Skip to main content

tycho_simulation/evm/protocol/ekubo_v3/
decoder.rs

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
261/// Determines the extension type, checking the legacy `extension_id` static
262/// attribute first if present, then falling back to address-based detection.
263fn extension_type_from_attributes_or_address(
264    static_attrs: &HashMap<String, Bytes>,
265    extension: Address,
266) -> Result<ExtensionType, InvalidSnapshotError> {
267    // Backward compat: use extension_id attribute if present (legacy format).
268    // A value of 0 means unset — fall through to address-based detection.
269    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    // New way: detect from extension address
281    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
315/// Gets an attribute by key, with an optional legacy fallback key.
316fn 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    // Call points are encoded in the first byte of the extension address.
355    // Bit 6 == beforeSwap, bit 5 == afterSwap.
356    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    /// Tests backward compatibility with the legacy attribute format:
391    /// - `extension_id` discriminant instead of address-based detection
392    /// - `ticks/` prefix instead of `tick/`
393    /// - `orders/` prefix instead of `rate_delta/`
394    /// - `token0_sale_rate`/`token1_sale_rate` instead of `rate_token0`/`rate_token1`
395    /// - `last_execution_time` instead of `last_time`
396    #[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            // BoostedFees is new, no legacy format
407            EkuboV3State::BoostedFees(_) => return,
408        };
409
410        let mut component = case.component;
411        // Add legacy extension_id attribute (keeps real extension address since
412        // the SDK validates it)
413        component
414            .static_attributes
415            .insert("extension_id".to_string(), extension_id.to_be_bytes().into());
416
417        // Rename state attributes to legacy format
418        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}