Skip to main content

tycho_simulation/rfq/protocols/native/
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, warn};
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::native::{
19    client::NativeClient, models::NativePriceData, state::NativeState,
20};
21
22/// Native Relay's liquidity on one chain: one per-pair state per book.
23///
24/// Native names no market maker, so a swap marks the whole venue used and a later swap on that
25/// state finds no liquidity.
26#[derive(Clone, Serialize, Deserialize)]
27pub struct NativeAllPairsState {
28    states: Arc<Vec<NativeState>>,
29    /// Every direction a book quotes, sorted, with the index of its state in `states`. A book
30    /// quoting the pair as given beats one quoting it the other way round.
31    directions: Arc<Vec<((Bytes, Bytes), usize)>>,
32    /// Whether a swap on this state already took Native's quote.
33    used: bool,
34}
35
36impl fmt::Debug for NativeAllPairsState {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.debug_struct("NativeAllPairsState")
39            .field("books", &self.states.len())
40            .field("used", &self.used)
41            .finish_non_exhaustive()
42    }
43}
44
45impl NativeAllPairsState {
46    /// Skips a book with a token missing from `tokens` (`debug!`: the decoder logged the token)
47    /// and a book its per-pair state rejects (`warn!`).
48    pub fn new(
49        books: Vec<NativePriceData>,
50        tokens: HashMap<Bytes, Token>,
51        client: NativeClient,
52    ) -> Self {
53        let mut states = Vec::with_capacity(books.len());
54        for book in books {
55            let (base_address, quote_address) =
56                (book.base_address.clone(), book.quote_address.clone());
57            let (Some(base_token), Some(quote_token)) =
58                (tokens.get(&base_address), tokens.get(&quote_address))
59            else {
60                debug!(
61                    "Skipping Native book {base_address}/{quote_address}: a token is missing from \
62                     the state."
63                );
64                continue;
65            };
66            match NativeState::new(base_token.clone(), quote_token.clone(), book, client.clone()) {
67                Ok(state) => states.push(state),
68                Err(e) => warn!(
69                    "Native book {base_address}/{quote_address} is invalid: {e}. Skipping it."
70                ),
71            }
72        }
73        let mut directions = HashMap::new();
74        for (index, state) in states.iter().enumerate() {
75            directions
76                .entry((state.book.base_address.clone(), state.book.quote_address.clone()))
77                .or_insert(index);
78        }
79        for (index, state) in states.iter().enumerate() {
80            directions
81                .entry((state.book.quote_address.clone(), state.book.base_address.clone()))
82                .or_insert(index);
83        }
84        let mut directions: Vec<_> = directions.into_iter().collect();
85        directions.sort();
86        Self { states: Arc::new(states), directions: Arc::new(directions), used: false }
87    }
88
89    /// The per-pair state that trades `token_in` for `token_out`.
90    fn pair_state(
91        &self,
92        token_in: &Bytes,
93        token_out: &Bytes,
94    ) -> Result<&NativeState, SimulationError> {
95        let index = self
96            .directions
97            .binary_search_by(|((a, b), _)| (a, b).cmp(&(token_in, token_out)))
98            .map_err(|_| {
99                SimulationError::InvalidInput(
100                    format!("Invalid token addresses. Got in={token_in}, out={token_out}"),
101                    None,
102                )
103            })?;
104        Ok(&self.states[self.directions[index].1])
105    }
106
107    /// The per-pair state that trades `token_in` for `token_out`, on a state no swap used.
108    fn quotable_pair_state(
109        &self,
110        token_in: &Bytes,
111        token_out: &Bytes,
112    ) -> Result<&NativeState, SimulationError> {
113        let state = self.pair_state(token_in, token_out)?;
114        if self.used {
115            return Err(SimulationError::RecoverableError(
116                "Native already quoted in this route".to_string(),
117            ));
118        }
119        Ok(state)
120    }
121
122    fn used_state(&self) -> Box<dyn ProtocolSim> {
123        Box::new(Self {
124            states: self.states.clone(),
125            directions: self.directions.clone(),
126            used: true,
127        })
128    }
129}
130
131#[typetag::serde]
132impl ProtocolSim for NativeAllPairsState {
133    fn fee(&self) -> f64 {
134        0.0
135    }
136
137    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
138        self.quotable_pair_state(&base.address, &quote.address)?
139            .spot_price(base, quote)
140    }
141
142    fn get_amount_out(
143        &self,
144        amount_in: BigUint,
145        token_in: &Token,
146        token_out: &Token,
147    ) -> Result<GetAmountOutResult, SimulationError> {
148        let state = self.quotable_pair_state(&token_in.address, &token_out.address)?;
149        match state.get_amount_out(amount_in, token_in, token_out) {
150            Ok(mut res) => {
151                res.new_state = self.used_state();
152                Ok(res)
153            }
154            Err(SimulationError::InvalidInput(message, Some(mut res))) => {
155                res.new_state = self.used_state();
156                Err(SimulationError::InvalidInput(message, Some(res)))
157            }
158            Err(e) => Err(e),
159        }
160    }
161
162    fn get_limits(
163        &self,
164        sell_token: Bytes,
165        buy_token: Bytes,
166    ) -> Result<(BigUint, BigUint), SimulationError> {
167        self.quotable_pair_state(&sell_token, &buy_token)?
168            .get_limits(sell_token, buy_token)
169    }
170
171    fn delta_transition(
172        &mut self,
173        _delta: ProtocolStateDelta,
174        _tokens: &HashMap<Bytes, Token>,
175        _balances: &Balances,
176    ) -> Result<(), TransitionError> {
177        Err(TransitionError::DecodeError("Not implemented".into()))
178    }
179
180    fn clone_box(&self) -> Box<dyn ProtocolSim> {
181        Box::new(self.clone())
182    }
183
184    fn as_any(&self) -> &dyn Any {
185        self
186    }
187
188    fn as_any_mut(&mut self) -> &mut dyn Any {
189        self
190    }
191
192    fn eq(&self, other: &dyn ProtocolSim) -> bool {
193        let Some(other) = other
194            .as_any()
195            .downcast_ref::<NativeAllPairsState>()
196        else {
197            return false;
198        };
199        self.used == other.used &&
200            self.states.len() == other.states.len() &&
201            self.states
202                .iter()
203                .zip(other.states.iter())
204                .all(|(a, b)| a.book == b.book)
205    }
206
207    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
208        Ok(self)
209    }
210}
211
212#[async_trait]
213impl IndicativelyPriced for NativeAllPairsState {
214    async fn request_signed_quote(
215        &self,
216        params: GetAmountOutParams,
217    ) -> Result<SignedQuote, SimulationError> {
218        self.pair_state(&params.token_in, &params.token_out)?
219            .request_signed_quote(params)
220            .await
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use std::collections::HashSet;
227
228    use tokio::time::Duration;
229    use tycho_common::models::Chain;
230
231    use super::*;
232    use crate::rfq::{
233        models::ComponentLayout,
234        protocols::{
235            native::models::NativePriceLevel,
236            test_utils::{token, usdc, weth},
237        },
238    };
239
240    fn book() -> NativePriceData {
241        NativePriceData {
242            base_address: weth().address,
243            quote_address: usdc().address,
244            minimum_in_base: 100_000_000_000.0,
245            minimum_in_quote: 100.0,
246            minimum_out_base: 0.0,
247            minimum_out_quote: 0.0,
248            bids: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
249            asks: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
250        }
251    }
252
253    fn state_with(books: Vec<NativePriceData>) -> NativeAllPairsState {
254        let client = NativeClient::new(
255            Chain::Ethereum,
256            String::new(),
257            HashSet::new(),
258            0.0,
259            HashSet::new(),
260            Duration::from_secs(5),
261            Duration::from_secs(5),
262        )
263        .unwrap()
264        .with_component_layout(ComponentLayout::AllPairs);
265        NativeAllPairsState::new(
266            books,
267            HashMap::from([(weth().address, weth()), (usdc().address, usdc())]),
268            client,
269        )
270    }
271
272    fn state() -> NativeAllPairsState {
273        state_with(vec![book()])
274    }
275
276    #[test]
277    fn once_per_venue() {
278        let state = state();
279        let first = state
280            .get_amount_out(BigUint::from(500_000_000_000_000_000u64), &weth(), &usdc())
281            .unwrap();
282        let after_first = first
283            .new_state
284            .as_any()
285            .downcast_ref::<NativeAllPairsState>()
286            .unwrap();
287        assert!(after_first.used);
288        assert!(matches!(
289            after_first.get_amount_out(BigUint::from(1_000_000_000u64), &usdc(), &weth()),
290            Err(SimulationError::RecoverableError(message)) if message == "Native already quoted in this route"
291        ));
292        assert!(matches!(
293            after_first.spot_price(&weth(), &usdc()),
294            Err(SimulationError::RecoverableError(message)) if message == "Native already quoted in this route"
295        ));
296        assert!(matches!(
297            after_first.get_limits(weth().address, usdc().address),
298            Err(SimulationError::RecoverableError(message)) if message == "Native already quoted in this route"
299        ));
300    }
301
302    #[test]
303    fn book_quoting_the_direction_beats_the_inverted_one() {
304        // The USDC/WETH book pays 1 WETH for 2000 USDC; the WETH/USDC book's asks sell 1 WETH for
305        // 2000 USDC too, but its minimum output would reject the swap.
306        let mut forward = book();
307        forward.minimum_out_base = 2_000_000_000_000_000_000.0;
308        let mut reverse = book();
309        reverse.base_address = usdc().address;
310        reverse.quote_address = weth().address;
311        reverse.minimum_in_base = 0.0;
312        reverse.bids = vec![NativePriceLevel { quantity: 2_000.0, price: 0.0005 }];
313        reverse.asks = vec![];
314        let state = state_with(vec![forward, reverse]);
315        let result = state
316            .get_amount_out(BigUint::from(2_000_000_000u64), &usdc(), &weth())
317            .unwrap();
318        assert_eq!(result.amount, BigUint::from(1_000_000_000_000_000_000u64));
319    }
320
321    #[test]
322    fn skips_book_its_state_rejects() {
323        let mut invalid = book();
324        invalid.minimum_in_base = f64::NAN;
325        let state = state_with(vec![invalid]);
326        let price = state.spot_price(&weth(), &usdc());
327        assert!(
328            matches!(price, Err(SimulationError::InvalidInput(msg, None)) if msg == format!("Invalid token addresses. Got in={}, out={}", weth().address, usdc().address))
329        );
330    }
331
332    #[test]
333    fn returns_partial_result_when_amount_exceeds_depth() {
334        let state = state();
335        let result =
336            state.get_amount_out(BigUint::from(2_000_000_000_000_000_000u64), &weth(), &usdc());
337        let Err(SimulationError::InvalidInput(_, Some(partial))) = result else {
338            panic!("Expected insufficient-liquidity result, got {result:?}");
339        };
340        assert_eq!(partial.amount, BigUint::from(2_000_000_000u64));
341        let new_state = partial
342            .new_state
343            .as_any()
344            .downcast_ref::<NativeAllPairsState>()
345            .unwrap();
346        assert!(new_state.used);
347    }
348
349    #[test]
350    fn rejects_invalid_pair() {
351        let other = token("0x1111111111111111111111111111111111111111", "OTHER", 18);
352        let state = state();
353        assert!(matches!(
354            state.get_amount_out(BigUint::from(1u64), &other, &usdc()),
355            Err(SimulationError::InvalidInput(_, None))
356        ));
357        assert!(matches!(
358            state.get_limits(other.address.clone(), usdc().address),
359            Err(SimulationError::InvalidInput(_, None))
360        ));
361        // Direction validation must win even when the book has no liquidity.
362        let mut empty = book();
363        empty.bids.clear();
364        empty.asks.clear();
365        let state = state_with(vec![empty]);
366        assert!(matches!(
367            state.spot_price(&other, &usdc()),
368            Err(SimulationError::InvalidInput(message, None))
369                if message.contains("Invalid token addresses")
370        ));
371    }
372
373    #[test]
374    fn reports_no_liquidity_for_empty_direction() {
375        let mut book = book();
376        book.bids.clear();
377        let state = state_with(vec![book]);
378        assert!(matches!(
379            state.get_amount_out(BigUint::from(500_000_000_000_000_000u64), &weth(), &usdc()),
380            Err(SimulationError::RecoverableError(_))
381        ));
382        assert!(matches!(
383            state.get_limits(weth().address, usdc().address),
384            Err(SimulationError::RecoverableError(_))
385        ));
386    }
387}