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 fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
90 self.valid_direction_guard(&base.address, "e.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 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(¶ms)
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 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}