Skip to main content

tycho_common/simulation/
protocol_sim.rs

1use std::{any::Any, collections::HashMap, fmt};
2
3use num_bigint::BigUint;
4
5use crate::{
6    dto::ProtocolStateDelta,
7    models::token::Token,
8    simulation::{
9        errors::{SimulationError, TransitionError},
10        indicatively_priced::IndicativelyPriced,
11        swap::{
12            self, LimitsParams, MarginalPriceParams, QuoteParams, SwapQuoter, TransitionParams,
13        },
14    },
15    Bytes,
16};
17
18#[derive(Default, Debug, Clone)]
19pub struct Balances {
20    pub component_balances: HashMap<String, HashMap<Bytes, Bytes>>,
21    pub account_balances: HashMap<Bytes, HashMap<Bytes, Bytes>>,
22}
23
24/// GetAmountOutResult struct represents the result of getting the amount out of a trading pair
25///
26/// # Fields
27///
28/// * `amount`: BigUint, the amount of the trading pair
29/// * `gas`: BigUint, the gas of the trading pair
30#[derive(Debug)]
31pub struct GetAmountOutResult {
32    pub amount: BigUint,
33    pub gas: BigUint,
34    pub new_state: Box<dyn ProtocolSim>,
35}
36
37impl GetAmountOutResult {
38    /// Constructs a new GetAmountOutResult struct with the given amount and gas
39    pub fn new(amount: BigUint, gas: BigUint, new_state: Box<dyn ProtocolSim>) -> Self {
40        GetAmountOutResult { amount, gas, new_state }
41    }
42
43    /// Aggregates the given GetAmountOutResult struct to the current one.
44    /// It updates the amount with the other's amount and adds the other's gas to the current one's
45    /// gas.
46    pub fn aggregate(&mut self, other: &Self) {
47        self.amount = other.amount.clone();
48        self.gas += &other.gas;
49    }
50}
51
52impl fmt::Display for GetAmountOutResult {
53    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
54        write!(f, "amount = {}, gas = {}", self.amount, self.gas)
55    }
56}
57
58/// Represents a price as a fraction in the token_in -> token_out direction with units
59/// `[token_out/token_in]`.
60///
61/// # Fields
62///
63/// * `numerator` - The amount of token_out (what you receive) in atomic units (wei)
64/// * `denominator` - The amount of token_in (what you pay) in atomic units (wei)
65///
66/// A fraction struct is used for price to have flexibility in precision independent of the
67/// decimal precisions of the numerator and denominator tokens. This allows for:
68/// - Exact price representation without floating-point errors
69/// - Handling tokens with different decimal places without loss of precision
70///
71/// # Example
72/// If we want to represent that token A is worth 2.5 units of token B:
73///
74/// ```
75/// use num_bigint::BigUint;
76/// use tycho_common::simulation::protocol_sim::Price;
77///
78/// let numerator = BigUint::from(25u32); // Represents 25 units of token B
79/// let denominator = BigUint::from(10u32); // Represents 10 units of token A
80/// let price = Price::new(numerator, denominator);
81/// ```
82///
83/// If you want to define a limit price for a trade, where you expect to get at least 120 T1 for
84/// 50 T2:
85/// ```
86/// use num_bigint::BigUint;
87/// use tycho_common::simulation::protocol_sim::Price;
88///
89/// let min_amount_out = BigUint::from(120u32); // The minimum amount of T1 you expect
90/// let amount_in = BigUint::from(50u32); // The amount of T2 you are selling
91/// let limit_price = Price::new(min_amount_out, amount_in);
92/// ```
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Price {
95    pub numerator: BigUint,
96    pub denominator: BigUint,
97}
98
99impl Price {
100    pub fn new(numerator: BigUint, denominator: BigUint) -> Self {
101        if denominator == BigUint::ZERO {
102            // Division by zero is not possible
103            panic!("Price denominator cannot be zero");
104        } else if numerator == BigUint::ZERO {
105            // Zero pool price is not valid in our context
106            panic!("Price numerator cannot be zero");
107        }
108        Self { numerator, denominator }
109    }
110}
111
112/// A point on the AMM price curve.
113///
114/// Collected during iterative numerical search algorithms.
115/// These points can be reused as bounds for subsequent searches, improving convergence speed.
116#[derive(Debug, Clone)]
117pub struct PricePoint {
118    /// The amount of token_in in atomic units (wei).
119    pub amount_in: BigUint,
120    /// The amount of token_out in atomic units (wei).
121    pub amount_out: BigUint,
122    /// The price in units of `[token_out/token_in]` scaled by decimals.
123    ///
124    /// Computed as `(amount_out / 10^token_out_decimals) / (amount_in / 10^token_in_decimals)`.
125    pub price: f64,
126}
127
128impl PricePoint {
129    pub fn new(amount_in: BigUint, amount_out: BigUint, price: f64) -> Self {
130        Self { amount_in, amount_out, price }
131    }
132}
133
134/// Represents a pool swap between two tokens at a given price on a pool.
135#[derive(Debug, Clone)]
136pub struct PoolSwap {
137    /// The amount of token_in sold to the pool
138    amount_in: BigUint,
139    /// The amount of token_out bought from the pool
140    amount_out: BigUint,
141    /// The new state of the pool after the swap
142    new_state: Box<dyn ProtocolSim>,
143    /// Optional price points that the pool was transitioned through while computing this swap.
144    /// Useful for providing good bounds for repeated calls.
145    price_points: Option<Vec<PricePoint>>,
146}
147
148impl PoolSwap {
149    pub fn new(
150        amount_in: BigUint,
151        amount_out: BigUint,
152        new_state: Box<dyn ProtocolSim>,
153        price_points: Option<Vec<PricePoint>>,
154    ) -> Self {
155        Self { amount_in, amount_out, new_state, price_points }
156    }
157
158    pub fn amount_in(&self) -> &BigUint {
159        &self.amount_in
160    }
161
162    pub fn amount_out(&self) -> &BigUint {
163        &self.amount_out
164    }
165
166    pub fn new_state(&self) -> &dyn ProtocolSim {
167        self.new_state.as_ref()
168    }
169
170    pub fn price_points(&self) -> &Option<Vec<PricePoint>> {
171        &self.price_points
172    }
173}
174
175/// Options on how to constrain the pool swap query.
176///
177/// All prices use units `[token_out/token_in]` with amounts in atomic units (wei). When selling
178/// token_in into a pool, prices decrease due to slippage.
179#[derive(Debug, Clone, PartialEq)]
180pub enum SwapConstraint {
181    /// Calculates the maximum trade while respecting a minimum trade price.
182    TradeLimitPrice {
183        /// The minimum acceptable trade price. The resulting `amount_out / amount_in >= limit`.
184        limit: Price,
185        /// Fraction to raise the acceptance threshold above `limit`. Loosens the search criteria
186        /// but will never allow violating the trade limit price itself.
187        tolerance: f64,
188        /// The minimum amount of token_in that must be used for this trade.
189        min_amount_in: Option<BigUint>,
190        /// The maximum amount of token_in that can be used for this trade.
191        max_amount_in: Option<BigUint>,
192    },
193
194    /// Calculates the swap required to move the pool's marginal price down to a target.
195    ///
196    /// # Edge Cases and Limitations
197    ///
198    /// Computing the exact amount to move a pool's marginal price to a target has several
199    /// challenges:
200    /// - The definition of marginal price varies between protocols. It is usually not an attribute
201    ///   of the pool but a consequence of its liquidity distribution and current state.
202    /// - For protocols with concentrated liquidity, the marginal price is discrete, meaning we
203    ///   can't always find an exact trade amount to reach the target price.
204    /// - Not all protocols support analytical solutions for this problem, requiring numerical
205    ///   methods.
206    PoolTargetPrice {
207        /// The target marginal price for the pool after the trade. The pool's price decreases
208        /// toward this target as token_in is sold into it.
209        target: Price,
210        /// Fraction above `target` considered acceptable. After trading, the pool's marginal
211        /// price will be in `[target, target * (1 + tolerance)]`.
212        tolerance: f64,
213        /// The lower bound for searching algorithms.
214        min_amount_in: Option<BigUint>,
215        /// The upper bound for searching algorithms.
216        max_amount_in: Option<BigUint>,
217    },
218}
219
220/// Represents the parameters for [ProtocolSim::query_pool_swap].
221///
222/// # Fields
223///
224/// * `token_in` - The token being sold (swapped into the pool)
225/// * `token_out` - The token being bought (swapped out of the pool)
226/// * `swap_constraint` - Type of price constraint to be applied. See [SwapConstraint].
227#[derive(Debug, Clone, PartialEq)]
228pub struct QueryPoolSwapParams {
229    token_in: Token,
230    token_out: Token,
231    swap_constraint: SwapConstraint,
232}
233
234impl QueryPoolSwapParams {
235    pub fn new(token_in: Token, token_out: Token, swap_constraint: SwapConstraint) -> Self {
236        Self { token_in, token_out, swap_constraint }
237    }
238
239    /// Returns a reference to the input token (token being sold into the pool)
240    pub fn token_in(&self) -> &Token {
241        &self.token_in
242    }
243
244    /// Returns a reference to the output token (token being bought out of the pool)
245    pub fn token_out(&self) -> &Token {
246        &self.token_out
247    }
248
249    /// Returns a reference to the price constraint
250    pub fn swap_constraint(&self) -> &SwapConstraint {
251        &self.swap_constraint
252    }
253}
254
255/// ProtocolSim trait
256/// This trait defines the methods that a protocol state must implement in order to be used
257/// in the trade simulation.
258#[typetag::serde(tag = "protocol", content = "state")]
259pub trait ProtocolSim: fmt::Debug + Send + Sync + 'static {
260    /// Returns the fee of the protocol as ratio
261    ///
262    /// E.g. if the fee is 1%, the value returned would be 0.01.
263    ///
264    /// # Panics
265    ///
266    /// Currently panic for protocols with asymmetric fees (e.g. Rocketpool, Uniswap V4),
267    /// where a single fee value cannot represent the protocol's fee structure.
268    fn fee(&self) -> f64;
269
270    /// Returns the protocol's current spot buy price for `base` in units of `quote`.
271    ///
272    /// The returned price is the amount of `quote` required to buy exactly 1 unit of `base`,
273    /// accounting for the protocol fee (i.e. `price = pre_fee_price / (1.0 - fee)`)
274    /// and assuming zero slippage (i.e., a negligibly small trade size).
275    ///
276    /// # Arguments
277    /// * `base` - the token being priced (what you buy). For BTC/USDT, BTC is the base token.
278    /// * `quote` - the token used to price (pay) for `base`. For BTC/USDT, USDT is the quote token.
279    ///
280    /// # Examples
281    /// If the BTC/USDT is trading at 1000 with a 20% fee, this returns `1000 / (1.0 - 0.20) = 1250`
282    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError>;
283
284    /// Returns the amount out given an amount in and input/output tokens.
285    ///
286    /// # Arguments
287    ///
288    /// * `amount_in` - The amount in of the input token.
289    /// * `token_in` - The input token ERC20 token.
290    /// * `token_out` - The output token ERC20 token.
291    ///
292    /// # Returns
293    ///
294    /// A `Result` containing a `GetAmountOutResult` struct on success or a
295    ///  `SimulationError` on failure.
296    fn get_amount_out(
297        &self,
298        amount_in: BigUint,
299        token_in: &Token,
300        token_out: &Token,
301    ) -> Result<GetAmountOutResult, SimulationError>;
302
303    /// Computes the maximum amount that can be traded between two tokens.
304    ///
305    /// This function calculates the maximum possible trade amount between two tokens,
306    /// taking into account the protocol's specific constraints and mechanics.
307    /// The implementation details vary by protocol - for example:
308    /// - For constant product AMMs (like Uniswap V2), this is based on available reserves
309    /// - For concentrated liquidity AMMs (like Uniswap V3), this considers liquidity across tick
310    ///   ranges
311    ///
312    /// Note: if there are no limits, the returned amount will be a "soft" limit,
313    ///       meaning that the actual amount traded could be higher but it's advised to not
314    ///       exceed it.
315    ///
316    /// # Arguments
317    /// * `sell_token` - The address of the token being sold
318    /// * `buy_token` - The address of the token being bought
319    ///
320    /// # Returns
321    /// * `Ok((BigUint, BigUint))` - A tuple containing:
322    ///   - First element: The maximum input amount (sell_token)
323    ///   - Second element: The maximum output amount (buy_token)
324    ///
325    /// For `let res = get_limits(...)`, the valid input domain for `get_amount_out` is `[0,
326    /// res.0]`.
327    ///
328    /// * `Err(SimulationError)` - If any unexpected error occurs
329    fn get_limits(
330        &self,
331        sell_token: Bytes,
332        buy_token: Bytes,
333    ) -> Result<(BigUint, BigUint), SimulationError>;
334
335    /// Decodes and applies a protocol state delta to the state
336    ///
337    /// Will error if the provided delta is missing any required attributes or if any of the
338    /// attribute values cannot be decoded.
339    ///
340    /// # Arguments
341    ///
342    /// * `delta` - A `ProtocolStateDelta` from the tycho indexer
343    ///
344    /// # Returns
345    ///
346    /// * `Result<(), TransitionError<String>>` - A `Result` containing `()` on success or a
347    ///   `TransitionError` on failure.
348    fn delta_transition(
349        &mut self,
350        delta: ProtocolStateDelta,
351        tokens: &HashMap<Bytes, Token>,
352        balances: &Balances,
353    ) -> Result<(), TransitionError>;
354
355    /// Calculates the swap volume required to achieve the provided goal when trading against this
356    /// pool.
357    ///
358    /// This method will branch towards different behaviors based on [SwapConstraint] enum. Please
359    /// refer to its documentation for further details on each behavior.
360    ///
361    /// In short, the current two options are:
362    /// - Maximize your trade while respecting a trade limit price:
363    ///   [SwapConstraint::TradeLimitPrice]
364    /// - Move the pool price to a target price: [SwapConstraint::PoolTargetPrice]
365    ///
366    /// # Arguments
367    ///
368    /// * `params` - A [QueryPoolSwapParams] struct containing the inputs for this method.
369    ///
370    /// # Returns
371    ///
372    /// * `Ok(PoolSwap)` - A `PoolSwap` struct containing the amounts to be traded and the state of
373    ///   the pool after trading.
374    /// * `Err(SimulationError)` - If:
375    ///   - The calculation encounters numerical issues
376    ///   - The method is not implemented for this protocol
377    #[allow(unused)]
378    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
379        Err(SimulationError::FatalError("query_pool_swap not implemented".into()))
380    }
381
382    /// Clones the protocol state as a trait object.
383    /// This allows the state to be cloned when it is being used as a `Box<dyn ProtocolSim>`.
384    fn clone_box(&self) -> Box<dyn ProtocolSim>;
385
386    /// Allows downcasting of the trait object to its underlying type.
387    fn as_any(&self) -> &dyn Any;
388
389    /// Allows downcasting of the trait object to its mutable underlying type.
390    fn as_any_mut(&mut self) -> &mut dyn Any;
391
392    /// Compares two protocol states for equality.
393    /// This method must be implemented to define how two protocol states are considered equal
394    /// (used for tests).
395    fn eq(&self, other: &dyn ProtocolSim) -> bool;
396
397    /// Cast as IndicativelyPriced. This is necessary for RFQ protocols
398    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
399        Err(SimulationError::FatalError("Pool State does not implement IndicativelyPriced".into()))
400    }
401}
402
403impl Clone for Box<dyn ProtocolSim> {
404    fn clone(&self) -> Box<dyn ProtocolSim> {
405        self.clone_box()
406    }
407}
408
409impl<T> ProtocolSim for T
410where
411    T: SwapQuoter + Clone + Send + Sync + Eq + 'static,
412{
413    fn fee(&self) -> f64 {
414        self.quotable_pairs()
415            .iter()
416            .map(|(t0, t1)| {
417                let amount = BigUint::from(10u32).pow(t0.decimals);
418                if let Ok(params) = QuoteParams::fixed_in(&t0.address, &t1.address, amount) {
419                    self.fee(params)
420                        .map(|f| f.fee())
421                        .unwrap_or(f64::MAX)
422                } else {
423                    f64::MAX
424                }
425            })
426            .reduce(f64::min)
427            .unwrap_or(f64::MAX)
428    }
429
430    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
431        self.marginal_price(MarginalPriceParams::new(&base.address, &quote.address))
432            .map(|r| r.price())
433    }
434
435    fn get_amount_out(
436        &self,
437        amount_in: BigUint,
438        token_in: &Token,
439        token_out: &Token,
440    ) -> Result<GetAmountOutResult, SimulationError> {
441        #[allow(deprecated)]
442        self.quote(
443            QuoteParams::fixed_in(&token_in.address, &token_out.address, amount_in)?
444                .with_new_state(),
445        )
446        .map(|r| {
447            GetAmountOutResult::new(
448                r.amount_out().clone(),
449                r.gas().clone(),
450                r.new_state()
451                    .expect("quote includes new state")
452                    .to_protocol_sim(),
453            )
454        })
455    }
456
457    fn get_limits(
458        &self,
459        sell_token: Bytes,
460        buy_token: Bytes,
461    ) -> Result<(BigUint, BigUint), SimulationError> {
462        self.swap_limits(LimitsParams::new(&sell_token, &buy_token))
463            .map(|r| (r.range_in().upper().clone(), r.range_out().upper().clone()))
464    }
465
466    fn delta_transition(
467        &mut self,
468        delta: ProtocolStateDelta,
469        tokens: &HashMap<Bytes, Token>,
470        balances: &Balances,
471    ) -> Result<(), TransitionError> {
472        self.delta_transition(TransitionParams::new(delta, tokens, balances))
473            .map(|_| ())
474    }
475
476    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
477        let constraint = match params.swap_constraint.clone() {
478            SwapConstraint::TradeLimitPrice { limit, tolerance, min_amount_in, max_amount_in } => {
479                swap::SwapConstraint::TradeLimitPrice {
480                    limit,
481                    tolerance,
482                    min_amount_in,
483                    max_amount_in,
484                }
485            }
486            SwapConstraint::PoolTargetPrice { target, tolerance, min_amount_in, max_amount_in } => {
487                swap::SwapConstraint::PoolTargetPrice {
488                    target,
489                    tolerance,
490                    min_amount_in,
491                    max_amount_in,
492                }
493            }
494        };
495        #[allow(deprecated)]
496        self.query_swap(swap::QuerySwapParams::new(
497            &params.token_in.address,
498            &params.token_out.address,
499            constraint,
500        ))
501        .map(|r| {
502            PoolSwap::new(
503                r.amount_in().clone(),
504                r.amount_out().clone(),
505                r.new_state().unwrap().to_protocol_sim(),
506                r.price_points().as_ref().map(|points| {
507                    points
508                        .iter()
509                        .map(|p| {
510                            PricePoint::new(
511                                p.amount_in().clone(),
512                                p.amount_out().clone(),
513                                p.price(),
514                            )
515                        })
516                        .collect()
517                }),
518            )
519        })
520    }
521
522    fn clone_box(&self) -> Box<dyn ProtocolSim> {
523        #[allow(deprecated)]
524        self.to_protocol_sim()
525    }
526
527    fn as_any(&self) -> &dyn Any {
528        self
529    }
530
531    fn as_any_mut(&mut self) -> &mut dyn Any {
532        self
533    }
534
535    fn eq(&self, other: &dyn ProtocolSim) -> bool {
536        if let Some(other) = other.as_any().downcast_ref::<T>() {
537            self == other
538        } else {
539            false
540        }
541    }
542
543    fn typetag_name(&self) -> &'static str {
544        self.typetag_name()
545    }
546
547    fn typetag_deserialize(&self) {
548        self.typetag_deserialize()
549    }
550}
551
552#[cfg(test)]
553mod tests {
554
555    use super::*;
556
557    #[test]
558    fn serde() {
559        use serde::{Deserialize, Serialize};
560
561        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562        struct DummyProtocol {
563            reserve_0: u64,
564            reserve_1: u64,
565        }
566
567        #[typetag::serde]
568        impl ProtocolSim for DummyProtocol {
569            fn clone_box(&self) -> Box<dyn ProtocolSim> {
570                todo!()
571            }
572
573            fn as_any(&self) -> &dyn Any {
574                self
575            }
576
577            fn as_any_mut(&mut self) -> &mut dyn Any {
578                todo!()
579            }
580
581            fn eq(&self, other: &dyn ProtocolSim) -> bool {
582                if let Some(other) = other.as_any().downcast_ref::<Self>() {
583                    self.reserve_0 == other.reserve_0 && self.reserve_1 == other.reserve_1
584                } else {
585                    false
586                }
587            }
588
589            fn fee(&self) -> f64 {
590                todo!()
591            }
592            fn spot_price(&self, _base: &Token, _quote: &Token) -> Result<f64, SimulationError> {
593                todo!()
594            }
595            fn get_amount_out(
596                &self,
597                _amount_in: BigUint,
598                _token_in: &Token,
599                _token_out: &Token,
600            ) -> Result<GetAmountOutResult, SimulationError> {
601                todo!()
602            }
603            fn get_limits(
604                &self,
605                _sell_token: Bytes,
606                _buy_token: Bytes,
607            ) -> Result<(BigUint, BigUint), SimulationError> {
608                todo!()
609            }
610            fn delta_transition(
611                &mut self,
612                _delta: ProtocolStateDelta,
613                _tokens: &HashMap<Bytes, Token>,
614                _balances: &Balances,
615            ) -> Result<(), TransitionError> {
616                todo!()
617            }
618        }
619
620        let state = DummyProtocol { reserve_0: 1, reserve_1: 2 };
621
622        assert_eq!(serde_json::to_string(&state).unwrap(), r#"{"reserve_0":1,"reserve_1":2}"#);
623        assert_eq!(
624            serde_json::to_string(&state as &dyn ProtocolSim).unwrap(),
625            r#"{"protocol":"DummyProtocol","state":{"reserve_0":1,"reserve_1":2}}"#
626        );
627
628        let deserialized: Box<dyn ProtocolSim> = serde_json::from_str(
629            r#"{"protocol":"DummyProtocol","state":{"reserve_0":1,"reserve_1":2}}"#,
630        )
631        .unwrap();
632
633        assert!(deserialized.eq(&state));
634    }
635}