Skip to main content

tycho_simulation/evm/protocol/ekubo/
state.rs

1use std::{
2    any::Any,
3    collections::{HashMap, HashSet},
4    fmt::Debug,
5};
6
7use evm_ekubo_sdk::{
8    math::uint::U256,
9    quoting::types::{NodeKey, TokenAmount},
10};
11use num_bigint::BigUint;
12use serde::{Deserialize, Serialize};
13use tycho_common::{
14    dto::ProtocolStateDelta,
15    models::token::Token,
16    simulation::{
17        errors::{SimulationError, TransitionError},
18        protocol_sim::{Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams},
19    },
20    Bytes,
21};
22
23use super::pool::{
24    base::BasePool, full_range::FullRangePool, oracle::OraclePool, twamm::TwammPool, EkuboPool,
25};
26use crate::evm::protocol::{
27    ekubo::pool::mev_resist::MevResistPool, u256_num::u256_to_f64, utils::add_fee_markup,
28};
29
30#[enum_delegate::implement(EkuboPool)]
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub enum EkuboState {
33    Base(BasePool),
34    FullRange(FullRangePool),
35    Oracle(OraclePool),
36    Twamm(TwammPool),
37    MevResist(MevResistPool),
38}
39
40fn sqrt_price_q128_to_f64(
41    x: U256,
42    (token0_decimals, token1_decimals): (usize, usize),
43) -> Result<f64, SimulationError> {
44    let token_correction = 10f64.powi(token0_decimals as i32 - token1_decimals as i32);
45
46    let price = u256_to_f64(alloy::primitives::U256::from_limbs(x.0))? / 2.0f64.powi(128);
47    Ok(price.powi(2) * token_correction)
48}
49
50#[typetag::serde]
51impl ProtocolSim for EkuboState {
52    fn fee(&self) -> f64 {
53        self.key().config.fee as f64 / (2f64.powi(64))
54    }
55
56    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
57        let sqrt_ratio = self.sqrt_ratio();
58        let (base_decimals, quote_decimals) = (base.decimals as usize, quote.decimals as usize);
59
60        let price = if base < quote {
61            sqrt_price_q128_to_f64(sqrt_ratio, (base_decimals, quote_decimals))?
62        } else {
63            1.0f64 / sqrt_price_q128_to_f64(sqrt_ratio, (quote_decimals, base_decimals))?
64        };
65        Ok(add_fee_markup(price, self.fee()))
66    }
67
68    fn get_amount_out(
69        &self,
70        amount_in: BigUint,
71        token_in: &Token,
72        _token_out: &Token,
73    ) -> Result<GetAmountOutResult, SimulationError> {
74        let token_amount = TokenAmount {
75            token: U256::from_big_endian(&token_in.address),
76            amount: amount_in.try_into().map_err(|_| {
77                SimulationError::InvalidInput("amount in must fit into a i128".to_string(), None)
78            })?,
79        };
80
81        let quote = self.quote(token_amount)?;
82
83        if quote.calculated_amount > i128::MAX as u128 {
84            return Err(SimulationError::RecoverableError(
85                "calculated amount exceeds i128::MAX".to_string(),
86            ));
87        }
88
89        let res = GetAmountOutResult {
90            amount: BigUint::from(quote.calculated_amount),
91            gas: quote.gas.into(),
92            new_state: Box::new(quote.new_state),
93        };
94
95        if quote.consumed_amount != token_amount.amount {
96            return Err(SimulationError::InvalidInput(
97                format!("pool does not have enough liquidity to support complete swap. input amount: {input_amount}, consumed amount: {consumed_amount}", input_amount = token_amount.amount, consumed_amount = quote.consumed_amount),
98                Some(res),
99            ));
100        }
101
102        Ok(res)
103    }
104
105    fn delta_transition(
106        &mut self,
107        delta: ProtocolStateDelta,
108        _tokens: &HashMap<Bytes, Token>,
109        _balances: &Balances,
110    ) -> Result<(), TransitionError> {
111        if let Some(liquidity) = delta
112            .updated_attributes
113            .get("liquidity")
114        {
115            self.set_liquidity(liquidity.clone().into());
116        }
117
118        if let Some(sqrt_price) = delta
119            .updated_attributes
120            .get("sqrt_ratio")
121        {
122            self.set_sqrt_ratio(U256::from_big_endian(sqrt_price));
123        }
124
125        self.finish_transition(delta.updated_attributes, delta.deleted_attributes)
126    }
127
128    fn clone_box(&self) -> Box<dyn ProtocolSim> {
129        Box::new(self.clone())
130    }
131
132    fn as_any(&self) -> &dyn Any {
133        self
134    }
135
136    fn as_any_mut(&mut self) -> &mut dyn Any {
137        self
138    }
139
140    fn eq(&self, other: &dyn ProtocolSim) -> bool {
141        other
142            .as_any()
143            .downcast_ref::<EkuboState>()
144            .is_some_and(|other_state| self == other_state)
145    }
146
147    fn get_limits(
148        &self,
149        sell_token: Bytes,
150        _buy_token: Bytes,
151    ) -> Result<(BigUint, BigUint), SimulationError> {
152        let consumed_amount = self.get_limit(U256::from_big_endian(&sell_token))?;
153
154        // TODO Update once exact out is supported
155        Ok((
156            BigUint::try_from(consumed_amount).map_err(|_| {
157                SimulationError::FatalError(format!(
158                    "Failed to convert consumed amount `{consumed_amount}` into BigUint"
159                ))
160            })?,
161            BigUint::ZERO,
162        ))
163    }
164
165    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
166        crate::evm::query_pool_swap::query_pool_swap(self, params)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use evm_ekubo_sdk::{
173        math::{tick::MIN_SQRT_RATIO, uint::U256},
174        quoting::types::{Config, NodeKey, Tick},
175    };
176    use rstest::*;
177    use rstest_reuse::apply;
178
179    use super::*;
180    use crate::evm::protocol::ekubo::{pool::base::BasePool, test_cases::*};
181
182    #[apply(all_cases)]
183    fn test_delta_transition(case: TestCase) {
184        let mut state = case.state_before_transition;
185
186        state
187            .delta_transition(
188                ProtocolStateDelta {
189                    updated_attributes: case.transition_attributes,
190                    ..Default::default()
191                },
192                &HashMap::default(),
193                &Balances::default(),
194            )
195            .expect("executing transition");
196
197        assert_eq!(state, case.state_after_transition);
198    }
199
200    #[apply(all_cases)]
201    fn test_get_amount_out(case: TestCase) {
202        let (token0, token1) = (case.token0(), case.token1());
203        let (amount_in, expected_out) = case.swap_token0;
204
205        let res = case
206            .state_after_transition
207            .get_amount_out(amount_in, &token0, &token1)
208            .expect("computing quote");
209
210        assert_eq!(res.amount, expected_out);
211    }
212
213    #[apply(all_cases)]
214    fn test_get_limits(case: TestCase) {
215        use std::ops::Deref;
216
217        let (token0, token1) = (case.token0(), case.token1());
218        let state = case.state_after_transition;
219
220        let max_amount_in = state
221            .get_limits(token0.address.deref().into(), token1.address.deref().into())
222            .expect("computing limits for token0")
223            .0;
224
225        assert_eq!(max_amount_in, case.expected_limit_token0);
226
227        state
228            .get_amount_out(max_amount_in, &token0, &token1)
229            .expect("quoting with limit");
230    }
231
232    #[test]
233    fn test_get_limits_negative_consumed_amount() {
234        // Reproduces an issue where get_limit was returning a negative value which then failed
235        // when converting to BigUint in get_limits. This happened for pools with depleted liquidity
236        // for the current price.
237        let eth_address = U256::zero();
238        let usdt_address_bytes =
239            hex::decode("dac17f958d2ee523a2206206994597c13d831ec7").expect("valid hex");
240        let usdt_address = U256::from_big_endian(&usdt_address_bytes);
241
242        let pool_key = NodeKey {
243            token0: eth_address,
244            token1: usdt_address,
245            config: Config { fee: 0, tick_spacing: 1000, extension: U256::zero() },
246        };
247
248        // Create a pool with single tick of minimal liquidity
249        // positioned such that one direction has effectively no liquidity
250        let state = EkuboState::Base(
251            BasePool::new(
252                pool_key,
253                vec![
254                    Tick { index: 1000, liquidity_delta: 1 },
255                    Tick { index: 2000, liquidity_delta: -1 },
256                ],
257                MIN_SQRT_RATIO, // Minimum valid price (all liquidity is above current price)
258                0,              // No liquidity at current price.
259                -887272,        // MIN_TICK (corresponding to MIN_SQRT_RATIO)
260            )
261            .unwrap(),
262        );
263
264        let (limit, _) = state
265            .get_limits(
266                pool_key.token0.to_big_endian().into(),
267                pool_key.token1.to_big_endian().into(),
268            )
269            .unwrap();
270
271        // Limit should be 0 for pool with no liquidity at current price
272        assert_eq!(limit, BigUint::ZERO);
273    }
274}