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