Skip to main content

tycho_simulation/rfq/protocols/native/
state.rs

1use std::{any::Any, collections::HashMap, fmt};
2
3use async_trait::async_trait;
4use num_bigint::BigUint;
5use num_traits::{FromPrimitive, 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::native::{client::NativeClient, models::NativePriceData},
21};
22
23// `Deserialize` bypasses `new`, so it does not validate the book or remove zero-quantity levels.
24// This is harmless while `delta_transition` cannot update the book; use `TryFrom`-based
25// deserialization before supporting state deltas.
26#[derive(Clone, Serialize, Deserialize)]
27pub struct NativeState {
28    pub base_token: Token,
29    pub quote_token: Token,
30    pub book: NativePriceData,
31    pub client: NativeClient,
32}
33
34impl fmt::Debug for NativeState {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.debug_struct("NativeState")
37            .field("base_token", &self.base_token)
38            .field("quote_token", &self.quote_token)
39            .finish_non_exhaustive()
40    }
41}
42
43impl NativeState {
44    pub fn new(
45        base_token: Token,
46        quote_token: Token,
47        mut book: NativePriceData,
48        client: NativeClient,
49    ) -> Result<Self, SimulationError> {
50        // Zero-quantity levels carry no liquidity and must not influence spot prices or limits.
51        book.bids
52            .retain(|level| level.quantity != 0.0);
53        book.asks
54            .retain(|level| level.quantity != 0.0);
55        let state = NativeState { base_token, quote_token, book, client };
56        state.validate_book()?;
57        Ok(state)
58    }
59
60    fn validate_book(&self) -> Result<(), SimulationError> {
61        if self.book.base_address != self.base_token.address ||
62            self.book.quote_address != self.quote_token.address
63        {
64            return Err(SimulationError::FatalError(
65                "Native book token addresses do not match state tokens".to_string(),
66            ));
67        }
68        let minimums = [
69            self.book.minimum_in_base,
70            self.book.minimum_in_quote,
71            self.book.minimum_out_base,
72            self.book.minimum_out_quote,
73        ];
74        if minimums
75            .iter()
76            .any(|minimum| !minimum.is_finite() || *minimum < 0.0)
77        {
78            return Err(SimulationError::FatalError(
79                "Native book contains an invalid minimum amount".to_string(),
80            ));
81        }
82        if self
83            .book
84            .bids
85            .iter()
86            .chain(self.book.asks.iter())
87            .any(|level| {
88                !level.quantity.is_finite() ||
89                    level.quantity < 0.0 ||
90                    !level.price.is_finite() ||
91                    level.price <= 0.0
92            })
93        {
94            return Err(SimulationError::FatalError(
95                "Native book contains an invalid price level".to_string(),
96            ));
97        }
98
99        Ok(())
100    }
101
102    fn enforce_minimum(
103        amount: &BigUint,
104        minimum: f64,
105        amount_kind: &str,
106    ) -> Result<(), SimulationError> {
107        if minimum == 0.0 {
108            return Ok(())
109        }
110
111        // Native Relay reports minimums in atomic units, matching `amount`. Round up defensively
112        // because the JSON number is deserialized into an f64 by the orderbook model.
113        let minimum = BigUint::from_f64(minimum.ceil()).ok_or_else(|| {
114            SimulationError::FatalError(format!(
115                "Can't convert Native minimum {amount_kind} amount to BigUint"
116            ))
117        })?;
118        if amount < &minimum {
119            return Err(SimulationError::RecoverableError(format!(
120                "Amount below minimum {amount_kind}. Amount: {amount}, min amount: {minimum}"
121            )))
122        }
123
124        Ok(())
125    }
126}
127
128#[typetag::serde]
129impl ProtocolSim for NativeState {
130    fn fee(&self) -> f64 {
131        0.0
132    }
133
134    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
135        let inverse = if base.address == self.base_token.address &&
136            quote.address == self.quote_token.address
137        {
138            false
139        } else if base.address == self.quote_token.address &&
140            quote.address == self.base_token.address
141        {
142            true
143        } else {
144            return Err(SimulationError::RecoverableError(format!(
145                "Invalid token addresses: {}, {}",
146                base.address, quote.address
147            )))
148        };
149
150        let best_bid = self
151            .book
152            .bids
153            .first()
154            .map(|lvl| lvl.price);
155        let best_ask = self
156            .book
157            .asks
158            .first()
159            .map(|lvl| lvl.price);
160
161        let average_price = match (best_bid, best_ask) {
162            (Some(bid), Some(ask)) => bid.midpoint(ask),
163            (Some(bid), None) => bid,
164            (None, Some(ask)) => ask,
165            (None, None) => {
166                return Err(SimulationError::RecoverableError("No liquidity".to_string()))
167            }
168        };
169
170        let spot_price = if inverse { average_price.recip() } else { average_price };
171
172        if !spot_price.is_finite() || spot_price <= 0.0 {
173            return Err(SimulationError::RecoverableError(
174                "Native spot price is not positive and finite".to_string(),
175            ))
176        }
177
178        Ok(spot_price)
179    }
180
181    fn get_amount_out(
182        &self,
183        amount_in: BigUint,
184        token_in: &Token,
185        token_out: &Token,
186    ) -> Result<GetAmountOutResult, SimulationError> {
187        let is_sell_base = token_in.address == self.base_token.address &&
188            token_out.address == self.quote_token.address;
189        let is_sell_quote = token_in.address == self.quote_token.address &&
190            token_out.address == self.base_token.address;
191
192        if !is_sell_base && !is_sell_quote {
193            return Err(SimulationError::InvalidInput(
194                format!(
195                    "Invalid token addresses. Got in={}, out={}",
196                    token_in.address, token_out.address
197                ),
198                None,
199            ));
200        }
201
202        if amount_in == BigUint::ZERO {
203            return Err(SimulationError::InvalidInput(
204                "Native swap amount must be greater than zero".to_string(),
205                None,
206            ));
207        }
208
209        let (minimum_in, minimum_out) = if is_sell_base {
210            (self.book.minimum_in_base, self.book.minimum_out_quote)
211        } else {
212            (self.book.minimum_in_quote, self.book.minimum_out_base)
213        };
214        Self::enforce_minimum(&amount_in, minimum_in, "input")?;
215
216        let amount_in_f64 = amount_in.to_f64().ok_or_else(|| {
217            SimulationError::RecoverableError("Can't convert amount in to f64".into())
218        })? / 10f64.powi(token_in.decimals as i32);
219
220        let levels = if is_sell_base {
221            self.book.bids.clone()
222        } else {
223            NativePriceData::invert_price_levels(&self.book.asks)
224        };
225
226        if levels.is_empty() {
227            return Err(SimulationError::RecoverableError("No liquidity".into()));
228        }
229
230        let (amount_out_f64, remaining) =
231            NativePriceData::get_amount_out_from_levels(amount_in_f64, &levels);
232
233        let res = GetAmountOutResult {
234            amount: BigUint::from_f64(amount_out_f64 * 10f64.powi(token_out.decimals as i32))
235                .ok_or_else(|| {
236                    SimulationError::RecoverableError("Can't convert amount out to BigUint".into())
237                })?,
238            gas: BigUint::from(134_000u64), // Approximate standard gas for Native swap
239            new_state: self.clone_box(),
240        };
241
242        if remaining > 0.0 {
243            return Err(SimulationError::InvalidInput(
244                format!("Pool has not enough liquidity to support complete swap. Input amount: {}, consumed: {}", amount_in_f64, amount_in_f64 - remaining),
245                Some(res),
246            ));
247        }
248
249        Self::enforce_minimum(&res.amount, minimum_out, "output")?;
250
251        Ok(res)
252    }
253
254    fn get_limits(
255        &self,
256        sell_token: Bytes,
257        buy_token: Bytes,
258    ) -> Result<(BigUint, BigUint), SimulationError> {
259        let is_sell_base =
260            sell_token == self.base_token.address && buy_token == self.quote_token.address;
261        let is_sell_quote =
262            sell_token == self.quote_token.address && buy_token == self.base_token.address;
263
264        if !is_sell_base && !is_sell_quote {
265            return Err(SimulationError::InvalidInput(
266                format!("Invalid token addresses. Got sell={}, buy={}", sell_token, buy_token),
267                None,
268            ));
269        }
270
271        let levels = if is_sell_base {
272            self.book.bids.clone()
273        } else {
274            NativePriceData::invert_price_levels(&self.book.asks)
275        };
276
277        if levels.is_empty() {
278            return Err(SimulationError::RecoverableError("No liquidity".into()));
279        }
280
281        let (total_sell_amount, total_buy_amount) =
282            levels
283                .iter()
284                .fold((0.0, 0.0), |(sell_sum, buy_sum), level| {
285                    (sell_sum + level.quantity, buy_sum + level.quantity * level.price)
286                });
287
288        let sell_decimals =
289            if is_sell_base { self.base_token.decimals } else { self.quote_token.decimals };
290        let buy_decimals =
291            if is_sell_base { self.quote_token.decimals } else { self.base_token.decimals };
292
293        let sell_limit = BigUint::from_f64(total_sell_amount * 10f64.powi(sell_decimals as i32))
294            .ok_or_else(|| {
295                SimulationError::RecoverableError("Can't convert limit to BigUInt".into())
296            })?;
297        let buy_limit = BigUint::from_f64(total_buy_amount * 10f64.powi(buy_decimals as i32))
298            .ok_or_else(|| {
299                SimulationError::RecoverableError("Can't convert limit to BigUInt".into())
300            })?;
301
302        Ok((sell_limit, buy_limit))
303    }
304
305    fn delta_transition(
306        &mut self,
307        _delta: ProtocolStateDelta,
308        _tokens: &HashMap<Bytes, Token>,
309        _balances: &Balances,
310    ) -> Result<(), TransitionError> {
311        Err(TransitionError::DecodeError("Not implemented".into()))
312    }
313
314    fn clone_box(&self) -> Box<dyn ProtocolSim> {
315        Box::new(self.clone())
316    }
317
318    fn as_any(&self) -> &dyn Any {
319        self
320    }
321
322    fn as_any_mut(&mut self) -> &mut dyn Any {
323        self
324    }
325
326    fn eq(&self, other: &dyn ProtocolSim) -> bool {
327        if let Some(other_state) = other
328            .as_any()
329            .downcast_ref::<NativeState>()
330        {
331            self.base_token == other_state.base_token &&
332                self.quote_token == other_state.quote_token &&
333                self.book == other_state.book
334        } else {
335            false
336        }
337    }
338
339    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
340        Ok(self)
341    }
342}
343
344#[async_trait]
345impl IndicativelyPriced for NativeState {
346    async fn request_signed_quote(
347        &self,
348        params: GetAmountOutParams,
349    ) -> Result<SignedQuote, SimulationError> {
350        Ok(self
351            .client
352            .request_binding_quote(&params)
353            .await?)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::{collections::HashSet, str::FromStr};
360
361    use rstest::rstest;
362    use tokio::time::Duration;
363    use tycho_common::models::Chain;
364
365    use super::*;
366    use crate::rfq::protocols::native::models::NativePriceLevel;
367
368    fn token(address: &str, symbol: &str, decimals: u32) -> Token {
369        Token::new(
370            &Bytes::from_str(address).unwrap(),
371            symbol,
372            decimals,
373            0,
374            &[],
375            Chain::Ethereum,
376            100,
377        )
378    }
379
380    fn state() -> NativeState {
381        let base_token = token("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "WETH", 18);
382        let quote_token = token("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "USDC", 6);
383        let book = NativePriceData {
384            base_address: base_token.address.clone(),
385            quote_address: quote_token.address.clone(),
386            minimum_in_base: 100_000_000_000.0,
387            minimum_in_quote: 100.0,
388            minimum_out_base: 0.0,
389            minimum_out_quote: 0.0,
390            bids: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
391            asks: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
392        };
393        let client = NativeClient::new(
394            Chain::Ethereum,
395            String::new(),
396            HashSet::new(),
397            0.0,
398            HashSet::new(),
399            Duration::from_secs(5),
400            Duration::from_secs(5),
401        )
402        .unwrap();
403
404        NativeState::new(base_token, quote_token, book, client).unwrap()
405    }
406
407    #[test]
408    fn accepts_base_sell_at_atomic_input_minimum() {
409        let state = state();
410
411        let result = state.get_amount_out(
412            BigUint::from(100_000_000_000u64),
413            &state.base_token,
414            &state.quote_token,
415        );
416
417        assert!(result.is_ok());
418    }
419
420    #[test]
421    fn rejects_base_sell_below_atomic_input_minimum() {
422        let state = state();
423
424        let result = state.get_amount_out(
425            BigUint::from(99_999_999_999u64),
426            &state.base_token,
427            &state.quote_token,
428        );
429
430        assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
431    }
432
433    #[test]
434    fn accepts_quote_sell_at_atomic_input_minimum() {
435        let state = state();
436
437        let result =
438            state.get_amount_out(BigUint::from(100u64), &state.quote_token, &state.base_token);
439
440        assert!(result.is_ok());
441    }
442
443    #[test]
444    fn rejects_quote_sell_below_atomic_input_minimum() {
445        let state = state();
446
447        let result =
448            state.get_amount_out(BigUint::from(99u64), &state.quote_token, &state.base_token);
449
450        assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
451    }
452
453    #[test]
454    fn calculates_amount_out_for_base_sell() {
455        let state = state();
456
457        let result = state
458            .get_amount_out(
459                BigUint::from(500_000_000_000_000_000u64),
460                &state.base_token,
461                &state.quote_token,
462            )
463            .unwrap();
464
465        assert_eq!(result.amount, BigUint::from(1_000_000_000u64));
466    }
467
468    #[test]
469    fn ignores_zero_quantity_levels() {
470        let mut state = state();
471        state
472            .book
473            .bids
474            .insert(0, NativePriceLevel { quantity: 0.0, price: 1_000.0 });
475        let state = NativeState::new(state.base_token, state.quote_token, state.book, state.client)
476            .unwrap();
477
478        assert_eq!(state.book.bids.len(), 1);
479        assert_eq!(
480            state
481                .spot_price(&state.base_token, &state.quote_token)
482                .unwrap(),
483            2_000.0
484        );
485
486        let result = state
487            .get_amount_out(
488                BigUint::from(500_000_000_000_000_000u64),
489                &state.base_token,
490                &state.quote_token,
491            )
492            .unwrap();
493
494        assert_eq!(result.amount, BigUint::from(1_000_000_000u64));
495    }
496
497    #[test]
498    fn returns_finite_spot_price_for_large_finite_levels() {
499        let mut state = state();
500        state.book.bids[0] = NativePriceLevel { quantity: 1e-306, price: 1e308 };
501        state.book.asks[0] = NativePriceLevel { quantity: 1e-306, price: 1e308 };
502        let state = NativeState::new(state.base_token, state.quote_token, state.book, state.client)
503            .unwrap();
504
505        let price = state
506            .spot_price(&state.base_token, &state.quote_token)
507            .unwrap();
508
509        assert_eq!(price, 1e308);
510    }
511
512    #[test]
513    fn calculates_midpoint_spot_price_in_both_directions() {
514        let mut state = state();
515        state.book.bids[0].price = 1_900.0;
516        state.book.asks[0].price = 2_100.0;
517
518        let direct = state
519            .spot_price(&state.base_token, &state.quote_token)
520            .unwrap();
521        let inverse = state
522            .spot_price(&state.quote_token, &state.base_token)
523            .unwrap();
524
525        assert_eq!(direct, 2_000.0);
526        assert_eq!(inverse, direct.recip());
527    }
528
529    #[test]
530    fn rejects_non_finite_inverted_spot_price() {
531        let mut state = state();
532        let smallest_positive_price = f64::from_bits(1);
533        state.book.bids[0].price = smallest_positive_price;
534        state.book.asks[0].price = smallest_positive_price;
535        let state = NativeState::new(state.base_token, state.quote_token, state.book, state.client)
536            .unwrap();
537
538        let result = state.spot_price(&state.quote_token, &state.base_token);
539
540        assert!(matches!(
541            result,
542            Err(SimulationError::RecoverableError(message))
543                if message.contains("not positive and finite")
544        ));
545    }
546
547    #[test]
548    fn calculates_amount_out_for_quote_sell() {
549        let state = state();
550
551        let result = state
552            .get_amount_out(BigUint::from(1_000_000_000u64), &state.quote_token, &state.base_token)
553            .unwrap();
554
555        assert_eq!(result.amount, BigUint::from(500_000_000_000_000_000u64));
556    }
557
558    #[rstest]
559    #[case::sell_base(true, 2_500_000_000_000_000_000, 3_500_000_000)]
560    #[case::sell_quote(false, 8_192_000_000, 2_500_000_000_000_000_000)]
561    fn consumes_multiple_price_levels(
562        #[case] sell_base: bool,
563        #[case] amount_in: u64,
564        #[case] expected_amount_out: u64,
565    ) {
566        let mut state = state();
567        state.book.bids = vec![
568            NativePriceLevel { quantity: 1.0, price: 2_000.0 },
569            NativePriceLevel { quantity: 2.0, price: 1_000.0 },
570        ];
571        state.book.asks = vec![
572            NativePriceLevel { quantity: 1.0, price: 2_048.0 },
573            NativePriceLevel { quantity: 2.0, price: 4_096.0 },
574        ];
575        // Sell base: 1 * 2000 + 1.5 * 1000 = 3500 USDC.
576        // Sell quote: 2048 buys the first WETH; the remaining 6144 buys 1.5 WETH.
577        let (token_in, token_out) = if sell_base {
578            (&state.base_token, &state.quote_token)
579        } else {
580            (&state.quote_token, &state.base_token)
581        };
582
583        let result = state
584            .get_amount_out(BigUint::from(amount_in), token_in, token_out)
585            .unwrap();
586
587        assert_eq!(result.amount, BigUint::from(expected_amount_out));
588    }
589
590    #[test]
591    fn enforces_base_sell_atomic_output_minimum() {
592        let mut state = state();
593        state.book.minimum_out_quote = 1_000_000_000.0;
594        let amount_in = BigUint::from(500_000_000_000_000_000u64);
595
596        assert!(state
597            .get_amount_out(amount_in.clone(), &state.base_token, &state.quote_token)
598            .is_ok());
599
600        state.book.minimum_out_quote = 1_000_000_001.0;
601        assert!(matches!(
602            state.get_amount_out(amount_in, &state.base_token, &state.quote_token),
603            Err(SimulationError::RecoverableError(message)) if message.contains("minimum output")
604        ));
605    }
606
607    #[test]
608    fn enforces_quote_sell_atomic_output_minimum() {
609        let mut state = state();
610        state.book.minimum_out_base = 500_000_000_000_000.0;
611        let amount_in = BigUint::from(1_000_000u64);
612
613        assert!(state
614            .get_amount_out(amount_in.clone(), &state.quote_token, &state.base_token)
615            .is_ok());
616
617        state.book.minimum_out_base = 500_000_000_000_001.0;
618        assert!(matches!(
619            state.get_amount_out(amount_in, &state.quote_token, &state.base_token),
620            Err(SimulationError::RecoverableError(message)) if message.contains("minimum output")
621        ));
622    }
623
624    #[test]
625    fn returns_partial_result_when_amount_exceeds_depth() {
626        let state = state();
627
628        let result = state.get_amount_out(
629            BigUint::from(2_000_000_000_000_000_000u64),
630            &state.base_token,
631            &state.quote_token,
632        );
633
634        match result {
635            Err(SimulationError::InvalidInput(_, Some(partial))) => {
636                assert_eq!(partial.amount, BigUint::from(2_000_000_000u64));
637            }
638            other => panic!("Expected insufficient-liquidity result, got {other:?}"),
639        }
640    }
641
642    #[test]
643    fn rejects_sub_unit_partial_fill() {
644        let mut state = state();
645        state.book.minimum_in_base = 0.0;
646        state.book.bids[0].quantity = 0.5e-18;
647
648        let result =
649            state.get_amount_out(BigUint::from(1u64), &state.base_token, &state.quote_token);
650
651        assert!(matches!(result, Err(SimulationError::InvalidInput(_, Some(_)))));
652    }
653
654    #[test]
655    fn rejects_zero_amount() {
656        let state = state();
657
658        let result = state.get_amount_out(BigUint::ZERO, &state.base_token, &state.quote_token);
659
660        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
661    }
662
663    #[test]
664    fn gets_base_sell_limits() {
665        let state = state();
666
667        let limits = state
668            .get_limits(state.base_token.address.clone(), state.quote_token.address.clone())
669            .unwrap();
670
671        assert_eq!(limits.0, BigUint::from(1_000_000_000_000_000_000u64));
672        assert_eq!(limits.1, BigUint::from(2_000_000_000u64));
673    }
674
675    #[test]
676    fn gets_quote_sell_limits() {
677        let state = state();
678
679        let limits = state
680            .get_limits(state.quote_token.address.clone(), state.base_token.address.clone())
681            .unwrap();
682
683        assert_eq!(limits.0, BigUint::from(2_000_000_000u64));
684        assert_eq!(limits.1, BigUint::from(1_000_000_000_000_000_000u64));
685    }
686
687    #[test]
688    fn rejects_invalid_pair() {
689        let mut state = state();
690        let other = token("0x1111111111111111111111111111111111111111", "OTHER", 18);
691
692        assert!(matches!(
693            state.get_amount_out(BigUint::from(1u64), &other, &state.quote_token),
694            Err(SimulationError::InvalidInput(_, None))
695        ));
696        assert!(matches!(
697            state.get_limits(other.address.clone(), state.quote_token.address.clone()),
698            Err(SimulationError::InvalidInput(_, None))
699        ));
700
701        // Direction validation must win even when the book has no liquidity.
702        state.book.bids.clear();
703        state.book.asks.clear();
704        assert!(matches!(
705            state.spot_price(&other, &state.quote_token),
706            Err(SimulationError::RecoverableError(message))
707                if message.contains("Invalid token addresses")
708        ));
709    }
710
711    #[test]
712    fn rejects_invalid_book_state() {
713        let mut state = state();
714        state.book.bids[0].price = 0.0;
715
716        assert!(matches!(
717            NativeState::new(state.base_token, state.quote_token, state.book, state.client),
718            Err(SimulationError::FatalError(_))
719        ));
720    }
721
722    #[test]
723    fn rejects_invalid_output_minimum() {
724        let mut state = state();
725        state.book.minimum_out_base = -1.0;
726
727        assert!(matches!(
728            NativeState::new(state.base_token, state.quote_token, state.book, state.client),
729            Err(SimulationError::FatalError(_))
730        ));
731    }
732
733    #[test]
734    fn rejects_mismatched_book_tokens() {
735        let mut state = state();
736        state.book.base_address = Bytes::zero(20);
737
738        assert!(matches!(
739            NativeState::new(state.base_token, state.quote_token, state.book, state.client),
740            Err(SimulationError::FatalError(_))
741        ));
742    }
743
744    #[test]
745    fn reports_no_liquidity_for_empty_direction() {
746        let mut state = state();
747        state.book.bids.clear();
748
749        assert!(matches!(
750            state.get_amount_out(
751                BigUint::from(500_000_000_000_000_000u64),
752                &state.base_token,
753                &state.quote_token,
754            ),
755            Err(SimulationError::RecoverableError(_))
756        ));
757        assert!(matches!(
758            state.get_limits(state.base_token.address.clone(), state.quote_token.address.clone()),
759            Err(SimulationError::RecoverableError(_))
760        ));
761    }
762}