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            "".to_string(),
309            HashSet::new(),
310            Duration::from_secs(30),
311        )
312        .unwrap()
313    }
314
315    fn create_test_bebop_state() -> BebopState {
316        BebopState {
317            base_token: wbtc(),
318            quote_token: usdc(),
319            price_data: BebopPriceData {
320                base: hex::decode("2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(), // WBTC
321                quote: hex::decode("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), // USDC
322                last_update_ts: 1703097600,
323                bids: vec![65000.0f32, 1.5f32, 64950.0f32, 2.0f32, 64900.0f32, 0.5f32],
324                asks: vec![65100.0f32, 1.0f32, 65150.0f32, 2.5f32, 65200.0f32, 1.5f32],
325            },
326            client: empty_bebop_client(),
327        }
328    }
329
330    #[test]
331    fn test_spot_price_matching_base_and_quote() {
332        let state = create_test_bebop_state();
333
334        // Test WBTC/USDC (base/quote) - should use average of best bid and ask
335        let price = state
336            .spot_price(&wbtc(), &usdc())
337            .unwrap();
338        assert_eq!(price, 65050.0);
339    }
340
341    #[test]
342    fn test_spot_price_inverted_base_and_quote() {
343        let state = create_test_bebop_state();
344
345        // Test USDC/WBTC (quote/base) - should use average of best bid and ask, then invert
346        let price = state
347            .spot_price(&usdc(), &wbtc())
348            .unwrap();
349        let expected = 0.00001537279;
350        assert!((price - expected).abs() < 1e-10);
351    }
352
353    #[test]
354    fn test_spot_price_empty_asks() {
355        let mut state = create_test_bebop_state();
356        state.price_data.asks = vec![]; // Remove all asks
357
358        // Test WBTC/USDC with no asks - should use only best bid
359        let price = state
360            .spot_price(&wbtc(), &usdc())
361            .unwrap();
362        assert_eq!(price, 65000.0);
363    }
364
365    #[test]
366    fn test_spot_price_empty_bids() {
367        let mut state = create_test_bebop_state();
368        state.price_data.bids = vec![]; // Remove all bids
369                                        // Test WBTC/USDC with no bids - should use only best ask
370        let price = state
371            .spot_price(&wbtc(), &usdc())
372            .unwrap();
373        assert_eq!(price, 65100.0);
374    }
375
376    #[test]
377    fn test_spot_price_no_liquidity() {
378        let mut state = create_test_bebop_state();
379        state.price_data.bids = vec![]; // Remove all bids
380        state.price_data.asks = vec![]; // Remove all asks
381                                        // Test with no liquidity at all - should return error
382        let result = state.spot_price(&wbtc(), &usdc());
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn test_get_limits_sell_base_for_quote() {
388        let state = create_test_bebop_state();
389
390        // Test selling WBTC for USDC (should use bids)
391        let (wbtc_limit, usdc_limit) = state
392            .get_limits(wbtc().address.clone(), usdc().address.clone())
393            .unwrap();
394
395        // Use bids: vec![(65000.0, 1.5), (64950.0, 2.0), (64900.0, 0.5)]
396
397        // Total WBTC available: 1.5 + 2.0 + 0.5 = 4.0 WBTC
398        let expected_wbtc_limit = BigUint::from(4u64) * BigUint::from(10u64).pow(8u32);
399
400        // Total USDC value: (65000*1.5) + (64950*2.0) + (64900*0.5) = 97500 + 129900 + 32450 =
401        // 259850
402        let expected_usdc_limit = BigUint::from(259850u64) * BigUint::from(10u64).pow(6u32);
403
404        assert_eq!(wbtc_limit, expected_wbtc_limit);
405        assert_eq!(usdc_limit, expected_usdc_limit);
406    }
407
408    #[test]
409    fn test_get_limits_buy_base_with_quote() {
410        let state = create_test_bebop_state();
411
412        // Test buying WBTC with USDC (should use asks)
413        let (usdc_limit, wbtc_limit) = state
414            .get_limits(usdc().address.clone(), wbtc().address.clone())
415            .unwrap();
416
417        // Use asks: vec![(65100.0, 1.0), (65150.0, 2.5), (65200.0, 1.5)]
418
419        // Total USDC needed: (65100*1.0) + (65150*2.5) + (65200*1.5) = 65100 + 162875 + 97800 =
420        // 325775
421        let expected_usdc_limit = BigUint::from(325775u64) * BigUint::from(10u64).pow(6u32);
422
423        // Total WBTC available: 1.0 + 2.5 + 1.5 = 5.0 WBTC
424        let expected_wbtc_limit = BigUint::from(5u64) * BigUint::from(10u64).pow(8u32);
425
426        assert_eq!(usdc_limit, expected_usdc_limit);
427        assert_eq!(wbtc_limit, expected_wbtc_limit);
428    }
429
430    #[test]
431    fn test_get_limits_no_bids() {
432        let mut state = create_test_bebop_state();
433        state.price_data.bids = vec![]; // Remove all bids
434
435        // Test selling WBTC for USDC with no bids - should return 0
436        let (token_limit, quote_limit) = state
437            .get_limits(wbtc().address.clone(), usdc().address.clone())
438            .unwrap();
439
440        assert_eq!(token_limit, BigUint::from(0u64));
441        assert_eq!(quote_limit, BigUint::from(0u64));
442    }
443
444    #[test]
445    fn test_get_limits_no_asks() {
446        let mut state = create_test_bebop_state();
447        state.price_data.asks = vec![]; // Remove all asks
448
449        // Test buying WBTC with USDC with no asks - should return 0
450        let (token_limit, quote_limit) = state
451            .get_limits(usdc().address.clone(), wbtc().address.clone())
452            .unwrap();
453
454        assert_eq!(token_limit, BigUint::from(0u64));
455        assert_eq!(quote_limit, BigUint::from(0u64));
456    }
457
458    #[test]
459    fn test_get_limits_invalid_token_pair() {
460        let state = create_test_bebop_state();
461
462        // Create a different token (not WBTC or USDC)
463        let eth = Token::new(
464            &hex::decode("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
465                .unwrap()
466                .into(),
467            "ETH",
468            18,
469            0,
470            &[Some(10_000)],
471            Chain::Ethereum,
472            100,
473        );
474
475        // Test with invalid token pair (ETH not in WBTC/USDC pool) - should return error
476        let result = state.get_limits(eth.address.clone(), usdc().address.clone());
477        assert!(result.is_err());
478
479        if let Err(SimulationError::RecoverableError(msg)) = result {
480            assert!(msg.contains("Invalid token addresses"));
481        } else {
482            panic!("Expected RecoverableError with invalid token addresses message");
483        }
484    }
485
486    #[test]
487    fn test_get_amount_out() {
488        // WETH/USDC
489        let price_data = BebopPriceData {
490            base: hex::decode("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(), // WETH
491            quote: hex::decode("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), // USDC
492            last_update_ts: 1234567890,
493            bids: vec![3000.0f32, 2.0f32, 2900.0f32, 2.5f32],
494            asks: vec![3100.0f32, 1.5f32, 3000.0f32, 3.0f32],
495        };
496
497        let weth = weth();
498        let usdc = usdc();
499        let state = BebopState::new(weth.clone(), usdc.clone(), price_data, empty_bebop_client());
500
501        // swap 3 WETH -> USDC
502        let amount_out_result = state
503            .get_amount_out(BigUint::from_str("3_000000000000000000").unwrap(), &weth, &usdc)
504            .unwrap();
505
506        // 6000 from level 1 + 2900 from level 2 = 8900 USDC
507        assert_eq!(amount_out_result.amount, BigUint::from_str("8900_000_000").unwrap());
508
509        // swap 7000 USDC -> WETH
510        let amount_out_result = state
511            .get_amount_out(BigUint::from_str("7000_000_000").unwrap(), &usdc, &weth)
512            .unwrap();
513
514        // 1.5 from level 1 + 0.78333 from level 2 = 2.283333 WETH
515        assert_eq!(amount_out_result.amount, BigUint::from_str("2_283333333333333248").unwrap());
516    }
517}