tycho_simulation/evm/protocol/ekubo_v3/
state.rs1use 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
38const 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 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 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 #[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 #[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}