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