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},
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::{Balances, GetAmountOutResult, ProtocolSim},
20    },
21    Bytes,
22};
23
24use super::pool::{
25    concentrated::ConcentratedPool, full_range::FullRangePool, oracle::OraclePool,
26    twamm::TwammPool, ve33::Ve33Pool, EkuboPool, EkuboPoolQuote,
27};
28use crate::evm::protocol::{
29    ekubo_v3::{
30        addresses::SIGNED_EXCLUSIVE_SWAP_ADDRESS,
31        pool::{
32            boosted_fees::BoostedFeesPool, mev_capture::MevCapturePool, stableswap::StableswapPool,
33        },
34    },
35    u256_num::u256_to_f64,
36};
37
38/// Gas cost of `Core.forward`, the signature check and the signed-fee accounting, on top of the
39/// swap itself.
40///
41/// Measured against a plain-swap baseline in Ekubo's `SignedExclusiveSwap.t.sol`: `forward` plus
42/// the signature check costs 37,852 and charging the signed fee costs another 24,521. Fynd signs a
43/// fee above zero, so the constant is the sum of both.
44const SIGNED_EXCLUSIVE_SWAP_GAS: u64 = 62_373;
45
46#[enum_delegate::implement(EkuboPool)]
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub enum EkuboV3State {
49    Concentrated(ConcentratedPool),
50    FullRange(FullRangePool),
51    Stableswap(StableswapPool),
52    Oracle(OraclePool),
53    Twamm(TwammPool),
54    MevCapture(MevCapturePool),
55    BoostedFees(BoostedFeesPool),
56    Ve33(Ve33Pool),
57}
58
59fn sqrt_price_q128_to_f64(
60    x: U256,
61    (token0_decimals, token1_decimals): (usize, usize),
62) -> Result<f64, SimulationError> {
63    let token_correction = 10f64.powi(token0_decimals as i32 - token1_decimals as i32);
64
65    let price = u256_to_f64(x)? / 2.0f64.powi(128);
66    Ok(price.powi(2) * token_correction)
67}
68
69impl EkuboV3State {
70    /// Zero unless the extension forces the swap through `Core.forward`.
71    fn forward_overhead_gas(&self) -> u64 {
72        if self.key().config.extension == SIGNED_EXCLUSIVE_SWAP_ADDRESS {
73            SIGNED_EXCLUSIVE_SWAP_GAS
74        } else {
75            0
76        }
77    }
78}
79
80#[typetag::serde]
81impl ProtocolSim for EkuboV3State {
82    fn fee(&self) -> f64 {
83        let fee = match self {
84            Self::Ve33(pool) => pool.swap_fee(),
85            _ => self.key().config.fee,
86        };
87        fee as f64 / (2f64.powi(64))
88    }
89
90    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
91        let sqrt_ratio = self.sqrt_ratio();
92        let (base_decimals, quote_decimals) = (base.decimals as usize, quote.decimals as usize);
93
94        if base < quote {
95            sqrt_price_q128_to_f64(sqrt_ratio, (base_decimals, quote_decimals))
96        } else {
97            sqrt_price_q128_to_f64(sqrt_ratio, (quote_decimals, base_decimals))
98                .map(|price| 1.0f64 / price)
99        }
100    }
101
102    fn get_amount_out(
103        &self,
104        amount_in: BigUint,
105        token_in: &Token,
106        _token_out: &Token,
107    ) -> Result<GetAmountOutResult, SimulationError> {
108        let token_amount = EvmTokenAmount {
109            token: Address::try_from(&token_in.address[..]).map_err(|err| {
110                SimulationError::InvalidInput(format!("token_in invalid: {err}"), None)
111            })?,
112            amount: amount_in.try_into().map_err(|_| {
113                SimulationError::InvalidInput("amount in must fit into a i128".to_string(), None)
114            })?,
115        };
116
117        let quote = self.quote(token_amount)?;
118
119        if quote.calculated_amount > i128::MAX as u128 {
120            return Err(SimulationError::RecoverableError(
121                "calculated amount exceeds i128::MAX".to_string(),
122            ));
123        }
124
125        let res = GetAmountOutResult {
126            amount: BigUint::from(quote.calculated_amount),
127            gas: BigUint::from(quote.gas) + BigUint::from(self.forward_overhead_gas()),
128            new_state: Box::new(quote.new_state),
129        };
130
131        if quote.consumed_amount != token_amount.amount {
132            return Err(SimulationError::InvalidInput(
133                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),
134                Some(res),
135            ));
136        }
137
138        Ok(res)
139    }
140
141    fn delta_transition(
142        &mut self,
143        delta: ProtocolStateDelta,
144        _tokens: &HashMap<Bytes, Token>,
145        _balances: &Balances,
146    ) -> Result<(), TransitionError> {
147        if let Some(liquidity) = delta
148            .updated_attributes
149            .get("liquidity")
150        {
151            self.set_liquidity(liquidity.clone().into());
152        }
153
154        if let Some(sqrt_price) = delta
155            .updated_attributes
156            .get("sqrt_ratio")
157        {
158            self.set_sqrt_ratio(U256::try_from_be_slice(sqrt_price).ok_or_else(|| {
159                TransitionError::DecodeError("failed to parse updated pool price".to_string())
160            })?);
161        }
162
163        self.finish_transition(delta.updated_attributes, delta.deleted_attributes)
164    }
165
166    fn query_pool_swap(
167        &self,
168        params: &tycho_common::simulation::protocol_sim::QueryPoolSwapParams,
169    ) -> Result<tycho_common::simulation::protocol_sim::PoolSwap, SimulationError> {
170        crate::evm::query_pool_swap::query_pool_swap(self, params)
171    }
172
173    fn clone_box(&self) -> Box<dyn ProtocolSim> {
174        Box::new(self.clone())
175    }
176
177    fn as_any(&self) -> &dyn Any {
178        self
179    }
180
181    fn as_any_mut(&mut self) -> &mut dyn Any {
182        self
183    }
184
185    fn eq(&self, other: &dyn ProtocolSim) -> bool {
186        other
187            .as_any()
188            .downcast_ref::<EkuboV3State>()
189            .is_some_and(|other_state| self == other_state)
190    }
191
192    fn get_limits(
193        &self,
194        sell_token: Bytes,
195        _buy_token: Bytes,
196    ) -> Result<(BigUint, BigUint), SimulationError> {
197        let consumed_amount =
198            self.get_limit(Address::try_from(&sell_token[..]).map_err(|err| {
199                SimulationError::InvalidInput(format!("sell_token invalid: {err}"), None)
200            })?)?;
201
202        // TODO Update once exact out is supported
203        Ok((
204            BigUint::try_from(consumed_amount).map_err(|_| {
205                SimulationError::FatalError(format!(
206                    "Failed to convert consumed amount `{consumed_amount}` into BigUint"
207                ))
208            })?,
209            BigUint::ZERO,
210        ))
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use rstest::*;
217    use rstest_reuse::apply;
218
219    use super::*;
220    use crate::evm::protocol::ekubo_v3::test_cases::*;
221
222    /// Both pools price identically, so the gas gap is exactly the forward overhead.
223    #[rstest]
224    fn test_signed_exclusive_swap_gas_includes_the_forward_overhead() {
225        let signed = signed_exclusive_swap();
226        let (token0, token1) = (signed.token0(), signed.token1());
227        let (amount_in, _) = signed.swap_token0.clone();
228
229        let signed_gas = signed
230            .state_after_transition
231            .get_amount_out(amount_in.clone(), &token0, &token1)
232            .expect("signed pool quotes")
233            .gas;
234
235        let plain = concentrated();
236        let plain_gas = plain
237            .state_after_transition
238            .get_amount_out(amount_in, &plain.token0(), &plain.token1())
239            .expect("plain pool quotes")
240            .gas;
241
242        assert_eq!(
243            signed_gas - plain_gas,
244            BigUint::from(SIGNED_EXCLUSIVE_SWAP_GAS),
245            "the signed pool must carry exactly the forward overhead over an equivalent plain pool"
246        );
247    }
248
249    /// Only a pool that cannot be swapped without `Core.forward` is surcharged.
250    #[rstest]
251    fn test_other_pools_carry_no_forward_overhead() {
252        for case in [concentrated(), full_range(), mev_capture()] {
253            assert_eq!(
254                case.state_after_transition
255                    .forward_overhead_gas(),
256                0,
257                "only a signed-exclusive pool is surcharged"
258            );
259        }
260    }
261
262    #[apply(all_cases)]
263    fn test_delta_transition(case: TestCase) {
264        let mut state = case.state_before_transition;
265
266        state
267            .delta_transition(
268                ProtocolStateDelta {
269                    updated_attributes: case.transition_attributes,
270                    ..Default::default()
271                },
272                &HashMap::default(),
273                &Balances::default(),
274            )
275            .expect("executing transition");
276
277        assert_eq!(state, case.state_after_transition);
278    }
279
280    #[apply(all_cases)]
281    fn test_get_amount_out(case: TestCase) {
282        let (token0, token1) = (case.token0(), case.token1());
283        let (amount_in, expected_out) = case.swap_token0;
284
285        let res = case
286            .state_after_transition
287            .get_amount_out(amount_in, &token0, &token1)
288            .expect("computing quote");
289
290        assert_eq!(res.amount, expected_out);
291    }
292
293    #[apply(all_cases)]
294    fn test_get_limits(case: TestCase) {
295        use std::ops::Deref;
296
297        let (token0, token1) = (case.token0(), case.token1());
298        let state = case.state_after_transition;
299
300        let max_amount_in = state
301            .get_limits(token0.address.deref().into(), token1.address.deref().into())
302            .expect("computing limits for token0")
303            .0;
304
305        assert_eq!(max_amount_in, case.expected_limit_token0);
306
307        state
308            .get_amount_out(max_amount_in, &token0, &token1)
309            .expect("quoting with limit");
310    }
311}