Skip to main content

tycho_simulation/rfq/protocols/bebop/
all_pairs_state.rs

1use std::{any::Any, collections::HashMap, fmt, sync::Arc};
2
3use async_trait::async_trait;
4use num_bigint::BigUint;
5use serde::{Deserialize, Serialize};
6use tracing::debug;
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::protocols::bebop::{
19    client::BebopClient, models::BebopPriceData, state::BebopState,
20};
21
22/// Bebop's liquidity on one chain: one per-pair state per book, which prices every swap.
23///
24/// Bebop names no market maker and picks the makers behind a firm quote itself, so a swap marks
25/// the whole venue used and a later swap on that state finds no liquidity.
26#[derive(Clone, Serialize, Deserialize)]
27pub struct BebopAllPairsState {
28    /// Sorted by base token address, then quote token address.
29    pairs: Arc<Vec<BebopState>>,
30    /// Whether a swap on this state already took Bebop's quote.
31    used: bool,
32}
33
34impl fmt::Debug for BebopAllPairsState {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.debug_struct("BebopAllPairsState")
37            .field("pairs", &self.pairs.len())
38            .field("used", &self.used)
39            .finish()
40    }
41}
42
43impl BebopAllPairsState {
44    /// Skips a book with a token missing from `tokens` (`debug!`: the decoder logged the token).
45    pub fn new(
46        books: Vec<BebopPriceData>,
47        tokens: HashMap<Bytes, Token>,
48        client: BebopClient,
49    ) -> Self {
50        let mut pairs = Vec::with_capacity(books.len());
51        for book in books {
52            let (Some(base_token), Some(quote_token)) =
53                (book_token(&tokens, &book.base), book_token(&tokens, &book.quote))
54            else {
55                debug!(
56                    "Skipping Bebop book 0x{}/0x{}: a token is missing from the state.",
57                    hex::encode(&book.base),
58                    hex::encode(&book.quote)
59                );
60                continue;
61            };
62            pairs.push(BebopState::new(base_token, quote_token, book, client.clone()));
63        }
64        pairs.sort_by(|a, b| {
65            (&a.base_token.address, &a.quote_token.address)
66                .cmp(&(&b.base_token.address, &b.quote_token.address))
67        });
68        Self { pairs: Arc::new(pairs), used: false }
69    }
70
71    /// The state of the book with base `base` and quote `quote`, if any.
72    fn find(&self, base: &Bytes, quote: &Bytes) -> Option<&BebopState> {
73        let index = self.pairs.partition_point(|pair| {
74            (&pair.base_token.address, &pair.quote_token.address) < (base, quote)
75        });
76        self.pairs
77            .get(index)
78            .filter(|pair| &pair.base_token.address == base && &pair.quote_token.address == quote)
79    }
80
81    /// The state that trades `token_in` for `token_out`. A book quoting the pair as given beats
82    /// one quoting it the other way round.
83    fn pair_state(
84        &self,
85        token_in: &Bytes,
86        token_out: &Bytes,
87    ) -> Result<&BebopState, SimulationError> {
88        self.find(token_in, token_out)
89            .or_else(|| self.find(token_out, token_in))
90            .ok_or_else(|| {
91                SimulationError::RecoverableError(format!(
92                    "Invalid token addresses: {token_in}, {token_out}"
93                ))
94            })
95    }
96
97    fn check_unused(&self) -> Result<(), SimulationError> {
98        if self.used {
99            return Err(SimulationError::RecoverableError(
100                "Bebop already quoted in this route".into(),
101            ));
102        }
103        Ok(())
104    }
105
106    fn used_state(&self) -> Self {
107        Self { pairs: self.pairs.clone(), used: true }
108    }
109}
110
111/// The token at `address`, if `tokens` carries it.
112fn book_token(tokens: &HashMap<Bytes, Token>, address: &[u8]) -> Option<Token> {
113    tokens
114        .get(&Bytes::from(address.to_vec()))
115        .cloned()
116}
117
118#[typetag::serde]
119impl ProtocolSim for BebopAllPairsState {
120    fn fee(&self) -> f64 {
121        0.0
122    }
123
124    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
125        let pair_state = self.pair_state(&base.address, &quote.address)?;
126        self.check_unused()?;
127        pair_state.spot_price(base, quote)
128    }
129
130    fn get_amount_out(
131        &self,
132        amount_in: BigUint,
133        token_in: &Token,
134        token_out: &Token,
135    ) -> Result<GetAmountOutResult, SimulationError> {
136        let pair_state = self.pair_state(&token_in.address, &token_out.address)?;
137        self.check_unused()?;
138        // The per-pair state compares whole tokens, so it gets its own.
139        let (token_in, token_out) = if pair_state.base_token.address == token_in.address {
140            (&pair_state.base_token, &pair_state.quote_token)
141        } else {
142            (&pair_state.quote_token, &pair_state.base_token)
143        };
144        match pair_state.get_amount_out(amount_in, token_in, token_out) {
145            Ok(mut res) => {
146                res.new_state = Box::new(self.used_state());
147                Ok(res)
148            }
149            Err(SimulationError::InvalidInput(msg, Some(mut res))) => {
150                res.new_state = Box::new(self.used_state());
151                Err(SimulationError::InvalidInput(msg, Some(res)))
152            }
153            Err(e) => Err(e),
154        }
155    }
156
157    fn get_limits(
158        &self,
159        sell_token: Bytes,
160        buy_token: Bytes,
161    ) -> Result<(BigUint, BigUint), SimulationError> {
162        let pair_state = self.pair_state(&sell_token, &buy_token)?;
163        self.check_unused()?;
164        pair_state.get_limits(sell_token, buy_token)
165    }
166
167    fn delta_transition(
168        &mut self,
169        _delta: ProtocolStateDelta,
170        _tokens: &HashMap<Bytes, Token>,
171        _balances: &Balances,
172    ) -> Result<(), TransitionError> {
173        Err(TransitionError::DecodeError("Not implemented".into()))
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        let Some(other) = other
190            .as_any()
191            .downcast_ref::<BebopAllPairsState>()
192        else {
193            return false;
194        };
195        self.used == other.used &&
196            self.pairs.len() == other.pairs.len() &&
197            self.pairs
198                .iter()
199                .zip(other.pairs.iter())
200                .all(|(a, b)| a.price_data == b.price_data)
201    }
202
203    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
204        Ok(self)
205    }
206}
207
208#[async_trait]
209impl IndicativelyPriced for BebopAllPairsState {
210    async fn request_signed_quote(
211        &self,
212        params: GetAmountOutParams,
213    ) -> Result<SignedQuote, SimulationError> {
214        self.pair_state(&params.token_in, &params.token_out)?
215            .request_signed_quote(params)
216            .await
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use std::{collections::HashSet, str::FromStr};
223
224    use tokio::time::Duration;
225    use tycho_common::models::Chain;
226
227    use super::*;
228    use crate::rfq::protocols::test_utils::{usdc, wbtc, weth};
229
230    fn empty_bebop_client() -> BebopClient {
231        BebopClient::new(
232            Chain::Ethereum,
233            HashSet::new(),
234            0.0,
235            "".to_string(),
236            HashSet::new(),
237            Duration::from_secs(30),
238            None,
239            None,
240            None,
241        )
242        .unwrap()
243    }
244
245    fn book(base: &Token, quote: &Token, bids: &[f32], asks: &[f32]) -> BebopPriceData {
246        BebopPriceData {
247            base: base.address.to_vec(),
248            quote: quote.address.to_vec(),
249            last_update_ts: 1703097600,
250            bids: bids.to_vec(),
251            asks: asks.to_vec(),
252        }
253    }
254
255    fn state(books: Vec<BebopPriceData>) -> BebopAllPairsState {
256        BebopAllPairsState::new(
257            books,
258            HashMap::from([
259                (wbtc().address, wbtc()),
260                (usdc().address, usdc()),
261                (weth().address, weth()),
262            ]),
263            empty_bebop_client(),
264        )
265    }
266
267    /// WBTC/USDC and WETH/USDC books.
268    fn create_test_bebop_state() -> BebopAllPairsState {
269        state(vec![
270            book(
271                &wbtc(),
272                &usdc(),
273                &[65000.0, 1.5, 64950.0, 2.0, 64900.0, 0.5],
274                &[65100.0, 1.0, 65150.0, 2.5, 65200.0, 1.5],
275            ),
276            book(&weth(), &usdc(), &[3000.0, 2.0, 2900.0, 2.5], &[3100.0, 1.5, 3000.0, 3.0]),
277        ])
278    }
279
280    #[test]
281    fn test_book_quoting_the_direction_beats_the_inverted_one() {
282        // The USDC/WETH book pays 1/2048 WETH per USDC; the WETH/USDC book's asks sell 1 WETH for
283        // 3100 USDC.
284        let state = state(vec![
285            book(&weth(), &usdc(), &[3000.0, 2.0], &[3100.0, 1.5]),
286            book(&usdc(), &weth(), &[0.00048828125, 2000.0], &[]),
287        ]);
288        let result = state
289            .get_amount_out(BigUint::from(2_000_000_000u64), &usdc(), &weth())
290            .unwrap();
291        assert_eq!(result.amount, BigUint::from_str("0_976562500000000000").unwrap());
292    }
293
294    #[test]
295    fn test_get_limits_used_venue() {
296        let state = create_test_bebop_state().used_state();
297        let result = state.get_limits(wbtc().address, usdc().address);
298        assert!(
299            matches!(result, Err(SimulationError::RecoverableError(msg)) if msg == "Bebop already quoted in this route")
300        );
301    }
302
303    #[test]
304    fn test_get_limits_invalid_token_pair() {
305        let state = create_test_bebop_state();
306        let result = state.get_limits(wbtc().address, weth().address);
307        assert!(
308            matches!(result, Err(SimulationError::RecoverableError(msg)) if msg.contains("Invalid token addresses"))
309        );
310    }
311
312    #[test]
313    fn test_get_amount_out() {
314        let state = create_test_bebop_state();
315
316        // swap 3 WETH -> USDC: 6000 from level 1 + 2900 from level 2 = 8900 USDC
317        let amount_out_result = state
318            .get_amount_out(BigUint::from_str("3_000000000000000000").unwrap(), &weth(), &usdc())
319            .unwrap();
320        assert_eq!(amount_out_result.amount, BigUint::from_str("8900_000_000").unwrap());
321
322        // swap 7000 USDC -> WETH: 1.5 from level 1 + 0.78333 from level 2 = 2.283333 WETH
323        let amount_out_result = state
324            .get_amount_out(BigUint::from_str("7000_000_000").unwrap(), &usdc(), &weth())
325            .unwrap();
326        assert_eq!(amount_out_result.amount, BigUint::from_str("2_283333333333333248").unwrap());
327    }
328
329    #[test]
330    fn test_get_amount_out_once_per_venue() {
331        let state = create_test_bebop_state();
332        let first = state
333            .get_amount_out(BigUint::from_str("1_000000000000000000").unwrap(), &weth(), &usdc())
334            .unwrap();
335        let after_first = first
336            .new_state
337            .as_any()
338            .downcast_ref::<BebopAllPairsState>()
339            .unwrap();
340        assert!(after_first.used);
341
342        let second = after_first.get_amount_out(BigUint::from(100_000_000u64), &wbtc(), &usdc());
343        assert!(
344            matches!(second, Err(SimulationError::RecoverableError(msg)) if msg == "Bebop already quoted in this route")
345        );
346        let spot_price = after_first.spot_price(&wbtc(), &usdc());
347        assert!(
348            matches!(spot_price, Err(SimulationError::RecoverableError(msg)) if msg == "Bebop already quoted in this route")
349        );
350    }
351}