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