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