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::{
9        tick::{MAX_SQRT_RATIO, MIN_SQRT_RATIO},
10        uint::U256,
11    },
12    quoting::types::{NodeKey, TokenAmount},
13};
14use num_bigint::BigUint;
15use serde::{Deserialize, Serialize};
16use tycho_common::{
17    dto::ProtocolStateDelta,
18    models::token::Token,
19    simulation::{
20        errors::{SimulationError, TransitionError},
21        protocol_sim::{
22            Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams,
23            SwapConstraint,
24        },
25    },
26    Bytes,
27};
28
29use super::pool::{
30    base::BasePool, full_range::FullRangePool, oracle::OraclePool, twamm::TwammPool, EkuboPool,
31};
32use crate::evm::protocol::{
33    ekubo::pool::mev_resist::MevResistPool,
34    ekubo_common::{swap_to_target_price, EkuboSwapToPrice},
35    u256_num::u256_to_f64,
36    utils::add_fee_markup,
37};
38
39#[enum_delegate::implement(EkuboPool)]
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub enum EkuboState {
42    Base(BasePool),
43    FullRange(FullRangePool),
44    Oracle(OraclePool),
45    Twamm(TwammPool),
46    MevResist(MevResistPool),
47}
48
49fn sqrt_price_q128_to_f64(
50    x: U256,
51    (token0_decimals, token1_decimals): (usize, usize),
52) -> Result<f64, SimulationError> {
53    let token_correction = 10f64.powi(token0_decimals as i32 - token1_decimals as i32);
54
55    let price = u256_to_f64(alloy::primitives::U256::from_limbs(x.0))? / 2.0f64.powi(128);
56    Ok(price.powi(2) * token_correction)
57}
58
59impl EkuboSwapToPrice for EkuboState {
60    type SqrtRatio = U256;
61
62    fn sqrt_ratio_in_range(sqrt_ratio: &BigUint) -> Option<U256> {
63        if sqrt_ratio.bits() > 256 {
64            return None;
65        }
66        let sqrt_ratio = U256::from_big_endian(&sqrt_ratio.to_bytes_be());
67        (MIN_SQRT_RATIO..=MAX_SQRT_RATIO)
68            .contains(&sqrt_ratio)
69            .then_some(sqrt_ratio)
70    }
71
72    fn current_sqrt_ratio(&self) -> U256 {
73        self.sqrt_ratio()
74    }
75
76    fn quote_to_limit(
77        &self,
78        token_in: &Token,
79        amount: i128,
80        sqrt_ratio_limit: Option<U256>,
81    ) -> Result<(i128, u128, Self), SimulationError> {
82        let token_amount = TokenAmount { token: U256::from_big_endian(&token_in.address), amount };
83        let quote = self.quote(token_amount, sqrt_ratio_limit)?;
84        Ok((quote.consumed_amount, quote.calculated_amount, quote.new_state))
85    }
86}
87
88#[typetag::serde]
89impl ProtocolSim for EkuboState {
90    fn fee(&self) -> f64 {
91        self.key().config.fee as f64 / (2f64.powi(64))
92    }
93
94    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
95        let sqrt_ratio = self.sqrt_ratio();
96        let (base_decimals, quote_decimals) = (base.decimals as usize, quote.decimals as usize);
97
98        let price = if base < quote {
99            sqrt_price_q128_to_f64(sqrt_ratio, (base_decimals, quote_decimals))?
100        } else {
101            1.0f64 / sqrt_price_q128_to_f64(sqrt_ratio, (quote_decimals, base_decimals))?
102        };
103        Ok(add_fee_markup(price, self.fee()))
104    }
105
106    fn get_amount_out(
107        &self,
108        amount_in: BigUint,
109        token_in: &Token,
110        _token_out: &Token,
111    ) -> Result<GetAmountOutResult, SimulationError> {
112        let token_amount = TokenAmount {
113            token: U256::from_big_endian(&token_in.address),
114            amount: amount_in.try_into().map_err(|_| {
115                SimulationError::InvalidInput("amount in must fit into a i128".to_string(), None)
116            })?,
117        };
118
119        let quote = self.quote(token_amount, None)?;
120
121        if quote.calculated_amount > i128::MAX as u128 {
122            return Err(SimulationError::RecoverableError(
123                "calculated amount exceeds i128::MAX".to_string(),
124            ));
125        }
126
127        let res = GetAmountOutResult {
128            amount: BigUint::from(quote.calculated_amount),
129            gas: quote.gas.into(),
130            new_state: Box::new(quote.new_state),
131        };
132
133        if quote.consumed_amount != token_amount.amount {
134            return Err(SimulationError::InvalidInput(
135                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),
136                Some(res),
137            ));
138        }
139
140        Ok(res)
141    }
142
143    fn delta_transition(
144        &mut self,
145        delta: ProtocolStateDelta,
146        _tokens: &HashMap<Bytes, Token>,
147        _balances: &Balances,
148    ) -> Result<(), TransitionError> {
149        if let Some(liquidity) = delta
150            .updated_attributes
151            .get("liquidity")
152        {
153            self.set_liquidity(liquidity.clone().into());
154        }
155
156        if let Some(sqrt_price) = delta
157            .updated_attributes
158            .get("sqrt_ratio")
159        {
160            self.set_sqrt_ratio(U256::from_big_endian(sqrt_price));
161        }
162
163        self.finish_transition(delta.updated_attributes, delta.deleted_attributes)
164    }
165
166    fn clone_box(&self) -> Box<dyn ProtocolSim> {
167        Box::new(self.clone())
168    }
169
170    fn as_any(&self) -> &dyn Any {
171        self
172    }
173
174    fn as_any_mut(&mut self) -> &mut dyn Any {
175        self
176    }
177
178    fn eq(&self, other: &dyn ProtocolSim) -> bool {
179        other
180            .as_any()
181            .downcast_ref::<EkuboState>()
182            .is_some_and(|other_state| self == other_state)
183    }
184
185    fn get_limits(
186        &self,
187        sell_token: Bytes,
188        _buy_token: Bytes,
189    ) -> Result<(BigUint, BigUint), SimulationError> {
190        let consumed_amount = self.get_limit(U256::from_big_endian(&sell_token))?;
191
192        // TODO Update once exact out is supported
193        Ok((
194            BigUint::try_from(consumed_amount).map_err(|_| {
195                SimulationError::FatalError(format!(
196                    "Failed to convert consumed amount `{consumed_amount}` into BigUint"
197                ))
198            })?,
199            BigUint::ZERO,
200        ))
201    }
202
203    /// Solves [`SwapConstraint::PoolTargetPrice`] natively with a sqrt ratio limit. This path
204    /// ignores `min_amount_in`, `max_amount_in` and `tolerance`, and returns no price points.
205    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
206        match params.swap_constraint() {
207            SwapConstraint::TradeLimitPrice { .. } => {
208                crate::evm::query_pool_swap::query_pool_swap(self, params)
209            }
210            SwapConstraint::PoolTargetPrice { target, .. } => {
211                swap_to_target_price(self, params, target, self.key().config.fee)
212            }
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use evm_ekubo_sdk::{
220        math::{tick::MIN_SQRT_RATIO, uint::U256},
221        quoting::types::{Config, NodeKey, Tick},
222    };
223    use rstest::*;
224    use rstest_reuse::apply;
225
226    use super::*;
227    use crate::evm::protocol::{
228        ekubo::{pool::base::BasePool, test_cases::*},
229        ekubo_common::test_helpers::*,
230    };
231
232    #[apply(all_cases)]
233    fn test_delta_transition(case: TestCase) {
234        let mut state = case.state_before_transition;
235
236        state
237            .delta_transition(
238                ProtocolStateDelta {
239                    updated_attributes: case.transition_attributes,
240                    ..Default::default()
241                },
242                &HashMap::default(),
243                &Balances::default(),
244            )
245            .expect("executing transition");
246
247        assert_eq!(state, case.state_after_transition);
248    }
249
250    #[apply(all_cases)]
251    fn test_get_amount_out(case: TestCase) {
252        let (token0, token1) = (case.token0(), case.token1());
253        let (amount_in, expected_out) = case.swap_token0;
254
255        let res = case
256            .state_after_transition
257            .get_amount_out(amount_in, &token0, &token1)
258            .expect("computing quote");
259
260        assert_eq!(res.amount, expected_out);
261    }
262
263    #[apply(all_cases)]
264    fn test_get_limits(case: TestCase) {
265        use std::ops::Deref;
266
267        let (token0, token1) = (case.token0(), case.token1());
268        let state = case.state_after_transition;
269
270        let max_amount_in = state
271            .get_limits(token0.address.deref().into(), token1.address.deref().into())
272            .expect("computing limits for token0")
273            .0;
274
275        assert_eq!(max_amount_in, case.expected_limit_token0);
276
277        state
278            .get_amount_out(max_amount_in, &token0, &token1)
279            .expect("quoting with limit");
280    }
281
282    #[test]
283    fn test_get_limits_negative_consumed_amount() {
284        // Reproduces an issue where get_limit was returning a negative value which then failed
285        // when converting to BigUint in get_limits. This happened for pools with depleted liquidity
286        // for the current price.
287        let eth_address = U256::zero();
288        let usdt_address_bytes =
289            hex::decode("dac17f958d2ee523a2206206994597c13d831ec7").expect("valid hex");
290        let usdt_address = U256::from_big_endian(&usdt_address_bytes);
291
292        let pool_key = NodeKey {
293            token0: eth_address,
294            token1: usdt_address,
295            config: Config { fee: 0, tick_spacing: 1000, extension: U256::zero() },
296        };
297
298        // Create a pool with single tick of minimal liquidity
299        // positioned such that one direction has effectively no liquidity
300        let state = EkuboState::Base(
301            BasePool::new(
302                pool_key,
303                vec![
304                    Tick { index: 1000, liquidity_delta: 1 },
305                    Tick { index: 2000, liquidity_delta: -1 },
306                ],
307                MIN_SQRT_RATIO, // Minimum valid price (all liquidity is above current price)
308                0,              // No liquidity at current price.
309                -887272,        // MIN_TICK (corresponding to MIN_SQRT_RATIO)
310            )
311            .unwrap(),
312        );
313
314        let (limit, _) = state
315            .get_limits(
316                pool_key.token0.to_big_endian().into(),
317                pool_key.token1.to_big_endian().into(),
318            )
319            .unwrap();
320
321        // Limit should be 0 for pool with no liquidity at current price
322        assert_eq!(limit, BigUint::ZERO);
323    }
324
325    #[rstest]
326    #[case::full_range(full_range(), 0.95)]
327    #[case::mev_resist_with_fee(mev_resist(), 0.999_995)]
328    #[case::oracle(oracle(), 0.99)]
329    #[case::twamm(twamm(), 0.99)]
330    fn test_query_pool_swap_target_price_lands_in_band(
331        #[case] case: TestCase,
332        #[case] multiplier: f64,
333    ) {
334        assert_lands_in_band(
335            &case.state_after_transition,
336            &case.token0(),
337            &case.token1(),
338            multiplier,
339        );
340    }
341
342    #[rstest]
343    fn test_query_pool_swap_target_price_above_spot(full_range: TestCase) {
344        let state = &full_range.state_after_transition;
345        assert_target_above_spot_rejected(state, &full_range.token0(), &full_range.token1());
346    }
347
348    #[rstest]
349    fn test_query_pool_swap_target_price_at_spot(full_range: TestCase) {
350        let state = &full_range.state_after_transition;
351        assert_target_at_spot_gives_zero_swap(state, &full_range.token0(), &full_range.token1());
352    }
353
354    #[rstest]
355    fn test_query_pool_swap_target_price_out_of_range(full_range: TestCase) {
356        let state = &full_range.state_after_transition;
357        assert_out_of_range_falls_back(state, &full_range.token0(), &full_range.token1());
358    }
359
360    #[rstest]
361    fn test_query_pool_swap_target_price_empty_pool(full_range: TestCase) {
362        let state = empty_full_range_state();
363        assert_missed_limit_falls_back(&state, &full_range.token0(), &full_range.token1());
364    }
365
366    #[test]
367    fn test_query_pool_swap_target_price_virtual_orders_past_target() {
368        let case = twamm();
369        let state = &case.state_after_transition;
370        assert_virtual_orders_applied_before_direction_check(state, &case.token0(), &case.token1());
371    }
372}