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