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