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::{
24    models::{token::Token, Chain},
25    Bytes,
26};
27
28use super::{
29    addresses::{
30        BOOSTED_FEES_CONCENTRATED_ADDRESS, MEV_CAPTURE_ADDRESS, ORACLE_ADDRESS,
31        SIGNED_EXCLUSIVE_SWAP_DEPLOYMENTS, TWAMM_ADDRESS_V1, TWAMM_ADDRESS_V2,
32        VE33_ROBINHOOD_ADDRESS,
33    },
34    attributes::{rate_deltas_from_attributes, ticks_from_attributes},
35    pool::{
36        boosted_fees::BoostedFeesPool,
37        concentrated::ConcentratedPool,
38        full_range::FullRangePool,
39        mev_capture::MevCapturePool,
40        oracle::OraclePool,
41        stableswap::StableswapPool,
42        twamm::TwammPool,
43        ve33::{Ve33Pool, Ve33UnderlyingPool},
44    },
45    state::EkuboV3State,
46};
47use crate::protocol::{
48    errors::InvalidSnapshotError,
49    models::{DecoderContext, TryFromWithBlock},
50};
51
52pub enum ExtensionType {
53    NoSwapCallPoints,
54    Oracle,
55    Twamm,
56    MevCapture,
57    BoostedFees,
58    SignedExclusiveSwap,
59    Ve33,
60}
61
62fn has_no_swap_call_points(extension: Address) -> bool {
63    // Call points are encoded in the first byte of the extension address.
64    // Bit 6 == beforeSwap, bit 5 == afterSwap.
65    extension[0] & 0b0110_0000 == 0
66}
67
68pub fn extension_type(extension: Address, chain: Chain) -> Option<ExtensionType> {
69    Some(if has_no_swap_call_points(extension) {
70        ExtensionType::NoSwapCallPoints
71    } else if extension == ORACLE_ADDRESS {
72        ExtensionType::Oracle
73    } else if [TWAMM_ADDRESS_V2, TWAMM_ADDRESS_V1].contains(&extension) {
74        ExtensionType::Twamm
75    } else if extension == MEV_CAPTURE_ADDRESS {
76        ExtensionType::MevCapture
77    } else if extension == BOOSTED_FEES_CONCENTRATED_ADDRESS {
78        ExtensionType::BoostedFees
79    } else if SIGNED_EXCLUSIVE_SWAP_DEPLOYMENTS.contains(&(chain, extension)) {
80        ExtensionType::SignedExclusiveSwap
81    } else if chain == Chain::Robinhood && extension == VE33_ROBINHOOD_ADDRESS {
82        ExtensionType::Ve33
83    } else {
84        return None;
85    })
86}
87
88struct TimedStateDetails {
89    rate_token0: u128,
90    rate_token1: u128,
91    last_time: u64,
92    rate_deltas: Vec<TimeRateDelta>,
93}
94
95impl TryFromWithBlock<ComponentWithState, BlockHeader> for EkuboV3State {
96    type Error = InvalidSnapshotError;
97
98    async fn try_from_with_header(
99        snapshot: ComponentWithState,
100        _block: BlockHeader,
101        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
102        _all_tokens: &HashMap<Bytes, Token>,
103        _decoder_context: &DecoderContext,
104    ) -> Result<Self, Self::Error> {
105        let chain = snapshot.component.chain;
106        let static_attrs = snapshot.component.static_attributes;
107        let state_attrs = snapshot.state.attributes;
108
109        let (token0, token1) = (
110            parse_address(attribute(&static_attrs, "token0")?, "token0")?,
111            parse_address(attribute(&static_attrs, "token1")?, "token1")?,
112        );
113
114        let fee = u64::from_be_bytes(
115            attribute(&static_attrs, "fee")?
116                .as_ref()
117                .try_into()
118                .map_err(|err| {
119                    InvalidSnapshotError::ValueError(format!("fee length mismatch: {err:?}"))
120                })?,
121        );
122
123        let pool_type_config = EvmPoolTypeConfig::try_from(
124            B32::try_from(attribute(&static_attrs, "pool_type_config")?.as_ref()).map_err(
125                |err| {
126                    InvalidSnapshotError::ValueError(format!(
127                        "pool_type_config length mismatch: {err:?}"
128                    ))
129                },
130            )?,
131        )
132        .map_err(|err| {
133            InvalidSnapshotError::ValueError(format!("parsing pool_type_config: {err}"))
134        })?;
135
136        let extension = parse_address(attribute(&static_attrs, "extension")?, "extension")?;
137
138        let liquidity = attribute(&state_attrs, "liquidity")?
139            .clone()
140            .into();
141
142        let sqrt_ratio = U256::try_from_be_slice(&attribute(&state_attrs, "sqrt_ratio")?[..])
143            .ok_or_else(|| InvalidSnapshotError::ValueError("invalid pool price".to_string()))?;
144
145        let concentrated_pool = |state_attrs,
146                                 pool_type_config|
147         -> Result<
148            (EvmConcentratedPoolKey, EvmConcentratedPoolState, i32, Vec<Tick>),
149            InvalidSnapshotError,
150        > {
151            let tick = attribute(state_attrs, "tick")?
152                .clone()
153                .into();
154
155            let mut ticks = ticks_from_attributes(
156                state_attrs
157                    .iter()
158                    .map(|(key, value)| (key.as_str(), Cow::Borrowed(value))),
159            )
160            .map_err(InvalidSnapshotError::ValueError)?;
161
162            ticks.sort_unstable_by_key(|tick| tick.index);
163
164            Ok((
165                EvmConcentratedPoolKey {
166                    token0,
167                    token1,
168                    config: EvmConcentratedPoolConfig { extension, fee, pool_type_config },
169                },
170                EvmConcentratedPoolState {
171                    sqrt_ratio,
172                    liquidity,
173                    active_tick_index: find_nearest_initialized_tick_index(&ticks, tick),
174                },
175                tick,
176                ticks,
177            ))
178        };
179
180        let ext_type = extension_type_from_attributes_or_address(&static_attrs, extension, chain)?;
181
182        Ok(match ext_type {
183            ExtensionType::NoSwapCallPoints => match pool_type_config {
184                EvmPoolTypeConfig::FullRange(pool_type_config) => {
185                    Self::FullRange(FullRangePool::new(
186                        FullRangePoolKey {
187                            token0,
188                            token1,
189                            config: PoolConfig { extension, fee, pool_type_config },
190                        },
191                        FullRangePoolState { sqrt_ratio, liquidity },
192                    )?)
193                }
194                EvmPoolTypeConfig::Stableswap(pool_type_config) => {
195                    Self::Stableswap(StableswapPool::new(
196                        StableswapPoolKey {
197                            token0,
198                            token1,
199                            config: PoolConfig { extension, fee, pool_type_config },
200                        },
201                        StableswapPoolState { sqrt_ratio, liquidity },
202                    )?)
203                }
204                EvmPoolTypeConfig::Concentrated(pool_type_config) => {
205                    let (key, state, tick, ticks) =
206                        concentrated_pool(&state_attrs, pool_type_config)?;
207
208                    Self::Concentrated(ConcentratedPool::new(key, state, tick, ticks)?)
209                }
210            },
211            ExtensionType::Oracle => Self::Oracle(OraclePool::new(
212                EvmOraclePoolKey {
213                    token0,
214                    token1,
215                    config: PoolConfig {
216                        extension,
217                        fee,
218                        pool_type_config: FullRangePoolTypeConfig,
219                    },
220                },
221                EvmFullRangePoolState { sqrt_ratio, liquidity },
222            )?),
223            ExtensionType::Twamm => {
224                let TimedStateDetails {
225                    rate_token0: token0_sale_rate,
226                    rate_token1: token1_sale_rate,
227                    last_time: last_execution_time,
228                    rate_deltas: virtual_order_deltas,
229                } = timed_state_details(state_attrs)?;
230
231                Self::Twamm(TwammPool::new(
232                    EvmTwammPoolKey {
233                        token0,
234                        token1,
235                        config: PoolConfig {
236                            extension,
237                            fee,
238                            pool_type_config: FullRangePoolTypeConfig,
239                        },
240                    },
241                    TwammPoolState {
242                        full_range_pool_state: FullRangePoolState { sqrt_ratio, liquidity },
243                        token0_sale_rate,
244                        token1_sale_rate,
245                        last_execution_time,
246                    },
247                    virtual_order_deltas,
248                )?)
249            }
250            ExtensionType::MevCapture => {
251                let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
252                    return Err(InvalidSnapshotError::ValueError(
253                        "expected concentrated pool type config for MEVCapture pool".to_string(),
254                    ));
255                };
256
257                let (key, concentrated_state, tick, ticks) =
258                    concentrated_pool(&state_attrs, pool_type_config)?;
259
260                Self::MevCapture(MevCapturePool::new(key, tick, concentrated_state, ticks)?)
261            }
262            ExtensionType::SignedExclusiveSwap => {
263                let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
264                    return Err(InvalidSnapshotError::ValueError(
265                        "expected concentrated pool type config for SignedExclusiveSwap pool"
266                            .to_string(),
267                    ));
268                };
269
270                let (key, state, tick, ticks) = concentrated_pool(&state_attrs, pool_type_config)?;
271
272                Self::Concentrated(ConcentratedPool::new(key, state, tick, ticks)?)
273            }
274            ExtensionType::BoostedFees => {
275                let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
276                    return Err(InvalidSnapshotError::ValueError(
277                        "expected concentrated pool type config for BoostedFees pool".to_string(),
278                    ));
279                };
280
281                let (key, concentrated_pool_state, tick, ticks) =
282                    concentrated_pool(&state_attrs, pool_type_config)?;
283
284                let TimedStateDetails {
285                    rate_token0: donate_rate0,
286                    rate_token1: donate_rate1,
287                    last_time: last_donate_time,
288                    rate_deltas: donate_rate_deltas,
289                } = timed_state_details(state_attrs)?;
290
291                Self::BoostedFees(BoostedFeesPool::new(
292                    key,
293                    concentrated_pool_state,
294                    donate_rate0,
295                    donate_rate1,
296                    last_donate_time,
297                    donate_rate_deltas,
298                    ticks,
299                    tick,
300                )?)
301            }
302            ExtensionType::Ve33 => {
303                let underlying_pool = match pool_type_config {
304                    EvmPoolTypeConfig::FullRange(pool_type_config) => {
305                        Ve33UnderlyingPool::FullRange(FullRangePool::new(
306                            FullRangePoolKey {
307                                token0,
308                                token1,
309                                config: PoolConfig { extension, fee, pool_type_config },
310                            },
311                            FullRangePoolState { sqrt_ratio, liquidity },
312                        )?)
313                    }
314                    EvmPoolTypeConfig::Stableswap(pool_type_config) => {
315                        Ve33UnderlyingPool::Stableswap(StableswapPool::new(
316                            StableswapPoolKey {
317                                token0,
318                                token1,
319                                config: PoolConfig { extension, fee, pool_type_config },
320                            },
321                            StableswapPoolState { sqrt_ratio, liquidity },
322                        )?)
323                    }
324                    EvmPoolTypeConfig::Concentrated(pool_type_config) => {
325                        let (key, state, tick, ticks) =
326                            concentrated_pool(&state_attrs, pool_type_config)?;
327                        Ve33UnderlyingPool::Concentrated(ConcentratedPool::new(
328                            key, state, tick, ticks,
329                        )?)
330                    }
331                };
332                let swap_fee = u64::from_be_bytes(
333                    attribute(&state_attrs, "swap_fee")?
334                        .as_ref()
335                        .try_into()
336                        .map_err(|err| {
337                            InvalidSnapshotError::ValueError(format!(
338                                "swap_fee length mismatch: {err:?}"
339                            ))
340                        })?,
341                );
342
343                Self::Ve33(Ve33Pool::new(underlying_pool, swap_fee)?)
344            }
345        })
346    }
347}
348
349/// Determines the extension type, checking the legacy `extension_id` static
350/// attribute first if present, then falling back to address-based detection.
351fn extension_type_from_attributes_or_address(
352    static_attrs: &HashMap<String, Bytes>,
353    extension: Address,
354    chain: Chain,
355) -> Result<ExtensionType, InvalidSnapshotError> {
356    // Backward compat: use extension_id attribute if present (legacy format).
357    // A value of 0 means unset — fall through to address-based detection.
358    if let Some(extension_id) = static_attrs.get("extension_id") {
359        match i32::from(extension_id.clone()) {
360            0 => {}
361            1 => return Ok(ExtensionType::NoSwapCallPoints),
362            2 => return Ok(ExtensionType::Oracle),
363            3 => return Ok(ExtensionType::Twamm),
364            4 => return Ok(ExtensionType::MevCapture),
365            _ => {}
366        }
367    }
368
369    // New way: detect from extension address
370    extension_type(extension, chain).ok_or_else(|| {
371        InvalidSnapshotError::ValueError(format!("unsupported extension {extension:x}"))
372    })
373}
374
375fn attribute<'a>(
376    map: &'a HashMap<String, Bytes>,
377    key: &str,
378) -> Result<&'a Bytes, InvalidSnapshotError> {
379    map.get(key)
380        .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
381}
382
383fn parse_address(bytes: &Bytes, attr_name: &str) -> Result<Address, InvalidSnapshotError> {
384    Address::try_from(&bytes[..])
385        .map_err(|err| InvalidSnapshotError::ValueError(format!("parsing {attr_name}: {err}")))
386}
387
388/// Gets an attribute by key, with an optional legacy fallback key.
389fn attribute_with_fallback<'a>(
390    map: &'a HashMap<String, Bytes>,
391    key: &str,
392    legacy_key: &str,
393) -> Result<&'a Bytes, InvalidSnapshotError> {
394    map.get(key)
395        .or_else(|| map.get(legacy_key))
396        .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
397}
398
399fn timed_state_details(
400    attrs: HashMap<String, Bytes>,
401) -> Result<TimedStateDetails, InvalidSnapshotError> {
402    let last_time = attribute_with_fallback(&attrs, "last_time", "last_execution_time")?
403        .clone()
404        .into();
405
406    Ok(TimedStateDetails {
407        rate_token0: attribute_with_fallback(&attrs, "rate_token0", "token0_sale_rate")?
408            .clone()
409            .into(),
410        rate_token1: attribute_with_fallback(&attrs, "rate_token1", "token1_sale_rate")?
411            .clone()
412            .into(),
413        last_time,
414        rate_deltas: rate_deltas_from_attributes(
415            attrs
416                .into_iter()
417                .map(|(key, value)| (key, Cow::Owned(value))),
418            last_time,
419        )
420        .map_err(InvalidSnapshotError::ValueError)?
421        .sorted_unstable_by_key(|delta| delta.time)
422        .collect(),
423    })
424}
425
426#[cfg(test)]
427mod tests {
428    use rstest::*;
429    use rstest_reuse::apply;
430    use tycho_common::models::protocol::ProtocolComponentState;
431
432    use super::*;
433    use crate::evm::protocol::{
434        ekubo_v3::test_cases::*, test_utils::try_decode_snapshot_with_defaults,
435    };
436
437    #[apply(all_cases)]
438    #[tokio::test]
439    async fn test_try_from_with_header(case: TestCase) {
440        let snapshot = ComponentWithState {
441            state: ProtocolComponentState {
442                component_id: String::new(),
443                attributes: case.state_attributes,
444                balances: HashMap::new(),
445            },
446            component: case.component,
447            component_tvl: None,
448            entrypoints: Vec::new(),
449        };
450
451        let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
452            .await
453            .expect("reconstructing state");
454
455        assert_eq!(result, case.state_before_transition);
456    }
457
458    /// Tests backward compatibility with the legacy attribute format:
459    /// - `extension_id` discriminant instead of address-based detection
460    /// - `ticks/` prefix instead of `tick/`
461    /// - `orders/` prefix instead of `rate_delta/`
462    /// - `token0_sale_rate`/`token1_sale_rate` instead of `rate_token0`/`rate_token1`
463    /// - `last_execution_time` instead of `last_time`
464    #[apply(all_cases)]
465    #[tokio::test]
466    async fn test_try_from_legacy_format(case: TestCase) {
467        let extension_id: i32 = match &case.state_before_transition {
468            EkuboV3State::Concentrated(_) |
469            EkuboV3State::FullRange(_) |
470            EkuboV3State::Stableswap(_) => 1,
471            EkuboV3State::Oracle(_) => 2,
472            EkuboV3State::Twamm(_) => 3,
473            EkuboV3State::MevCapture(_) => 4,
474            // BoostedFees is new, no legacy format
475            EkuboV3State::BoostedFees(_) | EkuboV3State::Ve33(_) => return,
476        };
477
478        let mut component = case.component;
479        // Add legacy extension_id attribute (keeps real extension address since
480        // the SDK validates it)
481        component
482            .static_attributes
483            .insert("extension_id".to_string(), extension_id.to_be_bytes().into());
484
485        // Rename state attributes to legacy format
486        let state_attributes = case
487            .state_attributes
488            .into_iter()
489            .map(|(key, value)| {
490                let key = key
491                    .replace("tick/", "ticks/")
492                    .replace("rate_delta/", "orders/");
493                let key = match key.as_str() {
494                    "rate_token0" => "token0_sale_rate".to_string(),
495                    "rate_token1" => "token1_sale_rate".to_string(),
496                    "last_time" => "last_execution_time".to_string(),
497                    _ => key,
498                };
499                (key, value)
500            })
501            .collect();
502
503        let snapshot = ComponentWithState {
504            state: ProtocolComponentState {
505                component_id: String::new(),
506                attributes: state_attributes,
507                balances: HashMap::new(),
508            },
509            component,
510            component_tvl: None,
511            entrypoints: Vec::new(),
512        };
513
514        let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
515            .await
516            .expect("reconstructing state from legacy format");
517
518        assert_eq!(result, case.state_before_transition);
519    }
520
521    #[apply(all_cases)]
522    #[tokio::test]
523    async fn test_try_from_invalid(case: TestCase) {
524        for missing_attribute in case.required_attributes {
525            let mut component = case.component.clone();
526            let mut attributes = case.state_attributes.clone();
527
528            component
529                .static_attributes
530                .remove(&missing_attribute);
531            attributes.remove(&missing_attribute);
532
533            let snapshot = ComponentWithState {
534                state: ProtocolComponentState {
535                    attributes,
536                    component_id: String::new(),
537                    balances: HashMap::new(),
538                },
539                component,
540                component_tvl: None,
541                entrypoints: Vec::new(),
542            };
543
544            EkuboV3State::try_from_with_header(
545                snapshot,
546                BlockHeader::default(),
547                &HashMap::default(),
548                &HashMap::default(),
549                &DecoderContext::new(),
550            )
551            .await
552            .unwrap_err();
553        }
554    }
555
556    #[rstest]
557    #[case::concentrated(concentrated())]
558    #[case::full_range(full_range())]
559    #[case::stableswap(stableswap())]
560    #[tokio::test]
561    async fn test_decode_ve33_underlying_pool_types(#[case] mut case: TestCase) {
562        case.component.chain = Chain::Robinhood;
563        case.component.static_attributes.insert(
564            "extension".to_string(),
565            VE33_ROBINHOOD_ADDRESS
566                .into_array()
567                .into(),
568        );
569        case.state_attributes
570            .insert("swap_fee".to_string(), 1_u64.to_be_bytes().into());
571
572        let snapshot = ComponentWithState {
573            state: ProtocolComponentState {
574                component_id: String::new(),
575                attributes: case.state_attributes,
576                balances: HashMap::new(),
577            },
578            component: case.component,
579            component_tvl: None,
580            entrypoints: Vec::new(),
581        };
582
583        let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
584            .await
585            .expect("reconstructing Ve33 state");
586
587        assert!(matches!(result, EkuboV3State::Ve33(_)));
588    }
589}