Skip to main content

tycho_simulation/rfq/protocols/bebop/
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::bebop::{client::BebopClient, models::BebopPriceData},
21};
22
23#[derive(Clone, Serialize, Deserialize)]
24pub struct BebopState {
25    pub base_token: Token,
26    pub quote_token: Token,
27    pub price_data: BebopPriceData,
28    pub client: BebopClient,
29}
30
31impl fmt::Debug for BebopState {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_struct("BebopState")
34            .field("base_token", &self.base_token)
35            .field("quote_token", &self.quote_token)
36            .finish_non_exhaustive()
37    }
38}
39
40impl BebopState {
41    pub fn new(
42        base_token: Token,
43        quote_token: Token,
44        price_data: BebopPriceData,
45        client: BebopClient,
46    ) -> Self {
47        BebopState { base_token, quote_token, price_data, client }
48    }
49}
50
51#[typetag::serde]
52impl ProtocolSim for BebopState {
53    fn fee(&self) -> f64 {
54        0.0
55    }
56
57    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
58        // Since this method does not care about sell direction, we average the price of the best
59        // bid and ask
60        let best_bid = self
61            .price_data
62            .get_bids()
63            .first()
64            .map(|(price, _)| *price);
65        let best_ask = self
66            .price_data
67            .get_asks()
68            .first()
69            .map(|(price, _)| *price);
70
71        // If just one is available, only consider that one
72        let average_price = match (best_bid, best_ask) {
73            (Some(best_bid), Some(best_ask)) => (best_bid + best_ask) / 2.0,
74            (Some(best_bid), None) => best_bid,
75            (None, Some(best_ask)) => best_ask,
76            (None, None) => {
77                return Err(SimulationError::RecoverableError("No liquidity available".to_string()))
78            }
79        };
80
81        // If the base/quote token addresses are the opposite of the pool tokens, we need to invert
82        // the price
83        if base.address == self.quote_token.address && quote.address == self.base_token.address {
84            Ok(1.0 / average_price)
85        } else if quote.address == self.quote_token.address &&
86            base.address == self.base_token.address
87        {
88            Ok(average_price)
89        } else {
90            Err(SimulationError::RecoverableError(format!(
91                "Invalid token addresses: {}, {}",
92                base.address, quote.address
93            )))
94        }
95    }
96
97    fn get_amount_out(
98        &self,
99        amount_in: BigUint,
100        token_in: &Token,
101        token_out: &Token,
102    ) -> Result<GetAmountOutResult, SimulationError> {
103        let sell_base = if token_in == &self.base_token && token_out == &self.quote_token {
104            true
105        } else if token_in == &self.quote_token && token_out == &self.base_token {
106            false
107        } else {
108            return Err(SimulationError::RecoverableError(format!(
109                "Invalid token addresses: {}, {}",
110                token_in.address, token_out.address
111            )));
112        };
113        // if sell base is true -> use bids
114        // if sell base is false -> use asks AND amount is in quote token so the levels need to be
115        // adjusted
116        let price_levels = if sell_base {
117            self.price_data.get_bids()
118        } else {
119            self.price_data
120                .get_asks()
121                .iter()
122                .map(|(price, size)| (1.0 / price, price * size))
123                .collect()
124        };
125
126        if price_levels.is_empty() {
127            return Err(SimulationError::RecoverableError("No liquidity".into()));
128        }
129
130        let amount_in = amount_in.to_f64().ok_or_else(|| {
131            SimulationError::RecoverableError("Can't convert amount in to f64".into())
132        })? / 10f64.powi(token_in.decimals as i32);
133        let (amount_out, remaining_amount_in) = self
134            .price_data
135            .get_amount_out_from_levels(amount_in, price_levels);
136        let res = GetAmountOutResult {
137            amount: BigUint::from_f64(amount_out * 10f64.powi(token_out.decimals as i32))
138                .ok_or_else(|| {
139                    SimulationError::RecoverableError("Can't convert amount out to BigUInt".into())
140                })?,
141            gas: BigUint::from(70_000u64), // Rough gas estimation
142            new_state: self.clone_box(),   // The state doesn't change after a swap
143        };
144
145        if remaining_amount_in > 0.0 {
146            return Err(SimulationError::InvalidInput(
147                format!("Pool has not enough liquidity to support complete swap. input amount: {amount_in}, consumed amount: {}", amount_in-remaining_amount_in),
148                Some(res)));
149        }
150
151        Ok(res)
152    }
153
154    fn get_limits(
155        &self,
156        sell_token: Bytes,
157        buy_token: Bytes,
158    ) -> Result<(BigUint, BigUint), SimulationError> {
159        // If selling BASE for QUOTE, we need to look at [BASE/QUOTE].bids
160        // If buying BASE with QUOTE, we need to look at [BASE/QUOTE].asks
161        let (sell_decimals, buy_decimals, price_levels) = if sell_token == self.base_token.address &&
162            buy_token == self.quote_token.address
163        {
164            (self.base_token.decimals, self.quote_token.decimals, self.price_data.get_bids())
165        } else if buy_token == self.base_token.address && sell_token == self.quote_token.address {
166            (self.quote_token.decimals, self.base_token.decimals, self.price_data.get_asks())
167        } else {
168            return Err(SimulationError::RecoverableError(format!(
169                "Invalid token addresses: {sell_token}, {buy_token}"
170            )));
171        };
172
173        // If there are no price levels, return 0 for both limits
174        if price_levels.is_empty() {
175            return Ok((BigUint::from(0u64), BigUint::from(0u64)));
176        }
177
178        let total_base_amount: f64 = price_levels
179            .iter()
180            .map(|(_, amount)| amount)
181            .sum();
182        let total_quote_amount: f64 = price_levels
183            .iter()
184            .map(|(price, amount)| price * amount)
185            .sum();
186
187        let (total_sell_amount, total_buy_amount) =
188            if sell_token == self.base_token.address && buy_token == self.quote_token.address {
189                (total_base_amount, total_quote_amount)
190            } else {
191                (total_quote_amount, total_base_amount)
192            };
193
194        let sell_limit =
195            BigUint::from((total_sell_amount * 10_f64.pow(sell_decimals as f64)) as u128);
196        let buy_limit = BigUint::from((total_buy_amount * 10_f64.pow(buy_decimals as f64)) as u128);
197
198        Ok((sell_limit, buy_limit))
199    }
200
201    fn delta_transition(
202        &mut self,
203        _delta: ProtocolStateDelta,
204        _tokens: &HashMap<Bytes, Token>,
205        _balances: &Balances,
206    ) -> Result<(), TransitionError> {
207        Err(TransitionError::DecodeError("Not implemented".into()))
208    }
209
210    fn clone_box(&self) -> Box<dyn ProtocolSim> {
211        Box::new(self.clone())
212    }
213
214    fn as_any(&self) -> &dyn Any {
215        self
216    }
217
218    fn as_any_mut(&mut self) -> &mut dyn Any {
219        self
220    }
221
222    fn eq(&self, other: &dyn ProtocolSim) -> bool {
223        if let Some(other_state) = other
224            .as_any()
225            .downcast_ref::<BebopState>()
226        {
227            self.base_token == other_state.base_token &&
228                self.quote_token == other_state.quote_token &&
229                self.price_data == other_state.price_data
230        } else {
231            false
232        }
233    }
234
235    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
236        Ok(self)
237    }
238}
239
240#[async_trait]
241impl IndicativelyPriced for BebopState {
242    async fn request_signed_quote(
243        &self,
244        params: GetAmountOutParams,
245    ) -> Result<SignedQuote, SimulationError> {
246        Ok(self
247            .client
248            .request_binding_quote(&params)
249            .await?)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use std::{collections::HashSet, str::FromStr};
256
257    use tokio::time::Duration;
258    use tycho_common::models::Chain;
259
260    use super::*;
261
262    fn wbtc() -> Token {
263        Token::new(
264            &hex::decode("2260fac5e5542a773aa44fbcfedf7c193bc2c599")
265                .unwrap()
266                .into(),
267            "WBTC",
268            8,
269            0,
270            &[Some(10_000)],
271            Chain::Ethereum,
272            100,
273        )
274    }
275
276    fn usdc() -> Token {
277        Token::new(
278            &hex::decode("a0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
279                .unwrap()
280                .into(),
281            "USDC",
282            6,
283            0,
284            &[Some(10_000)],
285            Chain::Ethereum,
286            100,
287        )
288    }
289
290    fn weth() -> Token {
291        Token::new(
292            &Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(),
293            "WETH",
294            18,
295            0,
296            &[],
297            Default::default(),
298            100,
299        )
300    }
301
302    fn empty_bebop_client() -> BebopClient {
303        BebopClient::new(
304            Chain::Ethereum,
305            HashSet::new(),
306            0.0,
307            "".to_string(),
308            HashSet::new(),
309            Duration::from_secs(30),
310        )
311        .unwrap()
312    }
313
314    fn create_test_bebop_state() -> BebopState {
315        BebopState {
316            base_token: wbtc(),
317            quote_token: usdc(),
318            price_data: BebopPriceData {
319                base: hex::decode("2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(), // WBTC
320                quote: hex::decode("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), // USDC
321                last_update_ts: 1703097600,
322                bids: vec![65000.0f32, 1.5f32, 64950.0f32, 2.0f32, 64900.0f32, 0.5f32],
323                asks: vec![65100.0f32, 1.0f32, 65150.0f32, 2.5f32, 65200.0f32, 1.5f32],
324            },
325            client: empty_bebop_client(),
326        }
327    }
328
329    #[test]
330    fn test_spot_price_matching_base_and_quote() {
331        let state = create_test_bebop_state();
332
333        // Test WBTC/USDC (base/quote) - should use average of best bid and ask
334        let price = state
335            .spot_price(&wbtc(), &usdc())
336            .unwrap();
337        assert_eq!(price, 65050.0);
338    }
339
340    #[test]
341    fn test_spot_price_inverted_base_and_quote() {
342        let state = create_test_bebop_state();
343
344        // Test USDC/WBTC (quote/base) - should use average of best bid and ask, then invert
345        let price = state
346            .spot_price(&usdc(), &wbtc())
347            .unwrap();
348        let expected = 0.00001537279;
349        assert!((price - expected).abs() < 1e-10);
350    }
351
352    #[test]
353    fn test_spot_price_empty_asks() {
354        let mut state = create_test_bebop_state();
355        state.price_data.asks = vec![]; // Remove all asks
356
357        // Test WBTC/USDC with no asks - should use only best bid
358        let price = state
359            .spot_price(&wbtc(), &usdc())
360            .unwrap();
361        assert_eq!(price, 65000.0);
362    }
363
364    #[test]
365    fn test_spot_price_empty_bids() {
366        let mut state = create_test_bebop_state();
367        state.price_data.bids = vec![]; // Remove all bids
368                                        // Test WBTC/USDC with no bids - should use only best ask
369        let price = state
370            .spot_price(&wbtc(), &usdc())
371            .unwrap();
372        assert_eq!(price, 65100.0);
373    }
374
375    #[test]
376    fn test_spot_price_no_liquidity() {
377        let mut state = create_test_bebop_state();
378        state.price_data.bids = vec![]; // Remove all bids
379        state.price_data.asks = vec![]; // Remove all asks
380                                        // Test with no liquidity at all - should return error
381        let result = state.spot_price(&wbtc(), &usdc());
382        assert!(result.is_err());
383    }
384
385    #[test]
386    fn test_get_limits_sell_base_for_quote() {
387        let state = create_test_bebop_state();
388
389        // Test selling WBTC for USDC (should use bids)
390        let (wbtc_limit, usdc_limit) = state
391            .get_limits(wbtc().address.clone(), usdc().address.clone())
392            .unwrap();
393
394        // Use bids: vec![(65000.0, 1.5), (64950.0, 2.0), (64900.0, 0.5)]
395
396        // Total WBTC available: 1.5 + 2.0 + 0.5 = 4.0 WBTC
397        let expected_wbtc_limit = BigUint::from(4u64) * BigUint::from(10u64).pow(8u32);
398
399        // Total USDC value: (65000*1.5) + (64950*2.0) + (64900*0.5) = 97500 + 129900 + 32450 =
400        // 259850
401        let expected_usdc_limit = BigUint::from(259850u64) * BigUint::from(10u64).pow(6u32);
402
403        assert_eq!(wbtc_limit, expected_wbtc_limit);
404        assert_eq!(usdc_limit, expected_usdc_limit);
405    }
406
407    #[test]
408    fn test_get_limits_buy_base_with_quote() {
409        let state = create_test_bebop_state();
410
411        // Test buying WBTC with USDC (should use asks)
412        let (usdc_limit, wbtc_limit) = state
413            .get_limits(usdc().address.clone(), wbtc().address.clone())
414            .unwrap();
415
416        // Use asks: vec![(65100.0, 1.0), (65150.0, 2.5), (65200.0, 1.5)]
417
418        // Total USDC needed: (65100*1.0) + (65150*2.5) + (65200*1.5) = 65100 + 162875 + 97800 =
419        // 325775
420        let expected_usdc_limit = BigUint::from(325775u64) * BigUint::from(10u64).pow(6u32);
421
422        // Total WBTC available: 1.0 + 2.5 + 1.5 = 5.0 WBTC
423        let expected_wbtc_limit = BigUint::from(5u64) * BigUint::from(10u64).pow(8u32);
424
425        assert_eq!(usdc_limit, expected_usdc_limit);
426        assert_eq!(wbtc_limit, expected_wbtc_limit);
427    }
428
429    #[test]
430    fn test_get_limits_no_bids() {
431        let mut state = create_test_bebop_state();
432        state.price_data.bids = vec![]; // Remove all bids
433
434        // Test selling WBTC for USDC with no bids - should return 0
435        let (token_limit, quote_limit) = state
436            .get_limits(wbtc().address.clone(), usdc().address.clone())
437            .unwrap();
438
439        assert_eq!(token_limit, BigUint::from(0u64));
440        assert_eq!(quote_limit, BigUint::from(0u64));
441    }
442
443    #[test]
444    fn test_get_limits_no_asks() {
445        let mut state = create_test_bebop_state();
446        state.price_data.asks = vec![]; // Remove all asks
447
448        // Test buying WBTC with USDC with no asks - should return 0
449        let (token_limit, quote_limit) = state
450            .get_limits(usdc().address.clone(), wbtc().address.clone())
451            .unwrap();
452
453        assert_eq!(token_limit, BigUint::from(0u64));
454        assert_eq!(quote_limit, BigUint::from(0u64));
455    }
456
457    #[test]
458    fn test_get_limits_invalid_token_pair() {
459        let state = create_test_bebop_state();
460
461        // Create a different token (not WBTC or USDC)
462        let eth = Token::new(
463            &hex::decode("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
464                .unwrap()
465                .into(),
466            "ETH",
467            18,
468            0,
469            &[Some(10_000)],
470            Chain::Ethereum,
471            100,
472        );
473
474        // Test with invalid token pair (ETH not in WBTC/USDC pool) - should return error
475        let result = state.get_limits(eth.address.clone(), usdc().address.clone());
476        assert!(result.is_err());
477
478        if let Err(SimulationError::RecoverableError(msg)) = result {
479            assert!(msg.contains("Invalid token addresses"));
480        } else {
481            panic!("Expected RecoverableError with invalid token addresses message");
482        }
483    }
484
485    #[test]
486    fn test_get_amount_out() {
487        // WETH/USDC
488        let price_data = BebopPriceData {
489            base: hex::decode("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(), // WETH
490            quote: hex::decode("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), // USDC
491            last_update_ts: 1234567890,
492            bids: vec![3000.0f32, 2.0f32, 2900.0f32, 2.5f32],
493            asks: vec![3100.0f32, 1.5f32, 3000.0f32, 3.0f32],
494        };
495
496        let weth = weth();
497        let usdc = usdc();
498        let state = BebopState::new(weth.clone(), usdc.clone(), price_data, empty_bebop_client());
499
500        // swap 3 WETH -> USDC
501        let amount_out_result = state
502            .get_amount_out(BigUint::from_str("3_000000000000000000").unwrap(), &weth, &usdc)
503            .unwrap();
504
505        // 6000 from level 1 + 2900 from level 2 = 8900 USDC
506        assert_eq!(amount_out_result.amount, BigUint::from_str("8900_000_000").unwrap());
507
508        // swap 7000 USDC -> WETH
509        let amount_out_result = state
510            .get_amount_out(BigUint::from_str("7000_000_000").unwrap(), &usdc, &weth)
511            .unwrap();
512
513        // 1.5 from level 1 + 0.78333 from level 2 = 2.283333 WETH
514        assert_eq!(amount_out_result.amount, BigUint::from_str("2_283333333333333248").unwrap());
515    }
516}