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