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