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 },
12 Bytes,
13};
14
15#[derive(Default)]
16pub struct Balances {
17 pub component_balances: HashMap<String, HashMap<Bytes, Bytes>>,
18 pub account_balances: HashMap<Bytes, HashMap<Bytes, Bytes>>,
19}
20
21/// GetAmountOutResult struct represents the result of getting the amount out of a trading pair
22///
23/// # Fields
24///
25/// * `amount`: BigUint, the amount of the trading pair
26/// * `gas`: BigUint, the gas of the trading pair
27#[derive(Debug)]
28pub struct GetAmountOutResult {
29 pub amount: BigUint,
30 pub gas: BigUint,
31 pub new_state: Box<dyn ProtocolSim>,
32}
33
34impl GetAmountOutResult {
35 /// Constructs a new GetAmountOutResult struct with the given amount and gas
36 pub fn new(amount: BigUint, gas: BigUint, new_state: Box<dyn ProtocolSim>) -> Self {
37 GetAmountOutResult { amount, gas, new_state }
38 }
39
40 /// Aggregates the given GetAmountOutResult struct to the current one.
41 /// It updates the amount with the other's amount and adds the other's gas to the current one's
42 /// gas.
43 pub fn aggregate(&mut self, other: &Self) {
44 self.amount = other.amount.clone();
45 self.gas += &other.gas;
46 }
47}
48
49impl fmt::Display for GetAmountOutResult {
50 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
51 write!(f, "amount = {}, gas = {}", self.amount, self.gas)
52 }
53}
54
55/// Represents a price as a fraction in the token_in -> token_out direction with units
56/// `[token_out/token_in]`.
57///
58/// # Fields
59///
60/// * `numerator` - The amount of token_out (what you receive) in atomic units (wei)
61/// * `denominator` - The amount of token_in (what you pay) in atomic units (wei)
62///
63/// A fraction struct is used for price to have flexibility in precision independent of the
64/// decimal precisions of the numerator and denominator tokens. This allows for:
65/// - Exact price representation without floating-point errors
66/// - Handling tokens with different decimal places without loss of precision
67///
68/// # Example
69/// If we want to represent that token A is worth 2.5 units of token B:
70///
71/// ```
72/// use num_bigint::BigUint;
73/// use tycho_common::simulation::protocol_sim::Price;
74///
75/// let numerator = BigUint::from(25u32); // Represents 25 units of token B
76/// let denominator = BigUint::from(10u32); // Represents 10 units of token A
77/// let price = Price::new(numerator, denominator);
78/// ```
79///
80/// If you want to define a limit price for a trade, where you expect to get at least 120 T1 for
81/// 50 T2:
82/// ```
83/// use num_bigint::BigUint;
84/// use tycho_common::simulation::protocol_sim::Price;
85///
86/// let min_amount_out = BigUint::from(120u32); // The minimum amount of T1 you expect
87/// let amount_in = BigUint::from(50u32); // The amount of T2 you are selling
88/// let limit_price = Price::new(min_amount_out, amount_in);
89/// ```
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Price {
92 pub numerator: BigUint,
93 pub denominator: BigUint,
94}
95
96impl Price {
97 pub fn new(numerator: BigUint, denominator: BigUint) -> Self {
98 if denominator == BigUint::ZERO {
99 // Division by zero is not possible
100 panic!("Price denominator cannot be zero");
101 } else if numerator == BigUint::ZERO {
102 // Zero pool price is not valid in our context
103 panic!("Price numerator cannot be zero");
104 }
105 Self { numerator, denominator }
106 }
107}
108
109/// Represents a pool swap between two tokens at a given price on a pool.
110#[derive(Debug, Clone)]
111pub struct PoolSwap {
112 /// The amount of token_in sold to the pool
113 amount_in: BigUint,
114 /// The amount of token_out bought from the pool
115 amount_out: BigUint,
116 /// The new state of the pool after the swap
117 new_state: Box<dyn ProtocolSim>,
118 /// Optional price points that the pool was transitioned through while computing this swap.
119 /// The values are tuples of (amount_in, amount_out, price). This is useful for repeated calls
120 /// by providing good bounds for the next call.
121 price_points: Option<Vec<(BigUint, BigUint, f64)>>,
122}
123
124impl PoolSwap {
125 pub fn new(
126 amount_in: BigUint,
127 amount_out: BigUint,
128 new_state: Box<dyn ProtocolSim>,
129 price_points: Option<Vec<(BigUint, BigUint, f64)>>,
130 ) -> Self {
131 Self { amount_in, amount_out, new_state, price_points }
132 }
133
134 pub fn amount_in(&self) -> &BigUint {
135 &self.amount_in
136 }
137
138 pub fn amount_out(&self) -> &BigUint {
139 &self.amount_out
140 }
141
142 pub fn new_state(&self) -> &dyn ProtocolSim {
143 self.new_state.as_ref()
144 }
145
146 pub fn price_points(&self) -> &Option<Vec<(BigUint, BigUint, f64)>> {
147 &self.price_points
148 }
149}
150
151/// Options on how to constrain the pool swap query.
152///
153/// All prices use units `[token_out/token_in]` with amounts in atomic units (wei). When selling
154/// token_in into a pool, prices decrease due to slippage.
155#[derive(Debug, Clone, PartialEq)]
156pub enum SwapConstraint {
157 /// Calculates the maximum trade while respecting a minimum trade price.
158 TradeLimitPrice {
159 /// The minimum acceptable trade price. The resulting `amount_out / amount_in >= limit`.
160 limit: Price,
161 /// Fraction to raise the acceptance threshold above `limit`. Loosens the search criteria
162 /// but will never allow violating the trade limit price itself.
163 tolerance: f64,
164 /// The minimum amount of token_in that must be used for this trade.
165 min_amount_in: Option<BigUint>,
166 /// The maximum amount of token_in that can be used for this trade.
167 max_amount_in: Option<BigUint>,
168 },
169
170 /// Calculates the swap required to move the pool's marginal price down to a target.
171 ///
172 /// # Edge Cases and Limitations
173 ///
174 /// Computing the exact amount to move a pool's marginal price to a target has several
175 /// challenges:
176 /// - The definition of marginal price varies between protocols. It is usually not an attribute
177 /// of the pool but a consequence of its liquidity distribution and current state.
178 /// - For protocols with concentrated liquidity, the marginal price is discrete, meaning we
179 /// can't always find an exact trade amount to reach the target price.
180 /// - Not all protocols support analytical solutions for this problem, requiring numerical
181 /// methods.
182 PoolTargetPrice {
183 /// The target marginal price for the pool after the trade. The pool's price decreases
184 /// toward this target as token_in is sold into it.
185 target: Price,
186 /// Fraction above `target` considered acceptable. After trading, the pool's marginal
187 /// price will be in `[target, target * (1 + tolerance)]`.
188 tolerance: f64,
189 /// The lower bound for searching algorithms.
190 min_amount_in: Option<BigUint>,
191 /// The upper bound for searching algorithms.
192 max_amount_in: Option<BigUint>,
193 },
194}
195
196/// Represents the parameters for [ProtocolSim::query_pool_swap].
197///
198/// # Fields
199///
200/// * `token_in` - The token being sold (swapped into the pool)
201/// * `token_out` - The token being bought (swapped out of the pool)
202/// * `swap_constraint` - Type of price constraint to be applied. See [SwapConstraint].
203#[derive(Debug, Clone, PartialEq)]
204pub struct QueryPoolSwapParams {
205 token_in: Token,
206 token_out: Token,
207 swap_constraint: SwapConstraint,
208}
209
210impl QueryPoolSwapParams {
211 pub fn new(token_in: Token, token_out: Token, swap_constraint: SwapConstraint) -> Self {
212 Self { token_in, token_out, swap_constraint }
213 }
214
215 /// Returns a reference to the input token (token being sold into the pool)
216 pub fn token_in(&self) -> &Token {
217 &self.token_in
218 }
219
220 /// Returns a reference to the output token (token being bought out of the pool)
221 pub fn token_out(&self) -> &Token {
222 &self.token_out
223 }
224
225 /// Returns a reference to the price constraint
226 pub fn swap_constraint(&self) -> &SwapConstraint {
227 &self.swap_constraint
228 }
229}
230
231/// ProtocolSim trait
232/// This trait defines the methods that a protocol state must implement in order to be used
233/// in the trade simulation.
234#[typetag::serde(tag = "protocol", content = "state")]
235pub trait ProtocolSim: fmt::Debug + Send + Sync + 'static {
236 /// Returns the fee of the protocol as ratio
237 ///
238 /// E.g. if the fee is 1%, the value returned would be 0.01.
239 ///
240 /// # Panics
241 ///
242 /// Currently panic for protocols with asymmetric fees (e.g. Rocketpool, Uniswap V4),
243 /// where a single fee value cannot represent the protocol's fee structure.
244 fn fee(&self) -> f64;
245
246 /// Returns the protocol's current spot buy price for `base` in units of `quote`.
247 ///
248 /// The returned price is the amount of `quote` required to buy exactly 1 unit of `base`,
249 /// accounting for the protocol fee (i.e. `price = pre_fee_price / (1.0 - fee)`)
250 /// and assuming zero slippage (i.e., a negligibly small trade size).
251 ///
252 /// # Arguments
253 /// * `base` - the token being priced (what you buy). For BTC/USDT, BTC is the base token.
254 /// * `quote` - the token used to price (pay) for `base`. For BTC/USDT, USDT is the quote token.
255 ///
256 /// # Examples
257 /// If the BTC/USDT is trading at 1000 with a 20% fee, this returns `1000 / (1.0 - 0.20) = 1250`
258 fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError>;
259
260 /// Returns the amount out given an amount in and input/output tokens.
261 ///
262 /// # Arguments
263 ///
264 /// * `amount_in` - The amount in of the input token.
265 /// * `token_in` - The input token ERC20 token.
266 /// * `token_out` - The output token ERC20 token.
267 ///
268 /// # Returns
269 ///
270 /// A `Result` containing a `GetAmountOutResult` struct on success or a
271 /// `SimulationError` on failure.
272 fn get_amount_out(
273 &self,
274 amount_in: BigUint,
275 token_in: &Token,
276 token_out: &Token,
277 ) -> Result<GetAmountOutResult, SimulationError>;
278
279 /// Computes the maximum amount that can be traded between two tokens.
280 ///
281 /// This function calculates the maximum possible trade amount between two tokens,
282 /// taking into account the protocol's specific constraints and mechanics.
283 /// The implementation details vary by protocol - for example:
284 /// - For constant product AMMs (like Uniswap V2), this is based on available reserves
285 /// - For concentrated liquidity AMMs (like Uniswap V3), this considers liquidity across tick
286 /// ranges
287 ///
288 /// Note: if there are no limits, the returned amount will be a "soft" limit,
289 /// meaning that the actual amount traded could be higher but it's advised to not
290 /// exceed it.
291 ///
292 /// # Arguments
293 /// * `sell_token` - The address of the token being sold
294 /// * `buy_token` - The address of the token being bought
295 ///
296 /// # Returns
297 /// * `Ok((BigUint, BigUint))` - A tuple containing:
298 /// - First element: The maximum input amount (sell_token)
299 /// - Second element: The maximum output amount (buy_token)
300 ///
301 /// For `let res = get_limits(...)`, the valid input domain for `get_amount_out` is `[0,
302 /// res.0]`.
303 ///
304 /// * `Err(SimulationError)` - If any unexpected error occurs
305 fn get_limits(
306 &self,
307 sell_token: Bytes,
308 buy_token: Bytes,
309 ) -> Result<(BigUint, BigUint), SimulationError>;
310
311 /// Decodes and applies a protocol state delta to the state
312 ///
313 /// Will error if the provided delta is missing any required attributes or if any of the
314 /// attribute values cannot be decoded.
315 ///
316 /// # Arguments
317 ///
318 /// * `delta` - A `ProtocolStateDelta` from the tycho indexer
319 ///
320 /// # Returns
321 ///
322 /// * `Result<(), TransitionError<String>>` - A `Result` containing `()` on success or a
323 /// `TransitionError` on failure.
324 fn delta_transition(
325 &mut self,
326 delta: ProtocolStateDelta,
327 tokens: &HashMap<Bytes, Token>,
328 balances: &Balances,
329 ) -> Result<(), TransitionError<String>>;
330
331 /// Calculates the swap volume required to achieve the provided goal when trading against this
332 /// pool.
333 ///
334 /// This method will branch towards different behaviors based on [SwapConstraint] enum. Please
335 /// refer to its documentation for further details on each behavior.
336 ///
337 /// In short, the current two options are:
338 /// - Maximize your trade while respecting a trade limit price:
339 /// [SwapConstraint::TradeLimitPrice]
340 /// - Move the pool price to a target price: [SwapConstraint::PoolTargetPrice]
341 ///
342 /// # Arguments
343 ///
344 /// * `params` - A [QueryPoolSwapParams] struct containing the inputs for this method.
345 ///
346 /// # Returns
347 ///
348 /// * `Ok(PoolSwap)` - A `PoolSwap` struct containing the amounts to be traded and the state of
349 /// the pool after trading.
350 /// * `Err(SimulationError)` - If:
351 /// - The calculation encounters numerical issues
352 /// - The method is not implemented for this protocol
353 #[allow(unused)]
354 fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
355 Err(SimulationError::FatalError("query_pool_swap not implemented".into()))
356 }
357
358 /// Clones the protocol state as a trait object.
359 /// This allows the state to be cloned when it is being used as a `Box<dyn ProtocolSim>`.
360 fn clone_box(&self) -> Box<dyn ProtocolSim>;
361
362 /// Allows downcasting of the trait object to its underlying type.
363 fn as_any(&self) -> &dyn Any;
364
365 /// Allows downcasting of the trait object to its mutable underlying type.
366 fn as_any_mut(&mut self) -> &mut dyn Any;
367
368 /// Compares two protocol states for equality.
369 /// This method must be implemented to define how two protocol states are considered equal
370 /// (used for tests).
371 fn eq(&self, other: &dyn ProtocolSim) -> bool;
372
373 /// Cast as IndicativelyPriced. This is necessary for RFQ protocols
374 fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
375 Err(SimulationError::FatalError("Pool State does not implement IndicativelyPriced".into()))
376 }
377}
378
379impl Clone for Box<dyn ProtocolSim> {
380 fn clone(&self) -> Box<dyn ProtocolSim> {
381 self.clone_box()
382 }
383}
384
385#[cfg(test)]
386mod tests {
387
388 use super::*;
389
390 #[test]
391 fn serde() {
392 use serde::{Deserialize, Serialize};
393
394 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
395 struct DummyProtocol {
396 reserve_0: u64,
397 reserve_1: u64,
398 }
399
400 #[typetag::serde]
401 impl ProtocolSim for DummyProtocol {
402 fn clone_box(&self) -> Box<dyn ProtocolSim> {
403 todo!()
404 }
405
406 fn as_any(&self) -> &dyn Any {
407 self
408 }
409
410 fn as_any_mut(&mut self) -> &mut dyn Any {
411 todo!()
412 }
413
414 fn eq(&self, other: &dyn ProtocolSim) -> bool {
415 if let Some(other) = other.as_any().downcast_ref::<Self>() {
416 self.reserve_0 == other.reserve_0 && self.reserve_1 == other.reserve_1
417 } else {
418 false
419 }
420 }
421
422 fn fee(&self) -> f64 {
423 todo!()
424 }
425 fn spot_price(&self, _base: &Token, _quote: &Token) -> Result<f64, SimulationError> {
426 todo!()
427 }
428 fn get_amount_out(
429 &self,
430 _amount_in: BigUint,
431 _token_in: &Token,
432 _token_out: &Token,
433 ) -> Result<GetAmountOutResult, SimulationError> {
434 todo!()
435 }
436 fn get_limits(
437 &self,
438 _sell_token: Bytes,
439 _buy_token: Bytes,
440 ) -> Result<(BigUint, BigUint), SimulationError> {
441 todo!()
442 }
443 fn delta_transition(
444 &mut self,
445 _delta: ProtocolStateDelta,
446 _tokens: &HashMap<Bytes, Token>,
447 _balances: &Balances,
448 ) -> Result<(), TransitionError<String>> {
449 todo!()
450 }
451 }
452
453 let state = DummyProtocol { reserve_0: 1, reserve_1: 2 };
454
455 assert_eq!(serde_json::to_string(&state).unwrap(), r#"{"reserve_0":1,"reserve_1":2}"#);
456 assert_eq!(
457 serde_json::to_string(&state as &dyn ProtocolSim).unwrap(),
458 r#"{"protocol":"DummyProtocol","state":{"reserve_0":1,"reserve_1":2}}"#
459 );
460
461 let deserialized: Box<dyn ProtocolSim> = serde_json::from_str(
462 r#"{"protocol":"DummyProtocol","state":{"reserve_0":1,"reserve_1":2}}"#,
463 )
464 .unwrap();
465
466 assert!(deserialized.eq(&state));
467 }
468}