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_DEPLOYMENTS,
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    ///
72    /// The pool key carries no chain, so a SignedExclusiveSwap address from any deployment counts.
73    fn forward_overhead_gas(&self) -> u64 {
74        let extension = self.key().config.extension;
75        if SIGNED_EXCLUSIVE_SWAP_DEPLOYMENTS
76            .iter()
77            .any(|(_, deployment)| *deployment == extension)
78        {
79            SIGNED_EXCLUSIVE_SWAP_GAS
80        } else {
81            0
82        }
83    }
84}
85
86#[typetag::serde]
87impl ProtocolSim for EkuboV3State {
88    fn fee(&self) -> f64 {
89        let fee = match self {
90            Self::Ve33(pool) => pool.swap_fee(),
91            _ => self.key().config.fee,
92        };
93        fee as f64 / (2f64.powi(64))
94    }
95
96    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
97        let sqrt_ratio = self.sqrt_ratio();
98        let (base_decimals, quote_decimals) = (base.decimals as usize, quote.decimals as usize);
99
100        if base < quote {
101            sqrt_price_q128_to_f64(sqrt_ratio, (base_decimals, quote_decimals))
102        } else {
103            sqrt_price_q128_to_f64(sqrt_ratio, (quote_decimals, base_decimals))
104                .map(|price| 1.0f64 / price)
105        }
106    }
107
108    fn get_amount_out(
109        &self,
110        amount_in: BigUint,
111        token_in: &Token,
112        _token_out: &Token,
113    ) -> Result<GetAmountOutResult, SimulationError> {
114        let token_amount = EvmTokenAmount {
115            token: Address::try_from(&token_in.address[..]).map_err(|err| {
116                SimulationError::InvalidInput(format!("token_in invalid: {err}"), None)
117            })?,
118            amount: amount_in.try_into().map_err(|_| {
119                SimulationError::InvalidInput("amount in must fit into a i128".to_string(), None)
120            })?,
121        };
122
123        let quote = self.quote(token_amount)?;
124
125        if quote.calculated_amount > i128::MAX as u128 {
126            return Err(SimulationError::RecoverableError(
127                "calculated amount exceeds i128::MAX".to_string(),
128            ));
129        }
130
131        let res = GetAmountOutResult {
132            amount: BigUint::from(quote.calculated_amount),
133            gas: BigUint::from(quote.gas) + BigUint::from(self.forward_overhead_gas()),
134            new_state: Box::new(quote.new_state),
135        };
136
137        if quote.consumed_amount != token_amount.amount {
138            return Err(SimulationError::InvalidInput(
139                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),
140                Some(res),
141            ));
142        }
143
144        Ok(res)
145    }
146
147    fn delta_transition(
148        &mut self,
149        delta: ProtocolStateDelta,
150        _tokens: &HashMap<Bytes, Token>,
151        _balances: &Balances,
152    ) -> Result<(), TransitionError> {
153        if let Some(liquidity) = delta
154            .updated_attributes
155            .get("liquidity")
156        {
157            self.set_liquidity(liquidity.clone().into());
158        }
159
160        if let Some(sqrt_price) = delta
161            .updated_attributes
162            .get("sqrt_ratio")
163        {
164            self.set_sqrt_ratio(U256::try_from_be_slice(sqrt_price).ok_or_else(|| {
165                TransitionError::DecodeError("failed to parse updated pool price".to_string())
166            })?);
167        }
168
169        self.finish_transition(delta.updated_attributes, delta.deleted_attributes)
170    }
171
172    fn query_pool_swap(
173        &self,
174        params: &tycho_common::simulation::protocol_sim::QueryPoolSwapParams,
175    ) -> Result<tycho_common::simulation::protocol_sim::PoolSwap, SimulationError> {
176        crate::evm::query_pool_swap::query_pool_swap(self, params)
177    }
178
179    fn clone_box(&self) -> Box<dyn ProtocolSim> {
180        Box::new(self.clone())
181    }
182
183    fn as_any(&self) -> &dyn Any {
184        self
185    }
186
187    fn as_any_mut(&mut self) -> &mut dyn Any {
188        self
189    }
190
191    fn eq(&self, other: &dyn ProtocolSim) -> bool {
192        other
193            .as_any()
194            .downcast_ref::<EkuboV3State>()
195            .is_some_and(|other_state| self == other_state)
196    }
197
198    fn get_limits(
199        &self,
200        sell_token: Bytes,
201        _buy_token: Bytes,
202    ) -> Result<(BigUint, BigUint), SimulationError> {
203        let consumed_amount =
204            self.get_limit(Address::try_from(&sell_token[..]).map_err(|err| {
205                SimulationError::InvalidInput(format!("sell_token invalid: {err}"), None)
206            })?)?;
207
208        // TODO Update once exact out is supported
209        Ok((
210            BigUint::try_from(consumed_amount).map_err(|_| {
211                SimulationError::FatalError(format!(
212                    "Failed to convert consumed amount `{consumed_amount}` into BigUint"
213                ))
214            })?,
215            BigUint::ZERO,
216        ))
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use rstest::*;
223    use rstest_reuse::apply;
224
225    use super::*;
226    use crate::evm::protocol::ekubo_v3::test_cases::*;
227
228    /// Both pools price identically, so the gas gap is exactly the forward overhead.
229    #[rstest]
230    fn test_signed_exclusive_swap_gas_includes_the_forward_overhead() {
231        let signed = signed_exclusive_swap();
232        let (token0, token1) = (signed.token0(), signed.token1());
233        let (amount_in, _) = signed.swap_token0.clone();
234
235        let signed_gas = signed
236            .state_after_transition
237            .get_amount_out(amount_in.clone(), &token0, &token1)
238            .expect("signed pool quotes")
239            .gas;
240
241        let plain = concentrated();
242        let plain_gas = plain
243            .state_after_transition
244            .get_amount_out(amount_in, &plain.token0(), &plain.token1())
245            .expect("plain pool quotes")
246            .gas;
247
248        assert_eq!(
249            signed_gas - plain_gas,
250            BigUint::from(SIGNED_EXCLUSIVE_SWAP_GAS),
251            "the signed pool must carry exactly the forward overhead over an equivalent plain pool"
252        );
253    }
254
255    /// Only a pool that cannot be swapped without `Core.forward` is surcharged.
256    #[rstest]
257    fn test_other_pools_carry_no_forward_overhead() {
258        for case in [concentrated(), full_range(), mev_capture()] {
259            assert_eq!(
260                case.state_after_transition
261                    .forward_overhead_gas(),
262                0,
263                "only a signed-exclusive pool is surcharged"
264            );
265        }
266    }
267
268    #[apply(all_cases)]
269    fn test_delta_transition(case: TestCase) {
270        let mut state = case.state_before_transition;
271
272        state
273            .delta_transition(
274                ProtocolStateDelta {
275                    updated_attributes: case.transition_attributes,
276                    ..Default::default()
277                },
278                &HashMap::default(),
279                &Balances::default(),
280            )
281            .expect("executing transition");
282
283        assert_eq!(state, case.state_after_transition);
284    }
285
286    #[apply(all_cases)]
287    fn test_get_amount_out(case: TestCase) {
288        let (token0, token1) = (case.token0(), case.token1());
289        let (amount_in, expected_out) = case.swap_token0;
290
291        let res = case
292            .state_after_transition
293            .get_amount_out(amount_in, &token0, &token1)
294            .expect("computing quote");
295
296        assert_eq!(res.amount, expected_out);
297    }
298
299    #[apply(all_cases)]
300    fn test_get_limits(case: TestCase) {
301        use std::ops::Deref;
302
303        let (token0, token1) = (case.token0(), case.token1());
304        let state = case.state_after_transition;
305
306        let max_amount_in = state
307            .get_limits(token0.address.deref().into(), token1.address.deref().into())
308            .expect("computing limits for token0")
309            .0;
310
311        assert_eq!(max_amount_in, case.expected_limit_token0);
312
313        state
314            .get_amount_out(max_amount_in, &token0, &token1)
315            .expect("quoting with limit");
316    }
317}