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