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
42const 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 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 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 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 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 #[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 #[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}