1use std::{borrow::Cow, collections::HashMap};
2
3use alloy::primitives::aliases::B32;
4use ekubo_sdk::{
5 chain::evm::{
6 EvmConcentratedPoolConfig, EvmConcentratedPoolKey, EvmConcentratedPoolState,
7 EvmFullRangePoolState, EvmOraclePoolKey, EvmPoolTypeConfig, EvmTwammPoolKey,
8 },
9 quoting::{
10 pools::{
11 full_range::{FullRangePoolKey, FullRangePoolState, FullRangePoolTypeConfig},
12 stableswap::{StableswapPoolKey, StableswapPoolState},
13 twamm::TwammPoolState,
14 },
15 types::{PoolConfig, Tick, TimeRateDelta},
16 util::find_nearest_initialized_tick_index,
17 },
18 U256,
19};
20use itertools::Itertools;
21use revm::primitives::Address;
22use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
23use tycho_common::{models::token::Token, Bytes};
24
25use super::{
26 attributes::{rate_deltas_from_attributes, ticks_from_attributes},
27 pool::{
28 concentrated::ConcentratedPool, full_range::FullRangePool, oracle::OraclePool,
29 twamm::TwammPool,
30 },
31 state::EkuboV3State,
32};
33use crate::{
34 evm::protocol::ekubo_v3::{
35 addresses::{
36 BOOSTED_FEES_CONCENTRATED_ADDRESS, MEV_CAPTURE_ADDRESS, ORACLE_ADDRESS, TWAMM_ADDRESS,
37 },
38 pool::{
39 boosted_fees::BoostedFeesPool, mev_capture::MevCapturePool, stableswap::StableswapPool,
40 },
41 },
42 protocol::{
43 errors::InvalidSnapshotError,
44 models::{DecoderContext, TryFromWithBlock},
45 },
46};
47
48pub enum ExtensionType {
49 NoSwapCallPoints,
50 Oracle,
51 Twamm,
52 MevCapture,
53 BoostedFees,
54}
55
56struct TimedStateDetails {
57 rate_token0: u128,
58 rate_token1: u128,
59 last_time: u64,
60 rate_deltas: Vec<TimeRateDelta>,
61}
62
63impl TryFromWithBlock<ComponentWithState, BlockHeader> for EkuboV3State {
64 type Error = InvalidSnapshotError;
65
66 async fn try_from_with_header(
67 snapshot: ComponentWithState,
68 _block: BlockHeader,
69 _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
70 _all_tokens: &HashMap<Bytes, Token>,
71 _decoder_context: &DecoderContext,
72 ) -> Result<Self, Self::Error> {
73 let static_attrs = snapshot.component.static_attributes;
74 let state_attrs = snapshot.state.attributes;
75
76 let (token0, token1) = (
77 parse_address(attribute(&static_attrs, "token0")?, "token0")?,
78 parse_address(attribute(&static_attrs, "token1")?, "token1")?,
79 );
80
81 let fee = u64::from_be_bytes(
82 attribute(&static_attrs, "fee")?
83 .as_ref()
84 .try_into()
85 .map_err(|err| {
86 InvalidSnapshotError::ValueError(format!("fee length mismatch: {err:?}"))
87 })?,
88 );
89
90 let pool_type_config = EvmPoolTypeConfig::try_from(
91 B32::try_from(attribute(&static_attrs, "pool_type_config")?.as_ref()).map_err(
92 |err| {
93 InvalidSnapshotError::ValueError(format!(
94 "pool_type_config length mismatch: {err:?}"
95 ))
96 },
97 )?,
98 )
99 .map_err(|err| {
100 InvalidSnapshotError::ValueError(format!("parsing pool_type_config: {err}"))
101 })?;
102
103 let extension = parse_address(attribute(&static_attrs, "extension")?, "extension")?;
104
105 let liquidity = attribute(&state_attrs, "liquidity")?
106 .clone()
107 .into();
108
109 let sqrt_ratio = U256::try_from_be_slice(&attribute(&state_attrs, "sqrt_ratio")?[..])
110 .ok_or_else(|| InvalidSnapshotError::ValueError("invalid pool price".to_string()))?;
111
112 let concentrated_pool = |state_attrs,
113 pool_type_config|
114 -> Result<
115 (EvmConcentratedPoolKey, EvmConcentratedPoolState, i32, Vec<Tick>),
116 InvalidSnapshotError,
117 > {
118 let tick = attribute(state_attrs, "tick")?
119 .clone()
120 .into();
121
122 let mut ticks = ticks_from_attributes(
123 state_attrs
124 .iter()
125 .map(|(key, value)| (key.as_str(), Cow::Borrowed(value))),
126 )
127 .map_err(InvalidSnapshotError::ValueError)?;
128
129 ticks.sort_unstable_by_key(|tick| tick.index);
130
131 Ok((
132 EvmConcentratedPoolKey {
133 token0,
134 token1,
135 config: EvmConcentratedPoolConfig { extension, fee, pool_type_config },
136 },
137 EvmConcentratedPoolState {
138 sqrt_ratio,
139 liquidity,
140 active_tick_index: find_nearest_initialized_tick_index(&ticks, tick),
141 },
142 tick,
143 ticks,
144 ))
145 };
146
147 let ext_type = extension_type_from_attributes_or_address(&static_attrs, extension)?;
148
149 Ok(match ext_type {
150 ExtensionType::NoSwapCallPoints => match pool_type_config {
151 EvmPoolTypeConfig::FullRange(pool_type_config) => {
152 Self::FullRange(FullRangePool::new(
153 FullRangePoolKey {
154 token0,
155 token1,
156 config: PoolConfig { extension, fee, pool_type_config },
157 },
158 FullRangePoolState { sqrt_ratio, liquidity },
159 )?)
160 }
161 EvmPoolTypeConfig::Stableswap(pool_type_config) => {
162 Self::Stableswap(StableswapPool::new(
163 StableswapPoolKey {
164 token0,
165 token1,
166 config: PoolConfig { extension, fee, pool_type_config },
167 },
168 StableswapPoolState { sqrt_ratio, liquidity },
169 )?)
170 }
171 EvmPoolTypeConfig::Concentrated(pool_type_config) => {
172 let (key, state, tick, ticks) =
173 concentrated_pool(&state_attrs, pool_type_config)?;
174
175 Self::Concentrated(ConcentratedPool::new(key, state, tick, ticks)?)
176 }
177 },
178 ExtensionType::Oracle => Self::Oracle(OraclePool::new(
179 EvmOraclePoolKey {
180 token0,
181 token1,
182 config: PoolConfig {
183 extension,
184 fee,
185 pool_type_config: FullRangePoolTypeConfig,
186 },
187 },
188 EvmFullRangePoolState { sqrt_ratio, liquidity },
189 )?),
190 ExtensionType::Twamm => {
191 let TimedStateDetails {
192 rate_token0: token0_sale_rate,
193 rate_token1: token1_sale_rate,
194 last_time: last_execution_time,
195 rate_deltas: virtual_order_deltas,
196 } = timed_state_details(state_attrs)?;
197
198 Self::Twamm(TwammPool::new(
199 EvmTwammPoolKey {
200 token0,
201 token1,
202 config: PoolConfig {
203 extension,
204 fee,
205 pool_type_config: FullRangePoolTypeConfig,
206 },
207 },
208 TwammPoolState {
209 full_range_pool_state: FullRangePoolState { sqrt_ratio, liquidity },
210 token0_sale_rate,
211 token1_sale_rate,
212 last_execution_time,
213 },
214 virtual_order_deltas,
215 )?)
216 }
217 ExtensionType::MevCapture => {
218 let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
219 return Err(InvalidSnapshotError::ValueError(
220 "expected concentrated pool type config for MEVCapture pool".to_string(),
221 ));
222 };
223
224 let (key, concentrated_state, tick, ticks) =
225 concentrated_pool(&state_attrs, pool_type_config)?;
226
227 Self::MevCapture(MevCapturePool::new(key, tick, concentrated_state, ticks)?)
228 }
229 ExtensionType::BoostedFees => {
230 let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
231 return Err(InvalidSnapshotError::ValueError(
232 "expected concentrated pool type config for BoostedFees pool".to_string(),
233 ));
234 };
235
236 let (key, concentrated_pool_state, tick, ticks) =
237 concentrated_pool(&state_attrs, pool_type_config)?;
238
239 let TimedStateDetails {
240 rate_token0: donate_rate0,
241 rate_token1: donate_rate1,
242 last_time: last_donate_time,
243 rate_deltas: donate_rate_deltas,
244 } = timed_state_details(state_attrs)?;
245
246 Self::BoostedFees(BoostedFeesPool::new(
247 key,
248 concentrated_pool_state,
249 donate_rate0,
250 donate_rate1,
251 last_donate_time,
252 donate_rate_deltas,
253 ticks,
254 tick,
255 )?)
256 }
257 })
258 }
259}
260
261fn extension_type_from_attributes_or_address(
264 static_attrs: &HashMap<String, Bytes>,
265 extension: Address,
266) -> Result<ExtensionType, InvalidSnapshotError> {
267 if let Some(extension_id) = static_attrs.get("extension_id") {
270 match i32::from(extension_id.clone()) {
271 0 => {}
272 1 => return Ok(ExtensionType::NoSwapCallPoints),
273 2 => return Ok(ExtensionType::Oracle),
274 3 => return Ok(ExtensionType::Twamm),
275 4 => return Ok(ExtensionType::MevCapture),
276 _ => {}
277 }
278 }
279
280 extension_type(extension).ok_or_else(|| {
282 InvalidSnapshotError::ValueError(format!("unsupported extension {extension:x}"))
283 })
284}
285
286pub fn extension_type(extension: Address) -> Option<ExtensionType> {
287 Some(if has_no_swap_call_points(extension) {
288 ExtensionType::NoSwapCallPoints
289 } else if extension == ORACLE_ADDRESS {
290 ExtensionType::Oracle
291 } else if extension == TWAMM_ADDRESS {
292 ExtensionType::Twamm
293 } else if extension == MEV_CAPTURE_ADDRESS {
294 ExtensionType::MevCapture
295 } else if extension == BOOSTED_FEES_CONCENTRATED_ADDRESS {
296 ExtensionType::BoostedFees
297 } else {
298 return None;
299 })
300}
301
302fn attribute<'a>(
303 map: &'a HashMap<String, Bytes>,
304 key: &str,
305) -> Result<&'a Bytes, InvalidSnapshotError> {
306 map.get(key)
307 .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
308}
309
310fn parse_address(bytes: &Bytes, attr_name: &str) -> Result<Address, InvalidSnapshotError> {
311 Address::try_from(&bytes[..])
312 .map_err(|err| InvalidSnapshotError::ValueError(format!("parsing {attr_name}: {err}")))
313}
314
315fn attribute_with_fallback<'a>(
317 map: &'a HashMap<String, Bytes>,
318 key: &str,
319 legacy_key: &str,
320) -> Result<&'a Bytes, InvalidSnapshotError> {
321 map.get(key)
322 .or_else(|| map.get(legacy_key))
323 .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
324}
325
326fn timed_state_details(
327 attrs: HashMap<String, Bytes>,
328) -> Result<TimedStateDetails, InvalidSnapshotError> {
329 let last_time = attribute_with_fallback(&attrs, "last_time", "last_execution_time")?
330 .clone()
331 .into();
332
333 Ok(TimedStateDetails {
334 rate_token0: attribute_with_fallback(&attrs, "rate_token0", "token0_sale_rate")?
335 .clone()
336 .into(),
337 rate_token1: attribute_with_fallback(&attrs, "rate_token1", "token1_sale_rate")?
338 .clone()
339 .into(),
340 last_time,
341 rate_deltas: rate_deltas_from_attributes(
342 attrs
343 .into_iter()
344 .map(|(key, value)| (key, Cow::Owned(value))),
345 last_time,
346 )
347 .map_err(InvalidSnapshotError::ValueError)?
348 .sorted_unstable_by_key(|delta| delta.time)
349 .collect(),
350 })
351}
352
353fn has_no_swap_call_points(extension: Address) -> bool {
354 extension[0] & 0b0110_0000 == 0
357}
358
359#[cfg(test)]
360mod tests {
361 use rstest::*;
362 use rstest_reuse::apply;
363 use tycho_common::dto::ResponseProtocolState;
364
365 use super::*;
366 use crate::evm::protocol::{
367 ekubo_v3::test_cases::*, test_utils::try_decode_snapshot_with_defaults,
368 };
369
370 #[apply(all_cases)]
371 #[tokio::test]
372 async fn test_try_from_with_header(case: TestCase) {
373 let snapshot = ComponentWithState {
374 state: ResponseProtocolState {
375 attributes: case.state_attributes,
376 ..Default::default()
377 },
378 component: case.component,
379 component_tvl: None,
380 entrypoints: Vec::new(),
381 };
382
383 let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
384 .await
385 .expect("reconstructing state");
386
387 assert_eq!(result, case.state_before_transition);
388 }
389
390 #[apply(all_cases)]
397 #[tokio::test]
398 async fn test_try_from_legacy_format(case: TestCase) {
399 let extension_id: i32 = match &case.state_before_transition {
400 EkuboV3State::Concentrated(_) |
401 EkuboV3State::FullRange(_) |
402 EkuboV3State::Stableswap(_) => 1,
403 EkuboV3State::Oracle(_) => 2,
404 EkuboV3State::Twamm(_) => 3,
405 EkuboV3State::MevCapture(_) => 4,
406 EkuboV3State::BoostedFees(_) => return,
408 };
409
410 let mut component = case.component;
411 component
414 .static_attributes
415 .insert("extension_id".to_string(), extension_id.to_be_bytes().into());
416
417 let state_attributes = case
419 .state_attributes
420 .into_iter()
421 .map(|(key, value)| {
422 let key = key
423 .replace("tick/", "ticks/")
424 .replace("rate_delta/", "orders/");
425 let key = match key.as_str() {
426 "rate_token0" => "token0_sale_rate".to_string(),
427 "rate_token1" => "token1_sale_rate".to_string(),
428 "last_time" => "last_execution_time".to_string(),
429 _ => key,
430 };
431 (key, value)
432 })
433 .collect();
434
435 let snapshot = ComponentWithState {
436 state: ResponseProtocolState { attributes: state_attributes, ..Default::default() },
437 component,
438 component_tvl: None,
439 entrypoints: Vec::new(),
440 };
441
442 let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
443 .await
444 .expect("reconstructing state from legacy format");
445
446 assert_eq!(result, case.state_before_transition);
447 }
448
449 #[apply(all_cases)]
450 #[tokio::test]
451 async fn test_try_from_invalid(case: TestCase) {
452 for missing_attribute in case.required_attributes {
453 let mut component = case.component.clone();
454 let mut attributes = case.state_attributes.clone();
455
456 component
457 .static_attributes
458 .remove(&missing_attribute);
459 attributes.remove(&missing_attribute);
460
461 let snapshot = ComponentWithState {
462 state: ResponseProtocolState {
463 attributes,
464 component_id: Default::default(),
465 balances: Default::default(),
466 },
467 component,
468 component_tvl: None,
469 entrypoints: Vec::new(),
470 };
471
472 EkuboV3State::try_from_with_header(
473 snapshot,
474 BlockHeader::default(),
475 &HashMap::default(),
476 &HashMap::default(),
477 &DecoderContext::new(),
478 )
479 .await
480 .unwrap_err();
481 }
482 }
483}