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