Skip to main content

tycho_simulation/rfq/protocols/liquorice/
state.rs

1use std::{any::Any, collections::HashMap, fmt};
2
3use async_trait::async_trait;
4use num_bigint::BigUint;
5use num_traits::{FromPrimitive, Pow, ToPrimitive};
6use serde::{Deserialize, Serialize};
7use tycho_common::{
8    dto::ProtocolStateDelta,
9    models::{protocol::GetAmountOutParams, token::Token},
10    simulation::{
11        errors::{SimulationError, TransitionError},
12        indicatively_priced::{IndicativelyPriced, SignedQuote},
13        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
14    },
15    Bytes,
16};
17
18use crate::rfq::{
19    client::RFQClient,
20    models::fill_levels,
21    protocols::liquorice::{client::LiquoriceClient, models::LiquoriceTokenPairPrice},
22};
23
24#[derive(Clone, Serialize, Deserialize)]
25pub struct LiquoriceState {
26    pub base_token: Token,
27    pub quote_token: Token,
28    pub prices_by_mm: HashMap<String, LiquoriceTokenPairPrice>,
29    pub client: LiquoriceClient,
30}
31
32impl fmt::Debug for LiquoriceState {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        let mm_names: Vec<&String> = self.prices_by_mm.keys().collect();
35        f.debug_struct("LiquoriceState")
36            .field("base_token", &self.base_token)
37            .field("quote_token", &self.quote_token)
38            .field("market_makers", &mm_names)
39            .finish_non_exhaustive()
40    }
41}
42
43impl LiquoriceState {
44    pub fn new(
45        base_token: Token,
46        quote_token: Token,
47        prices_by_mm: HashMap<String, LiquoriceTokenPairPrice>,
48        client: LiquoriceClient,
49    ) -> Self {
50        Self { base_token, quote_token, prices_by_mm, client }
51    }
52
53    fn valid_direction_guard(
54        &self,
55        token_address_in: &Bytes,
56        token_address_out: &Bytes,
57    ) -> Result<(), SimulationError> {
58        if !(token_address_in == &self.base_token.address &&
59            token_address_out == &self.quote_token.address)
60        {
61            Err(SimulationError::InvalidInput(
62                format!("Invalid token addresses. Got in={token_address_in}, out={token_address_out}, expected in={}, out={}", self.base_token.address, self.quote_token.address),
63                None,
64            ))
65        } else {
66            Ok(())
67        }
68    }
69
70    fn valid_levels_guard(&self) -> Result<(), SimulationError> {
71        if self
72            .prices_by_mm
73            .values()
74            .all(|price| price.levels.is_empty())
75        {
76            return Err(SimulationError::RecoverableError("No liquidity".into()));
77        }
78        Ok(())
79    }
80}
81
82#[typetag::serde]
83impl ProtocolSim for LiquoriceState {
84    fn fee(&self) -> f64 {
85        todo!()
86    }
87
88    /// Returns the best available price across all market makers
89    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
90        self.valid_direction_guard(&base.address, &quote.address)?;
91
92        self.prices_by_mm
93            .values()
94            .filter_map(|price| price.get_price())
95            .reduce(f64::max)
96            .ok_or(SimulationError::RecoverableError("No liquidity".into()))
97    }
98
99    fn get_amount_out(
100        &self,
101        amount_in: BigUint,
102        token_in: &Token,
103        token_out: &Token,
104    ) -> Result<GetAmountOutResult, SimulationError> {
105        self.valid_direction_guard(&token_in.address, &token_out.address)?;
106        self.valid_levels_guard()?;
107
108        let amount_in = amount_in.to_f64().ok_or_else(|| {
109            SimulationError::RecoverableError("Can't convert amount in to f64".into())
110        })? / 10f64.powi(token_in.decimals as i32);
111
112        // Find out largest amount_out across all market makers for the given amount_in
113        let (amount_out, remaining_amount_in) = self
114            .prices_by_mm
115            .values()
116            .filter(|price| !price.levels.is_empty())
117            .map(|price| fill_levels(&price.levels, amount_in))
118            .max_by(|a, b| {
119                a.0.partial_cmp(&b.0)
120                    .unwrap_or(std::cmp::Ordering::Equal)
121            })
122            .ok_or(SimulationError::RecoverableError("No liquidity".into()))?;
123
124        let res = GetAmountOutResult {
125            amount: BigUint::from_f64(amount_out * 10f64.powi(token_out.decimals as i32))
126                .ok_or_else(|| {
127                    SimulationError::RecoverableError("Can't convert amount out to BigUInt".into())
128                })?,
129            gas: BigUint::from(134_000u64),
130            new_state: self.clone_box(),
131        };
132
133        if remaining_amount_in > 0.0 {
134            return Err(SimulationError::InvalidInput(
135                format!("Pool has not enough liquidity to support complete swap. Input amount: {amount_in}, consumed amount: {}", amount_in-remaining_amount_in),
136                Some(res)));
137        }
138
139        Ok(res)
140    }
141
142    fn get_limits(
143        &self,
144        sell_token: Bytes,
145        buy_token: Bytes,
146    ) -> Result<(BigUint, BigUint), SimulationError> {
147        self.valid_direction_guard(&sell_token, &buy_token)?;
148        self.valid_levels_guard()?;
149
150        let sell_decimals = self.base_token.decimals;
151        let buy_decimals = self.quote_token.decimals;
152        let (total_sell_amount, total_buy_amount) = self
153            .prices_by_mm
154            .values()
155            .filter(|price| !price.levels.is_empty())
156            .map(|price| {
157                price
158                    .levels
159                    .iter()
160                    .fold((0.0, 0.0), |(sell_sum, buy_sum), level| {
161                        (sell_sum + level.quantity, buy_sum + level.quantity * level.price)
162                    })
163            })
164            .max_by(|a, b| {
165                a.1.partial_cmp(&b.1)
166                    .unwrap_or(std::cmp::Ordering::Equal)
167            })
168            .ok_or(SimulationError::RecoverableError("No liquidity".into()))?;
169
170        let sell_limit =
171            BigUint::from((total_sell_amount * 10_f64.pow(sell_decimals as f64)) as u128);
172        let buy_limit = BigUint::from((total_buy_amount * 10_f64.pow(buy_decimals as f64)) as u128);
173
174        Ok((sell_limit, buy_limit))
175    }
176
177    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
178        Ok(self)
179    }
180
181    fn delta_transition(
182        &mut self,
183        _delta: ProtocolStateDelta,
184        _tokens: &HashMap<Bytes, Token>,
185        _balances: &Balances,
186    ) -> Result<(), TransitionError> {
187        todo!()
188    }
189
190    fn clone_box(&self) -> Box<dyn ProtocolSim> {
191        Box::new(self.clone())
192    }
193
194    fn as_any(&self) -> &dyn Any {
195        self
196    }
197
198    fn as_any_mut(&mut self) -> &mut dyn Any {
199        self
200    }
201
202    fn eq(&self, other: &dyn ProtocolSim) -> bool {
203        if let Some(other_state) = other
204            .as_any()
205            .downcast_ref::<LiquoriceState>()
206        {
207            self.base_token == other_state.base_token &&
208                self.quote_token == other_state.quote_token &&
209                self.prices_by_mm == other_state.prices_by_mm
210        } else {
211            false
212        }
213    }
214}
215
216#[async_trait]
217impl IndicativelyPriced for LiquoriceState {
218    async fn request_signed_quote(
219        &self,
220        params: GetAmountOutParams,
221    ) -> Result<SignedQuote, SimulationError> {
222        Ok(self
223            .client
224            .request_binding_quote(&params)
225            .await?)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use std::{collections::HashSet, str::FromStr};
232
233    use tokio::time::Duration;
234    use tycho_common::models::Chain;
235
236    use super::*;
237    use crate::rfq::protocols::liquorice::models::LiquoricePriceLevel;
238
239    fn wbtc() -> Token {
240        Token::new(
241            &hex::decode("2260fac5e5542a773aa44fbcfedf7c193bc2c599")
242                .unwrap()
243                .into(),
244            "WBTC",
245            8,
246            0,
247            &[Some(10_000)],
248            Chain::Ethereum,
249            100,
250        )
251    }
252
253    fn usdc() -> Token {
254        Token::new(
255            &hex::decode("a0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
256                .unwrap()
257                .into(),
258            "USDC",
259            6,
260            0,
261            &[Some(10_000)],
262            Chain::Ethereum,
263            100,
264        )
265    }
266
267    fn weth() -> Token {
268        Token::new(
269            &Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(),
270            "WETH",
271            18,
272            0,
273            &[],
274            Default::default(),
275            100,
276        )
277    }
278
279    fn empty_liquorice_client() -> LiquoriceClient {
280        LiquoriceClient::new(
281            Chain::Ethereum,
282            HashSet::new(),
283            0.0,
284            HashSet::new(),
285            "".to_string(),
286            "".to_string(),
287            Duration::from_secs(0),
288            Duration::from_secs(30),
289            300,
290        )
291        .unwrap()
292    }
293
294    fn create_test_liquorice_state() -> LiquoriceState {
295        let base_addr = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap();
296        let quote_addr = Bytes::from_str("0xa0b86991c6218a76c1d19d4a2e9eb0ce3606eb48").unwrap();
297        let mut prices_by_mm = HashMap::new();
298        prices_by_mm.insert(
299            "test_mm".to_string(),
300            LiquoriceTokenPairPrice {
301                base_token: base_addr.clone(),
302                quote_token: quote_addr.clone(),
303                levels: vec![
304                    LiquoricePriceLevel { quantity: 0.5, price: 3000.0 },
305                    LiquoricePriceLevel { quantity: 1.5, price: 3000.0 },
306                    LiquoricePriceLevel { quantity: 5.0, price: 2999.0 },
307                ],
308                updated_at: None,
309            },
310        );
311        prices_by_mm.insert(
312            "test_mm_2".to_string(),
313            LiquoriceTokenPairPrice {
314                base_token: base_addr.clone(),
315                quote_token: quote_addr.clone(),
316                levels: vec![LiquoricePriceLevel { quantity: 1.0, price: 2998.0 }],
317                updated_at: None,
318            },
319        );
320        LiquoriceState {
321            base_token: weth(),
322            quote_token: usdc(),
323            prices_by_mm,
324            client: empty_liquorice_client(),
325        }
326    }
327
328    mod spot_price {
329        use super::*;
330
331        #[test]
332        fn returns_best_price() {
333            let state = create_test_liquorice_state();
334            let price = state
335                .spot_price(&state.base_token, &state.quote_token)
336                .unwrap();
337            assert!((price - 20995.0 / 7.0).abs() < 1e-10);
338        }
339
340        #[test]
341        fn returns_invalid_input_error() {
342            let state = create_test_liquorice_state();
343            let result = state.spot_price(&wbtc(), &usdc());
344            assert!(result.is_err());
345            if let Err(SimulationError::InvalidInput(msg, _)) = result {
346                assert!(msg.contains("Invalid token addresses"));
347            } else {
348                panic!("Expected InvalidInput");
349            }
350        }
351
352        #[test]
353        fn returns_no_liquidity_error() {
354            let mut state = create_test_liquorice_state();
355            state
356                .prices_by_mm
357                .values_mut()
358                .for_each(|price| price.levels.clear());
359            let result = state.spot_price(&state.base_token, &state.quote_token);
360            assert!(result.is_err());
361            if let Err(SimulationError::RecoverableError(msg)) = result {
362                assert_eq!(msg, "No liquidity");
363            } else {
364                panic!("Expected RecoverableError");
365            }
366        }
367    }
368
369    mod get_amount_out {
370        use super::*;
371
372        #[test]
373        fn weth_to_usdc() {
374            let state = create_test_liquorice_state();
375
376            let amount_out_result = state
377                .get_amount_out(BigUint::from_str("1500000000000000000").unwrap(), &weth(), &usdc())
378                .unwrap();
379
380            assert_eq!(amount_out_result.amount, BigUint::from_str("4500000000").unwrap());
381            assert_eq!(amount_out_result.gas, BigUint::from(134_000u64));
382        }
383
384        #[test]
385        fn usdc_to_weth() {
386            let state = create_test_liquorice_state();
387
388            let result =
389                state.get_amount_out(BigUint::from_str("10000000000").unwrap(), &usdc(), &weth());
390
391            assert!(result.is_err());
392            if let Err(SimulationError::InvalidInput(msg, ..)) = result {
393                assert!(msg.contains("Invalid token addresses"));
394            } else {
395                panic!("Expected InvalidInput");
396            }
397        }
398
399        #[test]
400        fn insufficient_liquidity() {
401            let state = create_test_liquorice_state();
402
403            // Best single maker (test_mm) has 7.0 capacity, so 8 WETH exceeds it
404            let result = state.get_amount_out(
405                BigUint::from_str("8000000000000000000").unwrap(),
406                &weth(),
407                &usdc(),
408            );
409
410            assert!(result.is_err());
411            if let Err(SimulationError::InvalidInput(msg, _)) = result {
412                assert!(msg.contains("Pool has not enough liquidity"));
413            } else {
414                panic!("Expected InvalidInput");
415            }
416        }
417
418        #[test]
419        fn invalid_token_pair() {
420            let state = create_test_liquorice_state();
421
422            let result =
423                state.get_amount_out(BigUint::from_str("100000000").unwrap(), &wbtc(), &usdc());
424
425            assert!(result.is_err());
426            if let Err(SimulationError::InvalidInput(msg, ..)) = result {
427                assert!(msg.contains("Invalid token addresses"));
428            } else {
429                panic!("Expected InvalidInput");
430            }
431        }
432    }
433
434    mod get_limits {
435        use super::*;
436
437        #[test]
438        fn valid_limits() {
439            let state = create_test_liquorice_state();
440            let (sell_limit, buy_limit) = state
441                .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
442                .unwrap();
443
444            assert_eq!(sell_limit, BigUint::from((7.0 * 10f64.powi(18)) as u128));
445            assert_eq!(buy_limit, BigUint::from((20995.0 * 10f64.powi(6)) as u128));
446        }
447
448        #[test]
449        fn invalid_token_pair() {
450            let state = create_test_liquorice_state();
451            let result =
452                state.get_limits(wbtc().address.clone(), state.quote_token.address.clone());
453            assert!(result.is_err());
454            if let Err(SimulationError::InvalidInput(msg, _)) = result {
455                assert!(msg.contains("Invalid token addresses"));
456            } else {
457                panic!("Expected InvalidInput");
458            }
459        }
460    }
461}