tycho_common/simulation/protocol_sim.rs
1use std::{any::Any, collections::HashMap, fmt, sync::Arc};
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 /// Runs every later quote from this state under a pending block's state, held in
439 /// `overrides`.
440 ///
441 /// The payload type belongs to the simulation crate that builds it. The default ignores it,
442 /// for protocols that do not simulate pending state.
443 ///
444 /// # Errors
445 /// Returns an error when the protocol simulates pending state but `overrides` is not the
446 /// type it expects.
447 fn set_pending_overrides(
448 &mut self,
449 _overrides: Arc<dyn Any + Send + Sync>,
450 ) -> Result<(), SimulationError> {
451 Ok(())
452 }
453}
454
455impl Clone for Box<dyn ProtocolSim> {
456 fn clone(&self) -> Box<dyn ProtocolSim> {
457 self.clone_box()
458 }
459}
460
461impl<T> ProtocolSim for T
462where
463 T: SwapQuoter + Clone + Send + Sync + Eq + 'static,
464{
465 fn fee(&self) -> f64 {
466 self.quotable_pairs()
467 .iter()
468 .map(|(t0, t1)| {
469 let amount = BigUint::from(10u32).pow(t0.decimals);
470 if let Ok(params) = QuoteParams::fixed_in(&t0.address, &t1.address, amount) {
471 self.fee(params)
472 .map(|f| f.fee())
473 .unwrap_or(f64::MAX)
474 } else {
475 f64::MAX
476 }
477 })
478 .reduce(f64::min)
479 .unwrap_or(f64::MAX)
480 }
481
482 fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
483 self.marginal_price(MarginalPriceParams::new(&base.address, "e.address))
484 .map(|r| r.price())
485 }
486
487 fn get_amount_out(
488 &self,
489 amount_in: BigUint,
490 token_in: &Token,
491 token_out: &Token,
492 ) -> Result<GetAmountOutResult, SimulationError> {
493 #[allow(deprecated)]
494 self.quote(
495 QuoteParams::fixed_in(&token_in.address, &token_out.address, amount_in)?
496 .with_new_state(),
497 )
498 .map(|r| {
499 GetAmountOutResult::new(
500 r.amount_out().clone(),
501 r.gas().clone(),
502 r.new_state()
503 .expect("quote includes new state")
504 .to_protocol_sim(),
505 )
506 })
507 }
508
509 fn get_limits(
510 &self,
511 sell_token: Bytes,
512 buy_token: Bytes,
513 ) -> Result<(BigUint, BigUint), SimulationError> {
514 self.swap_limits(LimitsParams::new(&sell_token, &buy_token))
515 .map(|r| (r.range_in().upper().clone(), r.range_out().upper().clone()))
516 }
517
518 fn delta_transition(
519 &mut self,
520 delta: ProtocolStateDelta,
521 tokens: &HashMap<Bytes, Token>,
522 balances: &Balances,
523 ) -> Result<(), TransitionError> {
524 self.delta_transition(TransitionParams::new(delta, tokens, balances))
525 .map(|_| ())
526 }
527
528 fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
529 let constraint = match params.swap_constraint.clone() {
530 SwapConstraint::TradeLimitPrice { limit, tolerance, min_amount_in, max_amount_in } => {
531 swap::SwapConstraint::TradeLimitPrice {
532 limit,
533 tolerance,
534 min_amount_in,
535 max_amount_in,
536 }
537 }
538 SwapConstraint::PoolTargetPrice { target, tolerance, min_amount_in, max_amount_in } => {
539 swap::SwapConstraint::PoolTargetPrice {
540 target,
541 tolerance,
542 min_amount_in,
543 max_amount_in,
544 }
545 }
546 };
547 #[allow(deprecated)]
548 self.query_swap(swap::QuerySwapParams::new(
549 ¶ms.token_in.address,
550 ¶ms.token_out.address,
551 constraint,
552 ))
553 .map(|r| {
554 PoolSwap::new(
555 r.amount_in().clone(),
556 r.amount_out().clone(),
557 r.new_state().unwrap().to_protocol_sim(),
558 r.price_points().as_ref().map(|points| {
559 points
560 .iter()
561 .map(|p| {
562 PricePoint::new(
563 p.amount_in().clone(),
564 p.amount_out().clone(),
565 p.price(),
566 )
567 })
568 .collect()
569 }),
570 )
571 })
572 }
573
574 fn clone_box(&self) -> Box<dyn ProtocolSim> {
575 #[allow(deprecated)]
576 self.to_protocol_sim()
577 }
578
579 fn as_any(&self) -> &dyn Any {
580 self
581 }
582
583 fn as_any_mut(&mut self) -> &mut dyn Any {
584 self
585 }
586
587 fn eq(&self, other: &dyn ProtocolSim) -> bool {
588 if let Some(other) = other.as_any().downcast_ref::<T>() {
589 self == other
590 } else {
591 false
592 }
593 }
594
595 fn typetag_name(&self) -> &'static str {
596 self.typetag_name()
597 }
598
599 fn typetag_deserialize(&self) {
600 self.typetag_deserialize()
601 }
602}
603
604#[cfg(test)]
605mod tests {
606
607 use super::*;
608
609 #[test]
610 fn serde() {
611 use serde::{Deserialize, Serialize};
612
613 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
614 struct DummyProtocol {
615 reserve_0: u64,
616 reserve_1: u64,
617 }
618
619 #[typetag::serde]
620 impl ProtocolSim for DummyProtocol {
621 fn clone_box(&self) -> Box<dyn ProtocolSim> {
622 todo!()
623 }
624
625 fn as_any(&self) -> &dyn Any {
626 self
627 }
628
629 fn as_any_mut(&mut self) -> &mut dyn Any {
630 todo!()
631 }
632
633 fn eq(&self, other: &dyn ProtocolSim) -> bool {
634 if let Some(other) = other.as_any().downcast_ref::<Self>() {
635 self.reserve_0 == other.reserve_0 && self.reserve_1 == other.reserve_1
636 } else {
637 false
638 }
639 }
640
641 fn fee(&self) -> f64 {
642 todo!()
643 }
644 fn spot_price(&self, _base: &Token, _quote: &Token) -> Result<f64, SimulationError> {
645 todo!()
646 }
647 fn get_amount_out(
648 &self,
649 _amount_in: BigUint,
650 _token_in: &Token,
651 _token_out: &Token,
652 ) -> Result<GetAmountOutResult, SimulationError> {
653 todo!()
654 }
655 fn get_limits(
656 &self,
657 _sell_token: Bytes,
658 _buy_token: Bytes,
659 ) -> Result<(BigUint, BigUint), SimulationError> {
660 todo!()
661 }
662 fn delta_transition(
663 &mut self,
664 _delta: ProtocolStateDelta,
665 _tokens: &HashMap<Bytes, Token>,
666 _balances: &Balances,
667 ) -> Result<(), TransitionError> {
668 todo!()
669 }
670 }
671
672 let state = DummyProtocol { reserve_0: 1, reserve_1: 2 };
673
674 assert_eq!(serde_json::to_string(&state).unwrap(), r#"{"reserve_0":1,"reserve_1":2}"#);
675 assert_eq!(
676 serde_json::to_string(&state as &dyn ProtocolSim).unwrap(),
677 r#"{"protocol":"DummyProtocol","state":{"reserve_0":1,"reserve_1":2}}"#
678 );
679
680 let deserialized: Box<dyn ProtocolSim> = serde_json::from_str(
681 r#"{"protocol":"DummyProtocol","state":{"reserve_0":1,"reserve_1":2}}"#,
682 )
683 .unwrap();
684
685 assert!(deserialized.eq(&state));
686 }
687}