Skip to main content

sendfun_sdk/launchpad/
quote.rs

1use crate::math::amm::{
2	self, AmmError, AmmInput, BuyArgs, QuoteError, SellArgs,
3};
4use crate::nexus::EffectiveFeeArgs;
5use crate::nexus::types::LaunchpadFees;
6use crate::utils::{
7	Landing, MarketQuote, MarketQuoteError, QuoteRequest, TradeDirection,
8	TradeMode,
9};
10
11#[derive(Clone, Copy, Debug)]
12pub struct CurveMarket<'a> {
13	pub virtual_base_reserves: u64,
14	pub virtual_quote_reserves: u64,
15	pub real_base_reserves: u64,
16	pub real_quote_reserves: u64,
17	pub created_at: i64,
18	/// `PartnerConfig.launchpad` of the trade's partner on the curve's
19	/// platform. It sets the protocol rate and the decay.
20	pub fees: &'a LaunchpadFees,
21	pub landing: Landing,
22	/// `BondingCurve.creator_fee_bps`, not the partner's
23	/// `max_creator_fee_bps`.
24	pub creator_fee_bps: u16,
25}
26
27/// Calculates a trade on a bonding curve. The price comes from the virtual
28/// reserves. The status is not checked: pass a `Funding` curve.
29pub fn quote(
30	market: &CurveMarket<'_>,
31	request: QuoteRequest,
32) -> Result<MarketQuote, MarketQuoteError> {
33	// The program refuses every trade here with `ThresholdReached`.
34	if market.real_base_reserves == 0 {
35		return Err(MarketQuoteError::SupplyExhausted);
36	}
37	let fee_bps = market
38		.fees
39		.effective_fee_bps(EffectiveFeeArgs {
40			creator_fee_bps: market.creator_fee_bps,
41			created_at: market.created_at,
42			now: market.landing.unix_timestamp,
43		})
44		.ok_or(MarketQuoteError::FeeOutOfRange)?;
45	let amm = AmmInput {
46		quote_reserves: market.virtual_quote_reserves,
47		base_reserves: market.virtual_base_reserves,
48		amount: request.amount,
49		fee_bps,
50	};
51	let Landing {
52		base_fee,
53		quote_fee,
54		..
55	} = market.landing;
56
57	match request.direction {
58		TradeDirection::Buy => {
59			let args = BuyArgs {
60				amm,
61				quote_fee,
62				base_fee,
63				base_reserve_cap: Some(market.real_base_reserves),
64			};
65			let bought = match request.mode {
66				TradeMode::ExactIn => amm::buy_exact_in_with_fees(args),
67				TradeMode::ExactOut => amm::buy_exact_out_with_fees(args),
68			}?;
69			Ok(MarketQuote::bought(&bought, request, fee_bps))
70		}
71		TradeDirection::Sell => {
72			let args = SellArgs {
73				amm,
74				quote_fee,
75				base_fee,
76			};
77			let sold = match request.mode {
78				TradeMode::ExactIn => amm::sell_exact_in_with_fees(args),
79				TradeMode::ExactOut => amm::sell_exact_out_with_fees(args),
80			}?;
81			// The virtual reserves can price more quote than the curve holds.
82			// The debit includes the fee.
83			let debit = sold
84				.quote_amount
85				.checked_add(sold.fee)
86				.ok_or(QuoteError::Amm(AmmError::Overflow))?;
87			if debit > market.real_quote_reserves {
88				return Err(MarketQuoteError::ExceedsQuoteHeld);
89			}
90			Ok(MarketQuote::sold(&sold, fee_bps))
91		}
92	}
93}
94
95#[cfg(test)]
96mod tests {
97	use super::*;
98	use crate::math::amm::MintFee;
99
100	const NOW: i64 = 1_000;
101	const ONE_SOL: u64 = 1_000_000_000;
102
103	/// The partner max differs from each curve's rate in the tests.
104	const FEES: LaunchpadFees = LaunchpadFees {
105		creation_fee_cents: 0,
106		protocol_fee_bps: 100,
107		max_creator_fee_bps: 50,
108		fee_decay_seconds: 0,
109		fee_decay_start_bps: 0,
110	};
111
112	const LANDING: Landing = Landing {
113		base_fee: None,
114		quote_fee: None,
115		unix_timestamp: NOW,
116	};
117
118	const MARKET: CurveMarket<'static> = CurveMarket {
119		virtual_base_reserves: 1_000_000_000_000_000,
120		virtual_quote_reserves: 30_000_000_000,
121		real_base_reserves: 500_000_000_000_000,
122		real_quote_reserves: 0,
123		created_at: 0,
124		fees: &FEES,
125		landing: LANDING,
126		creator_fee_bps: 0,
127	};
128
129	const BASE_100_BPS: MintFee = MintFee {
130		bps: 100,
131		maximum_fee: u64::MAX,
132	};
133
134	const fn request(
135		direction: TradeDirection,
136		mode: TradeMode,
137		amount: u64,
138	) -> QuoteRequest {
139		QuoteRequest {
140			direction,
141			mode,
142			amount,
143		}
144	}
145
146	#[test]
147	fn a_buy_prices_off_the_virtual_reserves() {
148		let quote = quote(
149			&MARKET,
150			request(TradeDirection::Buy, TradeMode::ExactIn, ONE_SOL),
151		)
152		.unwrap();
153		assert_eq!(quote.in_amount, ONE_SOL);
154		assert_eq!(quote.out_amount, 31_945_788_964_181);
155		assert_eq!(quote.fee, 10_000_000);
156		assert_eq!(quote.fee_bps, 100);
157		assert!(!quote.supply_capped);
158	}
159
160	#[test]
161	fn the_fee_decays_from_the_curves_creation() {
162		let decaying = LaunchpadFees {
163			fee_decay_seconds: 12,
164			fee_decay_start_bps: 5_000,
165			..FEES
166		};
167		let fresh = quote(
168			&CurveMarket {
169				created_at: NOW,
170				fees: &decaying,
171				..MARKET
172			},
173			request(TradeDirection::Buy, TradeMode::ExactIn, ONE_SOL),
174		)
175		.unwrap();
176		assert_eq!(fresh.fee_bps, 5_000);
177		assert_eq!(fresh.fee, 500_000_000);
178		assert_eq!(fresh.out_amount, 16_393_442_622_950);
179
180		assert_eq!(
181			quote(
182				&CurveMarket {
183					created_at: -1,
184					..MARKET
185				},
186				request(TradeDirection::Buy, TradeMode::ExactIn, ONE_SOL)
187			),
188			Err(MarketQuoteError::FeeOutOfRange)
189		);
190	}
191
192	/// A curve's rate can be above the partner max. A partner can lower its max
193	/// after the curve's creation.
194	#[test]
195	fn the_curves_creator_rate_prices_the_trade_not_the_partner_max() {
196		let decaying = LaunchpadFees {
197			fee_decay_seconds: 12,
198			fee_decay_start_bps: 5_000,
199			..FEES
200		};
201		let buy = request(TradeDirection::Buy, TradeMode::ExactIn, ONE_SOL);
202		// `(fee_bps, fee, out_amount)` past the decay window, then halfway
203		// through it.
204		for (creator_fee_bps, standard, halfway) in [
205			(
206				0,
207				(100, 10_000_000, 31_945_788_964_181),
208				(1_325, 132_500_000, 28_103_992_872_762),
209			),
210			(
211				30,
212				(130, 13_000_000, 31_852_066_995_836),
213				(1_348, 134_800_000, 28_031_569_534_621),
214			),
215			(
216				80,
217				(180, 18_000_000, 31_695_823_381_318),
218				(1_385, 138_500_000, 27_915_039_774_476),
219			),
220		] {
221			let market = CurveMarket {
222				creator_fee_bps,
223				fees: &decaying,
224				..MARKET
225			};
226			let past = quote(&market, buy).unwrap();
227			assert_eq!((past.fee_bps, past.fee, past.out_amount), standard);
228
229			let inside = quote(
230				&CurveMarket {
231					created_at: NOW - 6,
232					..market
233				},
234				buy,
235			)
236			.unwrap();
237			assert_eq!(
238				(inside.fee_bps, inside.fee, inside.out_amount),
239				halfway
240			);
241		}
242	}
243
244	#[test]
245	fn an_exact_in_buy_past_the_supply_left_fills_the_cap() {
246		let quote = quote(
247			&CurveMarket {
248				real_base_reserves: 1_000_000_000,
249				..MARKET
250			},
251			request(TradeDirection::Buy, TradeMode::ExactIn, ONE_SOL),
252		)
253		.unwrap();
254		assert_eq!(quote.out_amount, 1_000_000_000);
255		assert_eq!(quote.in_amount, 30_305);
256		assert!(quote.supply_capped);
257	}
258
259	/// With a base transfer fee, the cap applies to the base that leaves the
260	/// vault, not to the base that the buyer receives.
261	#[test]
262	fn an_exact_out_buy_past_the_supply_left_is_flagged() {
263		let wanted = 10_000_000_000_000;
264		let landing = Landing {
265			base_fee: Some(BASE_100_BPS),
266			..LANDING
267		};
268		let holding = |real_base_reserves| CurveMarket {
269			real_base_reserves,
270			landing,
271			..MARKET
272		};
273		let just_enough = quote(
274			&holding(10_101_010_101_011),
275			request(TradeDirection::Buy, TradeMode::ExactOut, wanted),
276		)
277		.unwrap();
278		assert_eq!(just_enough.out_amount, wanted);
279		assert!(!just_enough.supply_capped);
280
281		let one_short = quote(
282			&holding(10_101_010_101_010),
283			request(TradeDirection::Buy, TradeMode::ExactOut, wanted),
284		)
285		.unwrap();
286		assert_eq!(one_short.out_amount, 9_999_999_999_999);
287		assert!(one_short.supply_capped);
288	}
289
290	#[test]
291	fn a_sell_is_bounded_by_the_quote_the_curve_holds() {
292		let sell = request(
293			TradeDirection::Sell,
294			TradeMode::ExactIn,
295			10_000_000_000_000,
296		);
297		let sold = amm::sell_exact_in_with_fees(SellArgs {
298			amm: AmmInput {
299				quote_reserves: 30_000_000_000,
300				base_reserves: 1_000_000_000_000_000,
301				amount: 10_000_000_000_000,
302				fee_bps: 100,
303			},
304			quote_fee: None,
305			base_fee: None,
306		})
307		.unwrap();
308		let debit = sold.quote_amount + sold.fee;
309		assert_eq!(debit, 297_029_702);
310
311		let holding = |real_quote_reserves| CurveMarket {
312			real_quote_reserves,
313			..MARKET
314		};
315		let covered = quote(&holding(debit), sell).unwrap();
316		assert_eq!(covered.in_amount, 10_000_000_000_000);
317		assert_eq!(covered.out_amount, sold.quote_to_user);
318		assert!(!covered.supply_capped);
319		assert_eq!(
320			quote(&holding(debit - 1), sell),
321			Err(MarketQuoteError::ExceedsQuoteHeld)
322		);
323	}
324
325	#[test]
326	fn a_curve_with_no_supply_left_quotes_nothing() {
327		let complete = CurveMarket {
328			real_base_reserves: 0,
329			real_quote_reserves: 85_000_000_000,
330			..MARKET
331		};
332		for direction in [TradeDirection::Buy, TradeDirection::Sell] {
333			for mode in [TradeMode::ExactIn, TradeMode::ExactOut] {
334				assert_eq!(
335					quote(&complete, request(direction, mode, 1_000)),
336					Err(MarketQuoteError::SupplyExhausted)
337				);
338			}
339		}
340	}
341}