Skip to main content

riptide_amm_math/
quote.rs

1use super::SingleSideLiquidity;
2
3use super::error::{CoreError, ARITHMETIC_OVERFLOW, INVALID_ORACLE_DATA};
4
5use super::guards::{check_guards, check_oracle_validity, GuardParams};
6
7use super::oracle::{build_liquidity, build_price, consume_liquidity, OraclePayload};
8
9use borsh::BorshDeserialize;
10
11use riptide_amm_macros::alias;
12
13#[cfg(feature = "wasm")]
14use riptide_amm_macros::wasm_expose;
15
16#[derive(Debug, Clone, Copy, Eq, PartialEq)]
17#[cfg_attr(feature = "wasm", wasm_expose)]
18pub enum QuoteType {
19    TokenAExactIn,
20    TokenAExactOut,
21    TokenBExactIn,
22    TokenBExactOut,
23}
24
25impl QuoteType {
26    pub(crate) fn new(amount_is_token_a: bool, amount_is_input: bool) -> Self {
27        match (amount_is_token_a, amount_is_input) {
28            (true, true) => QuoteType::TokenAExactIn,
29            (true, false) => QuoteType::TokenAExactOut,
30            (false, true) => QuoteType::TokenBExactIn,
31            (false, false) => QuoteType::TokenBExactOut,
32        }
33    }
34
35    pub fn exact_in(&self) -> bool {
36        matches!(self, QuoteType::TokenAExactIn | QuoteType::TokenBExactIn)
37    }
38
39    pub fn exact_out(&self) -> bool {
40        matches!(self, QuoteType::TokenAExactOut | QuoteType::TokenBExactOut)
41    }
42
43    #[alias(output_is_token_b, a_to_b)]
44    pub fn input_is_token_a(&self) -> bool {
45        matches!(self, QuoteType::TokenAExactIn | QuoteType::TokenBExactOut)
46    }
47
48    #[alias(output_is_token_a, b_to_a)]
49    pub fn input_is_token_b(&self) -> bool {
50        matches!(self, QuoteType::TokenBExactIn | QuoteType::TokenAExactOut)
51    }
52}
53
54#[derive(Debug, Clone, Copy, Eq, PartialEq)]
55#[cfg_attr(feature = "wasm", wasm_expose)]
56pub struct Quote {
57    pub amount_in: u64,
58    pub amount_out: u64,
59    pub quote_type: QuoteType,
60}
61
62impl Quote {
63    pub fn apply_to_reserves(
64        &self,
65        reserves_a: u64,
66        reserves_b: u64,
67    ) -> Result<(u64, u64), CoreError> {
68        if self.quote_type.input_is_token_a() {
69            let post_a = reserves_a
70                .checked_add(self.amount_in)
71                .ok_or(ARITHMETIC_OVERFLOW)?;
72            let post_b = reserves_b
73                .checked_sub(self.amount_out)
74                .ok_or(ARITHMETIC_OVERFLOW)?;
75            Ok((post_a, post_b))
76        } else {
77            let post_a = reserves_a
78                .checked_sub(self.amount_out)
79                .ok_or(ARITHMETIC_OVERFLOW)?;
80            let post_b = reserves_b
81                .checked_add(self.amount_in)
82                .ok_or(ARITHMETIC_OVERFLOW)?;
83            Ok((post_a, post_b))
84        }
85    }
86}
87
88#[derive(Default, Debug, Clone, Eq, PartialEq)]
89#[cfg_attr(feature = "wasm", wasm_expose)]
90pub struct Prices {
91    pub oracle_price_q64_64: u128,
92    pub best_bid_price_q64_64: u128,
93    pub best_ask_price_q64_64: u128,
94    pub ask_spread_per_m: i32,
95    pub bid_spread_per_m: i32,
96}
97
98#[derive(Default, Debug, Clone, Eq, PartialEq)]
99#[cfg_attr(feature = "wasm", wasm_expose)]
100pub struct Price {
101    pub oracle_price_q64_64: u128,
102    pub best_price_q64_64: u128,
103    pub spread_per_m: i32,
104}
105
106#[derive(Debug, Clone, Eq, PartialEq)]
107#[cfg_attr(feature = "wasm", wasm_expose)]
108pub struct GuardedQuote {
109    pub quote: Quote,
110    pub post_reserves_a: u64,
111    pub post_reserves_b: u64,
112    pub price: Price,
113}
114
115pub type QuoteError = &'static str;
116
117#[derive(Debug)]
118struct InnerQuoteResult {
119    quote: Quote,
120    payload: OraclePayload,
121    liquidity: SingleSideLiquidity,
122}
123
124fn quote(
125    amount: u64,
126    amount_is_token_a: bool,
127    amount_is_input: bool,
128    oracle_data: &[u8],
129    reserves_a: u64,
130    reserves_b: u64,
131    skew_cliff_min_per_m: i32,
132    skew_cliff_max_per_m: i32,
133) -> Result<InnerQuoteResult, CoreError> {
134    let mut oracle_data = oracle_data;
135    let payload = OraclePayload::deserialize(&mut oracle_data).map_err(|_| INVALID_ORACLE_DATA)?;
136
137    let quote_type = QuoteType::new(amount_is_token_a, amount_is_input);
138
139    let liquidity = build_liquidity(
140        &payload,
141        quote_type,
142        reserves_a,
143        reserves_b,
144        skew_cliff_min_per_m,
145        skew_cliff_max_per_m,
146    )?;
147
148    let quote = consume_liquidity(amount, quote_type, &liquidity)?;
149
150    Ok(InnerQuoteResult {
151        quote,
152        payload,
153        liquidity,
154    })
155}
156
157#[cfg_attr(feature = "wasm", wasm_expose)]
158pub fn quote_exact_in(
159    amount: u64,
160    amount_is_token_a: bool,
161    oracle_data: &[u8],
162    reserves_a: u64,
163    reserves_b: u64,
164    skew_cliff_min_per_m: i32,
165    skew_cliff_max_per_m: i32,
166) -> Result<Quote, CoreError> {
167    quote(
168        amount,
169        amount_is_token_a,
170        true,
171        oracle_data,
172        reserves_a,
173        reserves_b,
174        skew_cliff_min_per_m,
175        skew_cliff_max_per_m,
176    )
177    .map(|r| r.quote)
178}
179
180#[cfg_attr(feature = "wasm", wasm_expose)]
181pub fn quote_exact_out(
182    amount: u64,
183    amount_is_token_a: bool,
184    oracle_data: &[u8],
185    reserves_a: u64,
186    reserves_b: u64,
187    skew_cliff_min_per_m: i32,
188    skew_cliff_max_per_m: i32,
189) -> Result<Quote, CoreError> {
190    quote(
191        amount,
192        amount_is_token_a,
193        false,
194        oracle_data,
195        reserves_a,
196        reserves_b,
197        skew_cliff_min_per_m,
198        skew_cliff_max_per_m,
199    )
200    .map(|r| r.quote)
201}
202
203fn quote_with_guards(
204    amount: u64,
205    amount_is_token_a: bool,
206    amount_is_input: bool,
207    oracle_data: &[u8],
208    reserves_a: u64,
209    reserves_b: u64,
210    skew_cliff_min_per_m: i32,
211    skew_cliff_max_per_m: i32,
212    current_slot: u64,
213    params: &GuardParams,
214) -> Result<GuardedQuote, QuoteError> {
215    check_oracle_validity(current_slot, params.valid_until)?;
216
217    let inner = quote(
218        amount,
219        amount_is_token_a,
220        amount_is_input,
221        oracle_data,
222        reserves_a,
223        reserves_b,
224        skew_cliff_min_per_m,
225        skew_cliff_max_per_m,
226    )?;
227
228    let quote_type = QuoteType::new(amount_is_token_a, amount_is_input);
229
230    let price = build_price(
231        &inner.liquidity,
232        &inner.payload.data,
233        quote_type,
234        reserves_a,
235        reserves_b,
236    )?;
237
238    let (post_reserves_a, post_reserves_b) =
239        inner.quote.apply_to_reserves(reserves_a, reserves_b)?;
240
241    check_guards(post_reserves_a, post_reserves_b, &price, params)?;
242
243    Ok(GuardedQuote {
244        quote: inner.quote,
245        post_reserves_a,
246        post_reserves_b,
247        price,
248    })
249}
250
251pub fn quote_exact_in_with_guards(
252    amount: u64,
253    amount_is_token_a: bool,
254    oracle_data: &[u8],
255    reserves_a: u64,
256    reserves_b: u64,
257    skew_cliff_min_per_m: i32,
258    skew_cliff_max_per_m: i32,
259    current_slot: u64,
260    params: &GuardParams,
261) -> Result<GuardedQuote, QuoteError> {
262    quote_with_guards(
263        amount,
264        amount_is_token_a,
265        true,
266        oracle_data,
267        reserves_a,
268        reserves_b,
269        skew_cliff_min_per_m,
270        skew_cliff_max_per_m,
271        current_slot,
272        params,
273    )
274}
275
276pub fn quote_exact_out_with_guards(
277    amount: u64,
278    amount_is_token_a: bool,
279    oracle_data: &[u8],
280    reserves_a: u64,
281    reserves_b: u64,
282    skew_cliff_min_per_m: i32,
283    skew_cliff_max_per_m: i32,
284    current_slot: u64,
285    params: &GuardParams,
286) -> Result<GuardedQuote, QuoteError> {
287    quote_with_guards(
288        amount,
289        amount_is_token_a,
290        false,
291        oracle_data,
292        reserves_a,
293        reserves_b,
294        skew_cliff_min_per_m,
295        skew_cliff_max_per_m,
296        current_slot,
297        params,
298    )
299}
300
301#[cfg_attr(feature = "wasm", wasm_expose)]
302pub fn bid_price(
303    oracle_data: &[u8],
304    liquidity: SingleSideLiquidity,
305    reserves_a: u64,
306    reserves_b: u64,
307) -> Result<Price, CoreError> {
308    let mut oracle_data = oracle_data;
309    let payload = OraclePayload::deserialize(&mut oracle_data).map_err(|_| INVALID_ORACLE_DATA)?;
310    build_price(
311        &liquidity,
312        &payload.data,
313        QuoteType::TokenAExactIn,
314        reserves_a,
315        reserves_b,
316    )
317}
318
319#[cfg_attr(feature = "wasm", wasm_expose)]
320pub fn ask_price(
321    oracle_data: &[u8],
322    liquidity: SingleSideLiquidity,
323    reserves_a: u64,
324    reserves_b: u64,
325) -> Result<Price, CoreError> {
326    let mut oracle_data = oracle_data;
327    let payload = OraclePayload::deserialize(&mut oracle_data).map_err(|_| INVALID_ORACLE_DATA)?;
328    build_price(
329        &liquidity,
330        &payload.data,
331        QuoteType::TokenBExactIn,
332        reserves_a,
333        reserves_b,
334    )
335}
336
337#[cfg_attr(feature = "wasm", wasm_expose)]
338pub fn bid_liquidity(
339    oracle_data: &[u8],
340    reserves_a: u64,
341    reserves_b: u64,
342    skew_cliff_min_per_m: i32,
343    skew_cliff_max_per_m: i32,
344) -> Result<SingleSideLiquidity, CoreError> {
345    let mut oracle_data = oracle_data;
346    let payload = OraclePayload::deserialize(&mut oracle_data).map_err(|_| INVALID_ORACLE_DATA)?;
347    build_liquidity(
348        &payload,
349        QuoteType::TokenAExactIn,
350        reserves_a,
351        reserves_b,
352        skew_cliff_min_per_m,
353        skew_cliff_max_per_m,
354    )
355}
356
357#[cfg_attr(feature = "wasm", wasm_expose)]
358pub fn ask_liquidity(
359    oracle_data: &[u8],
360    reserves_a: u64,
361    reserves_b: u64,
362    skew_cliff_min_per_m: i32,
363    skew_cliff_max_per_m: i32,
364) -> Result<SingleSideLiquidity, CoreError> {
365    let mut oracle_data = oracle_data;
366    let payload = OraclePayload::deserialize(&mut oracle_data).map_err(|_| INVALID_ORACLE_DATA)?;
367    build_liquidity(
368        &payload,
369        QuoteType::TokenBExactIn,
370        reserves_a,
371        reserves_b,
372        skew_cliff_min_per_m,
373        skew_cliff_max_per_m,
374    )
375}
376
377#[cfg(test)]
378mod tests {
379    use super::{
380        super::guards::{
381            INVENTORY_IMBALANCE, ORACLE_EXPIRED, ORACLE_PRICE_BELOW_MIN, SPREAD_BELOW_MIN,
382        },
383        *,
384    };
385    use borsh::BorshSerialize;
386    use rstest::rstest;
387
388    use super::super::oracle::{
389        OracleData, SkewMode, ORACLE_DATA_LEN, ORACLE_PAYLOAD_LEN, SKEW_OFFSET,
390    };
391
392    fn flat_oracle_data(price_q64_64: u128) -> [u8; ORACLE_PAYLOAD_LEN] {
393        let data = OracleData::FlatPrice { price_q64_64 };
394        let skew = SkewMode::None;
395
396        let mut buf = [0u8; ORACLE_PAYLOAD_LEN];
397
398        let mut data_slice = &mut buf[..ORACLE_DATA_LEN];
399        data.serialize(&mut data_slice).unwrap();
400
401        let mut skew_slice = &mut buf[SKEW_OFFSET..];
402        skew.serialize(&mut skew_slice).unwrap();
403
404        buf
405    }
406
407    fn pass_through_params() -> GuardParams {
408        GuardParams {
409            max_inventory_imbalance_per_m: i32::MAX,
410            max_a_inventory_per_m: 0,
411            max_b_inventory_per_m: 0,
412            min_spread_per_m: i32::MIN,
413            min_oracle_price: 0,
414            max_oracle_price: u128::MAX,
415            valid_until: u64::MAX,
416        }
417    }
418
419    #[rstest]
420    #[case::a_to_b_ok(100, 80, QuoteType::TokenAExactIn, 1000, 1000, Ok((1100, 920)))]
421    #[case::b_to_a_ok(80, 100, QuoteType::TokenBExactIn, 1000, 1000, Ok((900, 1080)))]
422    #[case::a_to_b_exact_out(100, 80, QuoteType::TokenBExactOut, 1000, 1000, Ok((1100, 920)))]
423    #[case::b_to_a_exact_out(80, 100, QuoteType::TokenAExactOut, 1000, 1000, Ok((900, 1080)))]
424    #[case::output_underflow_a(
425        100,
426        2000,
427        QuoteType::TokenAExactIn,
428        1000,
429        1000,
430        Err(ARITHMETIC_OVERFLOW)
431    )]
432    #[case::output_underflow_b(
433        100,
434        2000,
435        QuoteType::TokenBExactIn,
436        1000,
437        1000,
438        Err(ARITHMETIC_OVERFLOW)
439    )]
440    #[case::input_overflow_a(
441        1,
442        0,
443        QuoteType::TokenAExactIn,
444        u64::MAX,
445        1000,
446        Err(ARITHMETIC_OVERFLOW)
447    )]
448    #[case::input_overflow_b(
449        1,
450        0,
451        QuoteType::TokenBExactIn,
452        1000,
453        u64::MAX,
454        Err(ARITHMETIC_OVERFLOW)
455    )]
456    fn test_apply_to_reserves(
457        #[case] amount_in: u64,
458        #[case] amount_out: u64,
459        #[case] quote_type: QuoteType,
460        #[case] reserves_a: u64,
461        #[case] reserves_b: u64,
462        #[case] expected: Result<(u64, u64), CoreError>,
463    ) {
464        let quote = Quote {
465            amount_in,
466            amount_out,
467            quote_type,
468        };
469
470        let result = quote.apply_to_reserves(reserves_a, reserves_b);
471
472        assert_eq!(result, expected);
473    }
474
475    #[test]
476    fn test_guards_happy_path() {
477        let oracle = flat_oracle_data(1 << 64);
478        let params = pass_through_params();
479
480        let result = quote_exact_in_with_guards(100, true, &oracle, 1000, 1000, 0, 0, 0, &params);
481
482        let guarded = result.unwrap();
483        assert_eq!(guarded.quote.amount_in, 100);
484        assert_eq!(guarded.quote.amount_out, 100);
485        assert_eq!(guarded.post_reserves_a, 1100);
486        assert_eq!(guarded.post_reserves_b, 900);
487    }
488
489    #[test]
490    fn test_guards_oracle_expired() {
491        let oracle = flat_oracle_data(1 << 64);
492        let params = GuardParams {
493            valid_until: 5,
494            ..pass_through_params()
495        };
496
497        let result = quote_exact_in_with_guards(100, true, &oracle, 1000, 1000, 0, 0, 10, &params);
498
499        assert_eq!(result, Err(ORACLE_EXPIRED));
500    }
501
502    #[test]
503    fn test_guards_inventory_guard_fail() {
504        let oracle = flat_oracle_data(1 << 64);
505        let params = GuardParams {
506            max_inventory_imbalance_per_m: 10_000,
507            ..pass_through_params()
508        };
509
510        let result = quote_exact_in_with_guards(100, true, &oracle, 1500, 500, 0, 0, 0, &params);
511
512        assert_eq!(result, Err(INVENTORY_IMBALANCE));
513    }
514
515    #[test]
516    fn test_guards_spread_guard_fail() {
517        let oracle = flat_oracle_data(1 << 64);
518        let params = GuardParams {
519            min_spread_per_m: 100,
520            ..pass_through_params()
521        };
522
523        let result = quote_exact_in_with_guards(100, true, &oracle, 1000, 1000, 0, 0, 0, &params);
524
525        assert_eq!(result, Err(SPREAD_BELOW_MIN));
526    }
527
528    #[test]
529    fn test_guards_price_below_min() {
530        let price = 1u128 << 64;
531        let oracle = flat_oracle_data(price);
532        let params = GuardParams {
533            min_oracle_price: price + 1,
534            ..pass_through_params()
535        };
536
537        let result = quote_exact_in_with_guards(100, true, &oracle, 1000, 1000, 0, 0, 0, &params);
538
539        assert_eq!(result, Err(ORACLE_PRICE_BELOW_MIN));
540    }
541
542    #[test]
543    fn test_guards_invalid_oracle() {
544        let params = pass_through_params();
545
546        let result = quote_exact_in_with_guards(100, true, &[0u8; 4], 1000, 1000, 0, 0, 0, &params);
547
548        assert_eq!(result, Err(INVALID_ORACLE_DATA));
549    }
550}