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, "e.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 ¶ms.token_in.address,
498 ¶ms.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}