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