Skip to main content

tycho_simulation/evm/protocol/ekubo_v3/
state.rs

1use std::{
2    any::Any,
3    collections::{HashMap, HashSet},
4    fmt::Debug,
5};
6
7use ekubo_sdk::{
8    chain::evm::{EvmPoolKey, EvmTokenAmount, EVM_MAX_SQRT_RATIO, EVM_MIN_SQRT_RATIO},
9    U256,
10};
11use num_bigint::BigUint;
12use revm::primitives::Address;
13use serde::{Deserialize, Serialize};
14use tycho_common::{
15    dto::ProtocolStateDelta,
16    models::token::Token,
17    simulation::{
18        errors::{SimulationError, TransitionError},
19        protocol_sim::{
20            Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams,
21            SwapConstraint,
22        },
23    },
24    Bytes,
25};
26
27use super::pool::{
28    concentrated::ConcentratedPool, full_range::FullRangePool, oracle::OraclePool,
29    twamm::TwammPool, ve33::Ve33Pool, EkuboPool, EkuboPoolQuote,
30};
31use crate::evm::protocol::{
32    ekubo_common::{swap_to_target_price, EkuboSwapToPrice},
33    ekubo_v3::{
34        addresses::SIGNED_EXCLUSIVE_SWAP_DEPLOYMENTS,
35        pool::{
36            boosted_fees::BoostedFeesPool, mev_capture::MevCapturePool, stableswap::StableswapPool,
37        },
38    },
39    u256_num::u256_to_f64,
40};
41
42/// Gas cost of `Core.forward`, the signature check and the signed-fee accounting, on top of the
43/// swap itself.
44///
45/// Measured against a plain-swap baseline in Ekubo's `SignedExclusiveSwap.t.sol`: `forward` plus
46/// the signature check costs 37,852 and charging the signed fee costs another 24,521. Fynd signs a
47/// fee above zero, so the constant is the sum of both.
48const SIGNED_EXCLUSIVE_SWAP_GAS: u64 = 62_373;
49
50#[enum_delegate::implement(EkuboPool)]
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub enum EkuboV3State {
53    Concentrated(ConcentratedPool),
54    FullRange(FullRangePool),
55    Stableswap(StableswapPool),
56    Oracle(OraclePool),
57    Twamm(TwammPool),
58    MevCapture(MevCapturePool),
59    BoostedFees(BoostedFeesPool),
60    Ve33(Ve33Pool),
61}
62
63fn sqrt_price_q128_to_f64(
64    x: U256,
65    (token0_decimals, token1_decimals): (usize, usize),
66) -> Result<f64, SimulationError> {
67    let token_correction = 10f64.powi(token0_decimals as i32 - token1_decimals as i32);
68
69    let price = u256_to_f64(x)? / 2.0f64.powi(128);
70    Ok(price.powi(2) * token_correction)
71}
72
73impl EkuboV3State {
74    /// Zero unless the extension forces the swap through `Core.forward`.
75    ///
76    /// The pool key carries no chain, so a SignedExclusiveSwap address from any deployment counts.
77    fn forward_overhead_gas(&self) -> u64 {
78        let extension = self.key().config.extension;
79        if SIGNED_EXCLUSIVE_SWAP_DEPLOYMENTS
80            .iter()
81            .any(|(_, deployment)| *deployment == extension)
82        {
83            SIGNED_EXCLUSIVE_SWAP_GAS
84        } else {
85            0
86        }
87    }
88}
89
90impl EkuboSwapToPrice for EkuboV3State {
91    type SqrtRatio = U256;
92
93    fn sqrt_ratio_in_range(sqrt_ratio: &BigUint) -> Option<U256> {
94        let sqrt_ratio = U256::try_from_be_slice(&sqrt_ratio.to_bytes_be())?;
95        (EVM_MIN_SQRT_RATIO..=EVM_MAX_SQRT_RATIO)
96            .contains(&sqrt_ratio)
97            .then_some(sqrt_ratio)
98    }
99
100    fn current_sqrt_ratio(&self) -> U256 {
101        self.sqrt_ratio()
102    }
103
104    fn quote_to_limit(
105        &self,
106        token_in: &Token,
107        amount: i128,
108        sqrt_ratio_limit: Option<U256>,
109    ) -> Result<(i128, u128, Self), SimulationError> {
110        let token = Address::try_from(&token_in.address[..]).map_err(|err| {
111            SimulationError::InvalidInput(format!("token_in invalid: {err}"), None)
112        })?;
113        let quote = self.quote(EvmTokenAmount { token, amount }, sqrt_ratio_limit)?;
114        Ok((quote.consumed_amount, quote.calculated_amount, quote.new_state))
115    }
116}
117
118#[typetag::serde]
119impl ProtocolSim for EkuboV3State {
120    fn fee(&self) -> f64 {
121        let fee = match self {
122            Self::Ve33(pool) => pool.swap_fee(),
123            _ => self.key().config.fee,
124        };
125        fee as f64 / (2f64.powi(64))
126    }
127
128    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
129        let sqrt_ratio = self.sqrt_ratio();
130        let (base_decimals, quote_decimals) = (base.decimals as usize, quote.decimals as usize);
131
132        if base < quote {
133            sqrt_price_q128_to_f64(sqrt_ratio, (base_decimals, quote_decimals))
134        } else {
135            sqrt_price_q128_to_f64(sqrt_ratio, (quote_decimals, base_decimals))
136                .map(|price| 1.0f64 / price)
137        }
138    }
139
140    fn get_amount_out(
141        &self,
142        amount_in: BigUint,
143        token_in: &Token,
144        _token_out: &Token,
145    ) -> Result<GetAmountOutResult, SimulationError> {
146        let token_amount = EvmTokenAmount {
147            token: Address::try_from(&token_in.address[..]).map_err(|err| {
148                SimulationError::InvalidInput(format!("token_in invalid: {err}"), None)
149            })?,
150            amount: amount_in.try_into().map_err(|_| {
151                SimulationError::InvalidInput("amount in must fit into a i128".to_string(), None)
152            })?,
153        };
154
155        let quote = self.quote(token_amount, None)?;
156
157        if quote.calculated_amount > i128::MAX as u128 {
158            return Err(SimulationError::RecoverableError(
159                "calculated amount exceeds i128::MAX".to_string(),
160            ));
161        }
162
163        let res = GetAmountOutResult {
164            amount: BigUint::from(quote.calculated_amount),
165            gas: BigUint::from(quote.gas) + BigUint::from(self.forward_overhead_gas()),
166            new_state: Box::new(quote.new_state),
167        };
168
169        if quote.consumed_amount != token_amount.amount {
170            return Err(SimulationError::InvalidInput(
171                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),
172                Some(res),
173            ));
174        }
175
176        Ok(res)
177    }
178
179    fn delta_transition(
180        &mut self,
181        delta: ProtocolStateDelta,
182        _tokens: &HashMap<Bytes, Token>,
183        _balances: &Balances,
184    ) -> Result<(), TransitionError> {
185        if let Some(liquidity) = delta
186            .updated_attributes
187            .get("liquidity")
188        {
189            self.set_liquidity(liquidity.clone().into());
190        }
191
192        if let Some(sqrt_price) = delta
193            .updated_attributes
194            .get("sqrt_ratio")
195        {
196            self.set_sqrt_ratio(U256::try_from_be_slice(sqrt_price).ok_or_else(|| {
197                TransitionError::DecodeError("failed to parse updated pool price".to_string())
198            })?);
199        }
200
201        self.finish_transition(delta.updated_attributes, delta.deleted_attributes)
202    }
203
204    /// Solves [`SwapConstraint::PoolTargetPrice`] natively with a sqrt ratio limit. This path
205    /// ignores `min_amount_in`, `max_amount_in` and `tolerance`, and returns no price points.
206    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
207        match params.swap_constraint() {
208            SwapConstraint::TradeLimitPrice { .. } => {
209                crate::evm::query_pool_swap::query_pool_swap(self, params)
210            }
211            SwapConstraint::PoolTargetPrice { target, .. } => {
212                // Ekubo v3 spot prices have no fee markup.
213                swap_to_target_price(self, params, target, 0)
214            }
215        }
216    }
217
218    fn clone_box(&self) -> Box<dyn ProtocolSim> {
219        Box::new(self.clone())
220    }
221
222    fn as_any(&self) -> &dyn Any {
223        self
224    }
225
226    fn as_any_mut(&mut self) -> &mut dyn Any {
227        self
228    }
229
230    fn eq(&self, other: &dyn ProtocolSim) -> bool {
231        other
232            .as_any()
233            .downcast_ref::<EkuboV3State>()
234            .is_some_and(|other_state| self == other_state)
235    }
236
237    fn get_limits(
238        &self,
239        sell_token: Bytes,
240        _buy_token: Bytes,
241    ) -> Result<(BigUint, BigUint), SimulationError> {
242        let consumed_amount =
243            self.get_limit(Address::try_from(&sell_token[..]).map_err(|err| {
244                SimulationError::InvalidInput(format!("sell_token invalid: {err}"), None)
245            })?)?;
246
247        // TODO Update once exact out is supported
248        Ok((
249            BigUint::try_from(consumed_amount).map_err(|_| {
250                SimulationError::FatalError(format!(
251                    "Failed to convert consumed amount `{consumed_amount}` into BigUint"
252                ))
253            })?,
254            BigUint::ZERO,
255        ))
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use rstest::*;
262    use rstest_reuse::apply;
263
264    use super::*;
265    use crate::evm::protocol::{ekubo_common::test_helpers::*, ekubo_v3::test_cases::*};
266
267    /// Both pools price identically, so the gas gap is exactly the forward overhead.
268    #[rstest]
269    fn test_signed_exclusive_swap_gas_includes_the_forward_overhead() {
270        let signed = signed_exclusive_swap();
271        let (token0, token1) = (signed.token0(), signed.token1());
272        let (amount_in, _) = signed.swap_token0.clone();
273
274        let signed_gas = signed
275            .state_after_transition
276            .get_amount_out(amount_in.clone(), &token0, &token1)
277            .expect("signed pool quotes")
278            .gas;
279
280        let plain = concentrated();
281        let plain_gas = plain
282            .state_after_transition
283            .get_amount_out(amount_in, &plain.token0(), &plain.token1())
284            .expect("plain pool quotes")
285            .gas;
286
287        assert_eq!(
288            signed_gas - plain_gas,
289            BigUint::from(SIGNED_EXCLUSIVE_SWAP_GAS),
290            "the signed pool must carry exactly the forward overhead over an equivalent plain pool"
291        );
292    }
293
294    /// Only a pool that cannot be swapped without `Core.forward` is surcharged.
295    #[rstest]
296    fn test_other_pools_carry_no_forward_overhead() {
297        for case in [concentrated(), full_range(), mev_capture()] {
298            assert_eq!(
299                case.state_after_transition
300                    .forward_overhead_gas(),
301                0,
302                "only a signed-exclusive pool is surcharged"
303            );
304        }
305    }
306
307    #[apply(all_cases)]
308    fn test_delta_transition(case: TestCase) {
309        let mut state = case.state_before_transition;
310
311        state
312            .delta_transition(
313                ProtocolStateDelta {
314                    updated_attributes: case.transition_attributes,
315                    ..Default::default()
316                },
317                &HashMap::default(),
318                &Balances::default(),
319            )
320            .expect("executing transition");
321
322        assert_eq!(state, case.state_after_transition);
323    }
324
325    #[apply(all_cases)]
326    fn test_get_amount_out(case: TestCase) {
327        let (token0, token1) = (case.token0(), case.token1());
328        let (amount_in, expected_out) = case.swap_token0;
329
330        let res = case
331            .state_after_transition
332            .get_amount_out(amount_in, &token0, &token1)
333            .expect("computing quote");
334
335        assert_eq!(res.amount, expected_out);
336    }
337
338    #[apply(all_cases)]
339    fn test_get_limits(case: TestCase) {
340        use std::ops::Deref;
341
342        let (token0, token1) = (case.token0(), case.token1());
343        let state = case.state_after_transition;
344
345        let max_amount_in = state
346            .get_limits(token0.address.deref().into(), token1.address.deref().into())
347            .expect("computing limits for token0")
348            .0;
349
350        assert_eq!(max_amount_in, case.expected_limit_token0);
351
352        state
353            .get_amount_out(max_amount_in, &token0, &token1)
354            .expect("quoting with limit");
355    }
356
357    #[rstest]
358    #[case::full_range(full_range(), 0.95)]
359    #[case::mev_capture_with_fee(mev_capture(), 0.999_995)]
360    #[case::stableswap(stableswap(), 0.99)]
361    #[case::twamm(twamm(), 0.99)]
362    fn test_query_pool_swap_target_price_lands_in_band(
363        #[case] case: TestCase,
364        #[case] multiplier: f64,
365    ) {
366        assert_lands_in_band(
367            &case.state_after_transition,
368            &case.token0(),
369            &case.token1(),
370            multiplier,
371        );
372    }
373
374    #[rstest]
375    fn test_query_pool_swap_target_price_above_spot(full_range: TestCase) {
376        let state = &full_range.state_after_transition;
377        assert_target_above_spot_rejected(state, &full_range.token0(), &full_range.token1());
378    }
379
380    #[rstest]
381    fn test_query_pool_swap_target_price_at_spot(full_range: TestCase) {
382        let state = &full_range.state_after_transition;
383        assert_target_at_spot_gives_zero_swap(state, &full_range.token0(), &full_range.token1());
384    }
385
386    #[rstest]
387    fn test_query_pool_swap_target_price_out_of_range(full_range: TestCase) {
388        let state = &full_range.state_after_transition;
389        assert_out_of_range_falls_back(state, &full_range.token0(), &full_range.token1());
390    }
391
392    #[rstest]
393    fn test_query_pool_swap_target_price_empty_pool(full_range: TestCase) {
394        let state = empty_full_range_state();
395        assert_missed_limit_falls_back(&state, &full_range.token0(), &full_range.token1());
396    }
397
398    #[test]
399    fn test_query_pool_swap_target_price_virtual_orders_past_target() {
400        let case = twamm();
401        let state = &case.state_after_transition;
402        assert_virtual_orders_applied_before_direction_check(state, &case.token0(), &case.token1());
403    }
404}