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