Skip to main content

tycho_simulation/evm/protocol/aerodrome_slipstreams/
decoder.rs

1use std::collections::HashMap;
2
3use alloy::primitives::U256;
4use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
5use tycho_common::{models::token::Token, Bytes};
6
7use super::state::AerodromeSlipstreamsState;
8use crate::{
9    evm::protocol::utils::{
10        slipstreams::{dynamic_fee_module::DynamicFeeConfig, observations::Observation},
11        uniswap::{i24_be_bytes_to_i32, tick_list::TickInfo},
12    },
13    protocol::{
14        errors::InvalidSnapshotError,
15        models::{DecoderContext, TryFromWithBlock},
16    },
17};
18
19impl TryFromWithBlock<ComponentWithState, BlockHeader> for AerodromeSlipstreamsState {
20    type Error = InvalidSnapshotError;
21
22    /// Decodes a `ComponentWithState` into a `AerodromeSlipstreamsState`. Errors with a
23    /// `InvalidSnapshotError` if the snapshot is missing any required attributes or if the fee
24    /// amount is not supported.
25    async fn try_from_with_header(
26        snapshot: ComponentWithState,
27        block: BlockHeader,
28        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
29        _all_tokens: &HashMap<Bytes, Token>,
30        _decoder_context: &DecoderContext,
31    ) -> Result<Self, Self::Error> {
32        let liq = snapshot
33            .state
34            .attributes
35            .get("liquidity")
36            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("liquidity".to_string()))?
37            .clone();
38
39        // This is a hotfix because if the liquidity has never been updated after creation, it's
40        // currently encoded as H256::zero(), therefore, we can't decode this as u128.
41        // We can remove this once it has been fixed on the tycho side.
42        let liq_16_bytes = if liq.len() == 32 {
43            // Make sure it only happens for 0 values, otherwise error.
44            if liq == Bytes::zero(32) {
45                Bytes::from([0; 16])
46            } else {
47                return Err(InvalidSnapshotError::ValueError(format!(
48                    "Liquidity bytes too long for {liq}, expected 16"
49                )));
50            }
51        } else {
52            liq
53        };
54
55        let liquidity = u128::from(liq_16_bytes);
56
57        let sqrt_price = U256::from_be_slice(
58            snapshot
59                .state
60                .attributes
61                .get("sqrt_price_x96")
62                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("sqrt_price".to_string()))?,
63        );
64
65        let observation_index = u16::from(
66            snapshot
67                .state
68                .attributes
69                .get("observationIndex")
70                .ok_or_else(|| {
71                    InvalidSnapshotError::MissingAttribute("observationIndex".to_string())
72                })?
73                .clone(),
74        );
75
76        let observation_cardinality = u16::from(
77            snapshot
78                .state
79                .attributes
80                .get("observationCardinality")
81                .ok_or_else(|| {
82                    InvalidSnapshotError::MissingAttribute("observationCardinality".to_string())
83                })?
84                .clone(),
85        );
86
87        let dynamic_fee_config = DynamicFeeConfig::from_attributes(&snapshot.state.attributes)
88            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))?;
89
90        let tick_spacing = snapshot
91            .component
92            .static_attributes
93            .get("tick_spacing")
94            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick_spacing".to_string()))?
95            .clone();
96
97        let tick_spacing_4_bytes = if tick_spacing.len() == 32 {
98            // Make sure it only happens for 0 values, otherwise error.
99            if tick_spacing == Bytes::zero(32) {
100                Bytes::from([0; 4])
101            } else {
102                return Err(InvalidSnapshotError::ValueError(format!(
103                    "Tick Spacing bytes too long for {tick_spacing}, expected 4"
104                )));
105            }
106        } else {
107            tick_spacing
108        };
109
110        let tick_spacing = i24_be_bytes_to_i32(&tick_spacing_4_bytes);
111
112        let default_fee = u32::from(
113            snapshot
114                .component
115                .static_attributes
116                .get("default_fee")
117                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("default_fee".to_string()))?
118                .clone(),
119        );
120
121        let tick = snapshot
122            .state
123            .attributes
124            .get("tick")
125            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick".to_string()))?
126            .clone();
127
128        // This is a hotfix because if the tick has never been updated after creation, it's
129        // currently encoded as H256::zero(), therefore, we can't decode this as i32. We can
130        // remove this this will be fixed on the tycho side.
131        let ticks_4_bytes = if tick.len() == 32 {
132            // Make sure it only happens for 0 values, otherwise error.
133            if tick == Bytes::zero(32) {
134                Bytes::from([0; 4])
135            } else {
136                return Err(InvalidSnapshotError::ValueError(format!(
137                    "Tick bytes too long for {tick}, expected 4"
138                )));
139            }
140        } else {
141            tick
142        };
143        let tick = i24_be_bytes_to_i32(&ticks_4_bytes);
144
145        let ticks: Result<Vec<_>, _> = snapshot
146            .state
147            .attributes
148            .iter()
149            .filter_map(|(key, value)| {
150                if key.starts_with("ticks/") {
151                    Some(
152                        key.split('/')
153                            .nth(1)?
154                            .parse::<i32>()
155                            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
156                            .and_then(|tick_index| {
157                                TickInfo::new(tick_index, i128::from(value.clone())).map_err(
158                                    |err| InvalidSnapshotError::ValueError(err.to_string()),
159                                )
160                            }),
161                    )
162                } else {
163                    None
164                }
165            })
166            .collect();
167
168        let mut ticks = match ticks {
169            Ok(ticks) if !ticks.is_empty() => ticks
170                .into_iter()
171                .filter(|t| t.net_liquidity != 0)
172                .collect::<Vec<_>>(),
173            _ => return Err(InvalidSnapshotError::MissingAttribute("tick_liquidities".to_string())),
174        };
175
176        ticks.sort_by_key(|tick| tick.index);
177
178        let observations: Vec<Observation> = snapshot
179            .state
180            .attributes
181            .iter()
182            .filter_map(|(key, value)| {
183                key.strip_prefix("observations/")?
184                    .parse::<i32>()
185                    .ok()
186                    .and_then(|idx| Observation::from_attribute(idx, value).ok())
187            })
188            .collect();
189
190        let mut observations: Vec<_> = observations
191            .into_iter()
192            .filter(|t| t.initialized)
193            .collect();
194
195        if observations.is_empty() {
196            return Err(InvalidSnapshotError::MissingAttribute("observations".to_string()));
197        }
198
199        observations.sort_by_key(|observation| observation.index);
200
201        AerodromeSlipstreamsState::new(
202            snapshot.component.id.clone(),
203            block.timestamp,
204            liquidity,
205            sqrt_price,
206            observation_index,
207            observation_cardinality,
208            default_fee,
209            tick_spacing,
210            tick,
211            ticks,
212            observations,
213            dynamic_fee_config,
214        )
215        .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use alloy::primitives::U256;
222    use rstest::rstest;
223    use tycho_client::feed::synchronizer::ComponentWithState;
224    use tycho_common::{
225        models::protocol::{ProtocolComponent, ProtocolComponentState},
226        Bytes,
227    };
228
229    use super::*;
230    use crate::evm::protocol::{
231        test_utils::try_decode_snapshot_with_defaults,
232        utils::{
233            slipstreams::{dynamic_fee_module::DynamicFeeConfig, observations::Observation},
234            uniswap::{tick_list::TickInfo, tick_math::get_sqrt_ratio_at_tick},
235        },
236    };
237
238    const STALE_DYNAMIC_FEE_MODULE: [u8; 20] =
239        hex_literal::hex!("DB45818A6db280ecfeB33cbeBd445423d0216b5D");
240    fn snapshot(dynamic_fee_module: Option<Bytes>) -> ComponentWithState {
241        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("tick zero should have a sqrt price");
242        let initialized_observation = (U256::from(1) << 248_u32).to_be_bytes::<32>();
243        let mut attributes = HashMap::from([
244            ("liquidity".to_string(), Bytes::from(100_u128.to_be_bytes())),
245            ("sqrt_price_x96".to_string(), Bytes::from(sqrt_price.to_be_bytes::<32>())),
246            ("observationIndex".to_string(), Bytes::from(0_u16.to_be_bytes())),
247            ("observationCardinality".to_string(), Bytes::from(1_u16.to_be_bytes())),
248            ("dfc_baseFee".to_string(), Bytes::from(500_u32.to_be_bytes())),
249            ("dfc_scalingFactor".to_string(), Bytes::from(6_000_000_u64.to_be_bytes())),
250            ("dfc_feeCap".to_string(), Bytes::from(700_u32.to_be_bytes())),
251            ("dfc_initialFeeEnabled".to_string(), Bytes::from([1_u8])),
252            ("dfc_initialFee".to_string(), Bytes::from(30_u32.to_be_bytes())),
253            ("tick".to_string(), Bytes::from(0_i32.to_be_bytes())),
254            ("ticks/-1".to_string(), Bytes::from(1_i128.to_be_bytes())),
255            ("ticks/1".to_string(), Bytes::from((-1_i128).to_be_bytes())),
256            ("observations/0".to_string(), Bytes::from(initialized_observation)),
257        ]);
258        if let Some(dynamic_fee_module) = dynamic_fee_module {
259            attributes.insert("dynamic_fee_module".to_string(), dynamic_fee_module);
260        }
261
262        ComponentWithState {
263            state: ProtocolComponentState::new("test-pool", attributes, HashMap::new()),
264            component: ProtocolComponent {
265                id: "test-pool".to_string(),
266                static_attributes: HashMap::from([
267                    ("default_fee".to_string(), Bytes::from(100_u32.to_be_bytes())),
268                    ("tick_spacing".to_string(), Bytes::from(1_i32.to_be_bytes())),
269                ]),
270                ..Default::default()
271            },
272            component_tvl: None,
273            entrypoints: Vec::new(),
274        }
275    }
276
277    fn expected_state(dfc: DynamicFeeConfig) -> AerodromeSlipstreamsState {
278        AerodromeSlipstreamsState::new(
279            "test-pool".to_string(),
280            0,
281            100,
282            get_sqrt_ratio_at_tick(0).expect("tick zero should have a sqrt price"),
283            0,
284            1,
285            100,
286            1,
287            0,
288            vec![TickInfo::new(-1, 1).unwrap(), TickInfo::new(1, -1).unwrap()],
289            vec![Observation { initialized: true, index: 0, ..Default::default() }],
290            dfc,
291        )
292        .expect("test state should be valid")
293    }
294
295    #[rstest]
296    #[case::missing_module(None)]
297    #[case::stale_module(Some(Bytes::from(STALE_DYNAMIC_FEE_MODULE)))]
298    #[tokio::test]
299    async fn unsupported_dynamic_fee_module_fails_to_decode(
300        #[case] dynamic_fee_module: Option<Bytes>,
301    ) {
302        let decoded = try_decode_snapshot_with_defaults::<AerodromeSlipstreamsState>(snapshot(
303            dynamic_fee_module,
304        ))
305        .await;
306
307        assert!(
308            matches!(decoded, Err(InvalidSnapshotError::ValueError(_))),
309            "unsupported modules should be skipped, got {decoded:?}"
310        );
311    }
312
313    #[rstest]
314    #[case::factory_5e7b(hex_literal::hex!("090b2A6bb475c00e2256e2095A60887cD710803b"))]
315    #[case::factory_ade6(hex_literal::hex!("F4Ecd78EBEB6d36CF7f80B5B6B41453515fe2785"))]
316    #[tokio::test]
317    async fn supported_module_defaults_missing_initial_fee_attributes(
318        #[case] dynamic_fee_module: [u8; 20],
319    ) {
320        let mut snapshot = snapshot(Some(Bytes::from(dynamic_fee_module)));
321        snapshot
322            .state
323            .attributes
324            .remove("dfc_initialFeeEnabled");
325        snapshot
326            .state
327            .attributes
328            .remove("dfc_initialFee");
329
330        let decoded = try_decode_snapshot_with_defaults::<AerodromeSlipstreamsState>(snapshot)
331            .await
332            .expect("new module updates should work with pre-upgrade pool state");
333
334        assert_eq!(decoded, expected_state(DynamicFeeConfig::new(500, 700, 6_000_000, false, 0)));
335    }
336}