tycho_simulation/rfq/protocols/hashflow/
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 tycho_common::{
7    dto::ProtocolStateDelta,
8    models::{protocol::GetAmountOutParams, token::Token},
9    simulation::{
10        errors::{SimulationError, TransitionError},
11        indicatively_priced::{IndicativelyPriced, SignedQuote},
12        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
13    },
14    Bytes,
15};
16
17use crate::rfq::{
18    client::RFQClient,
19    protocols::hashflow::{client::HashflowClient, models::HashflowMarketMakerLevels},
20};
21
22#[derive(Clone)]
23pub struct HashflowState {
24    pub base_token: Token,
25    pub quote_token: Token,
26    pub levels: HashflowMarketMakerLevels,
27    pub market_maker: String,
28    pub client: HashflowClient,
29}
30
31impl fmt::Debug for HashflowState {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_struct("HashflowState")
34            .field("base_token", &self.base_token)
35            .field("quote_token", &self.quote_token)
36            .field("market_maker", &self.market_maker)
37            .finish_non_exhaustive()
38    }
39}
40
41impl HashflowState {
42    pub fn new(
43        base_token: Token,
44        quote_token: Token,
45        levels: HashflowMarketMakerLevels,
46        market_maker: String,
47        client: HashflowClient,
48    ) -> Self {
49        Self { base_token, quote_token, levels, market_maker, client }
50    }
51
52    fn valid_direction_guard(
53        &self,
54        token_address_in: &Bytes,
55        token_address_out: &Bytes,
56    ) -> Result<(), SimulationError> {
57        // The current levels are only valid for the base/quote pair.
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.levels.levels.is_empty() {
72            return Err(SimulationError::RecoverableError("No liquidity".into()));
73        }
74        Ok(())
75    }
76}
77
78impl ProtocolSim for HashflowState {
79    fn fee(&self) -> f64 {
80        todo!()
81    }
82
83    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
84        self.valid_direction_guard(&base.address, &quote.address)?;
85
86        // Hashflow's levels are sorted by price, so the first level represents the best price.
87        self.levels
88            .levels
89            .first()
90            .ok_or(SimulationError::RecoverableError("No liquidity".into()))
91            .map(|level| level.price)
92    }
93
94    fn get_amount_out(
95        &self,
96        amount_in: BigUint,
97        token_in: &Token,
98        token_out: &Token,
99    ) -> Result<GetAmountOutResult, SimulationError> {
100        self.valid_direction_guard(&token_in.address, &token_out.address)?;
101        self.valid_levels_guard()?;
102
103        let amount_in = amount_in.to_f64().ok_or_else(|| {
104            SimulationError::RecoverableError("Can't convert amount in to f64".into())
105        })? / 10f64.powi(token_in.decimals as i32);
106
107        // First level represents the minimum amount that can be traded
108        let min_amount = self.levels.levels[0].quantity;
109        if amount_in < min_amount {
110            return Err(SimulationError::RecoverableError(format!(
111                "Amount below minimum. Input amount: {amount_in}, min amount: {min_amount}"
112            )));
113        }
114
115        // Calculate amount out
116        let (amount_out, remaining_amount_in) = self
117            .levels
118            .get_amount_out_from_levels(amount_in);
119
120        let res = GetAmountOutResult {
121            amount: BigUint::from_f64(amount_out * 10f64.powi(token_out.decimals as i32))
122                .ok_or_else(|| {
123                    SimulationError::RecoverableError("Can't convert amount out to BigUInt".into())
124                })?,
125            gas: BigUint::from(134_000u64), // Rough gas estimation
126            new_state: self.clone_box(),    // The state doesn't change after a swap
127        };
128
129        if remaining_amount_in > 0.0 {
130            return Err(SimulationError::InvalidInput(
131                format!("Pool has not enough liquidity to support complete swap. Input amount: {amount_in}, consumed amount: {}", amount_in-remaining_amount_in),
132                Some(res)));
133        }
134
135        Ok(res)
136    }
137
138    fn get_limits(
139        &self,
140        sell_token: Bytes,
141        buy_token: Bytes,
142    ) -> Result<(BigUint, BigUint), SimulationError> {
143        self.valid_direction_guard(&sell_token, &buy_token)?;
144        self.valid_levels_guard()?;
145
146        let sell_decimals = self.base_token.decimals;
147        let buy_decimals = self.quote_token.decimals;
148        let (total_sell_amount, total_buy_amount) =
149            self.levels
150                .levels
151                .iter()
152                .fold((0.0, 0.0), |(sell_sum, buy_sum), level| {
153                    (sell_sum + level.quantity, buy_sum + level.quantity * level.price)
154                });
155
156        let sell_limit =
157            BigUint::from((total_sell_amount * 10_f64.pow(sell_decimals as f64)) as u128);
158        let buy_limit = BigUint::from((total_buy_amount * 10_f64.pow(buy_decimals as f64)) as u128);
159
160        Ok((sell_limit, buy_limit))
161    }
162
163    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
164        Ok(self)
165    }
166
167    fn delta_transition(
168        &mut self,
169        _delta: ProtocolStateDelta,
170        _tokens: &HashMap<Bytes, Token>,
171        _balances: &Balances,
172    ) -> Result<(), TransitionError<String>> {
173        todo!()
174    }
175
176    fn clone_box(&self) -> Box<dyn ProtocolSim> {
177        Box::new(self.clone())
178    }
179
180    fn as_any(&self) -> &dyn Any {
181        self
182    }
183
184    fn as_any_mut(&mut self) -> &mut dyn Any {
185        self
186    }
187
188    fn eq(&self, other: &dyn ProtocolSim) -> bool {
189        if let Some(other_state) = other
190            .as_any()
191            .downcast_ref::<HashflowState>()
192        {
193            self.base_token == other_state.base_token &&
194                self.quote_token == other_state.quote_token &&
195                self.levels == other_state.levels
196        } else {
197            false
198        }
199    }
200}
201
202#[async_trait]
203impl IndicativelyPriced for HashflowState {
204    async fn request_signed_quote(
205        &self,
206        params: GetAmountOutParams,
207    ) -> Result<SignedQuote, SimulationError> {
208        Ok(self
209            .client
210            .request_binding_quote(&params)
211            .await?)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use std::{collections::HashSet, str::FromStr};
218
219    use tokio::time::Duration;
220    use tycho_common::models::Chain;
221
222    use super::*;
223    use crate::rfq::protocols::hashflow::models::{HashflowPair, HashflowPriceLevel};
224
225    fn wbtc() -> Token {
226        Token::new(
227            &hex::decode("2260fac5e5542a773aa44fbcfedf7c193bc2c599")
228                .unwrap()
229                .into(),
230            "WBTC",
231            8,
232            0,
233            &[Some(10_000)],
234            Chain::Ethereum,
235            100,
236        )
237    }
238
239    fn usdc() -> Token {
240        Token::new(
241            &hex::decode("a0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
242                .unwrap()
243                .into(),
244            "USDC",
245            6,
246            0,
247            &[Some(10_000)],
248            Chain::Ethereum,
249            100,
250        )
251    }
252
253    fn weth() -> Token {
254        Token::new(
255            &Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(),
256            "WETH",
257            18,
258            0,
259            &[],
260            Default::default(),
261            100,
262        )
263    }
264
265    fn empty_hashflow_client() -> HashflowClient {
266        HashflowClient::new(
267            Chain::Ethereum,
268            HashSet::new(),
269            0.0,
270            HashSet::new(),
271            "".to_string(),
272            "".to_string(),
273            Duration::from_secs(0),
274            Duration::from_secs(30),
275        )
276        .unwrap()
277    }
278
279    fn create_test_hashflow_state() -> HashflowState {
280        HashflowState {
281            base_token: weth(),
282            quote_token: usdc(),
283            levels: HashflowMarketMakerLevels {
284                pair: HashflowPair {
285                    base_token: Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
286                        .unwrap(),
287                    quote_token: Bytes::from_str("0xa0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
288                        .unwrap(),
289                },
290                levels: vec![
291                    HashflowPriceLevel { quantity: 0.5, price: 3000.0 },
292                    HashflowPriceLevel { quantity: 1.5, price: 3000.0 },
293                    HashflowPriceLevel { quantity: 5.0, price: 2999.0 },
294                ],
295            },
296            market_maker: "test_mm".to_string(),
297            client: empty_hashflow_client(),
298        }
299    }
300
301    mod spot_price {
302        use super::*;
303
304        #[test]
305        fn returns_best_price() {
306            let state = create_test_hashflow_state();
307            let price = state
308                .spot_price(&state.base_token, &state.quote_token)
309                .unwrap();
310            // The best price is the first level's price (3000.0)
311            assert_eq!(price, 3000.0);
312        }
313
314        #[test]
315        fn returns_invalid_input_error() {
316            let state = create_test_hashflow_state();
317            let result = state.spot_price(&wbtc(), &usdc());
318            assert!(result.is_err());
319            if let Err(SimulationError::InvalidInput(msg, _)) = result {
320                assert!(msg.contains("Invalid token addresses"));
321            } else {
322                panic!("Expected InvalidInput");
323            }
324        }
325
326        #[test]
327        fn returns_no_liquidity_error() {
328            let mut state = create_test_hashflow_state();
329            state.levels.levels.clear();
330            let result = state.spot_price(&state.base_token, &state.quote_token);
331            assert!(result.is_err());
332            if let Err(SimulationError::RecoverableError(msg)) = result {
333                assert_eq!(msg, "No liquidity");
334            } else {
335                panic!("Expected RecoverableError");
336            }
337        }
338    }
339
340    mod get_amount_out {
341        use super::*;
342
343        #[test]
344        fn wbtc_to_usdc() {
345            let state = create_test_hashflow_state();
346
347            // Test swapping 1.5 WETH -> USDC
348            // Should consume first level (0.5 WETH at 3000) + partial second level (1.0 WETH at
349            // 3000)
350            let amount_out_result = state
351                .get_amount_out(
352                    BigUint::from_str("1500000000000000000").unwrap(), // 1.5 WETH (18 decimals)
353                    &weth(),
354                    &usdc(),
355                )
356                .unwrap();
357
358            // Expected: (0.5 * 3000) + (1.0 * 3000) = 1500 + 3000 = 4500 USDC
359            assert_eq!(amount_out_result.amount, BigUint::from_str("4500000000").unwrap()); // 6 decimals
360            assert_eq!(amount_out_result.gas, BigUint::from(134_000u64));
361        }
362
363        #[test]
364        fn usdc_to_wbtc() {
365            let state = create_test_hashflow_state();
366
367            // Test swapping 10000 USDC -> WETH
368            // The price levels returned by Hashflow are only valid for the requested pair,
369            // and they can't be inverted to derive the reverse swap.
370            // In that case, we should return an error.
371            let result = state.get_amount_out(
372                BigUint::from_str("10000000000").unwrap(), // 10000 USDC (6 decimals)
373                &usdc(),
374                &weth(),
375            );
376
377            assert!(result.is_err());
378            if let Err(SimulationError::InvalidInput(msg, ..)) = result {
379                assert!(msg.contains("Invalid token addresses"));
380            } else {
381                panic!("Expected InvalidInput");
382            }
383        }
384
385        #[test]
386        fn below_minimum() {
387            let state = create_test_hashflow_state();
388
389            // Test with amount below minimum (first level quantity is 0.5 WETH)
390            let result = state.get_amount_out(
391                BigUint::from_str("250000000000000000").unwrap(), // 0.25 WETH (18 decimals)
392                &weth(),
393                &usdc(),
394            );
395
396            assert!(result.is_err());
397            if let Err(SimulationError::RecoverableError(msg)) = result {
398                assert!(msg.contains("Amount below minimum"));
399            } else {
400                panic!("Expected RecoverableError");
401            }
402        }
403
404        #[test]
405        fn insufficient_liquidity() {
406            let state = create_test_hashflow_state();
407
408            // Test with amount exceeding total liquidity (total is 7.0 WETH)
409            let result = state.get_amount_out(
410                BigUint::from_str("8000000000000000000").unwrap(), // 8.0 WETH (18 decimals)
411                &weth(),
412                &usdc(),
413            );
414
415            assert!(result.is_err());
416            if let Err(SimulationError::InvalidInput(msg, _)) = result {
417                assert!(msg.contains("Pool has not enough liquidity"));
418            } else {
419                panic!("Expected InvalidInput");
420            }
421        }
422
423        #[test]
424        fn invalid_token_pair() {
425            let state = create_test_hashflow_state();
426
427            // Test with invalid token pair (WBTC not in WETH/USDC pool)
428            let result = state.get_amount_out(
429                BigUint::from_str("100000000").unwrap(), // 1 WBTC
430                &wbtc(),
431                &usdc(),
432            );
433
434            assert!(result.is_err());
435            if let Err(SimulationError::InvalidInput(msg, ..)) = result {
436                assert!(msg.contains("Invalid token addresses"));
437            } else {
438                panic!("Expected InvalidInput");
439            }
440        }
441
442        #[test]
443        fn no_liquidity() {
444            let mut state = create_test_hashflow_state();
445            state.levels.levels = vec![]; // Remove all levels
446
447            let result = state.get_amount_out(
448                BigUint::from_str("1000000000000000000").unwrap(), // 1.0 WETH
449                &weth(),
450                &usdc(),
451            );
452
453            assert!(result.is_err());
454            if let Err(SimulationError::RecoverableError(msg)) = result {
455                assert_eq!(msg, "No liquidity");
456            } else {
457                panic!("Expected RecoverableError");
458            }
459        }
460    }
461
462    mod get_limits {
463        use super::*;
464
465        #[test]
466        fn valid_limits() {
467            let state = create_test_hashflow_state();
468            let (sell_limit, buy_limit) = state
469                .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
470                .unwrap();
471
472            // Total sell: 0.5 + 1.5 + 5.0 = 7.0 WETH (18 decimals)
473            // Total buy: (0.5+1.5)*3000 + 5.0*2999 = 20995 USDC (6 decimals)
474            assert_eq!(sell_limit, BigUint::from((7.0 * 10f64.powi(18)) as u128));
475            assert_eq!(buy_limit, BigUint::from((20995.0 * 10f64.powi(6)) as u128));
476        }
477
478        #[test]
479        fn invalid_token_pair() {
480            let state = create_test_hashflow_state();
481            let result =
482                state.get_limits(wbtc().address.clone(), state.quote_token.address.clone());
483            assert!(result.is_err());
484            if let Err(SimulationError::InvalidInput(msg, _)) = result {
485                assert!(msg.contains("Invalid token addresses"));
486            } else {
487                panic!("Expected InvalidInput");
488            }
489        }
490
491        #[test]
492        fn no_liquidity() {
493            let mut state = create_test_hashflow_state();
494            state.levels.levels = vec![];
495            let result = state
496                .get_limits(state.base_token.address.clone(), state.quote_token.address.clone());
497            assert!(result.is_err());
498            if let Err(SimulationError::RecoverableError(msg)) = result {
499                assert_eq!(msg, "No liquidity");
500            } else {
501                panic!("Expected RecoverableError");
502            }
503        }
504    }
505}