Skip to main content

tycho_simulation/evm/protocol/uniswap_v3/
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::{enums::FeeAmount, fee_tier::FeeTier, state::UniswapV3State};
8use crate::{
9    evm::protocol::utils::uniswap::{i24_be_bytes_to_i32, tick_list::TickInfo},
10    protocol::{
11        errors::InvalidSnapshotError,
12        models::{DecoderContext, TryFromWithBlock},
13    },
14};
15
16impl TryFromWithBlock<ComponentWithState, BlockHeader> for UniswapV3State {
17    type Error = InvalidSnapshotError;
18
19    /// Decodes a `ComponentWithState` into a `UniswapV3State`. Errors with a `InvalidSnapshotError`
20    /// if the snapshot is missing any required attributes, if its fee and tick spacing are out of
21    /// range, or if it has no `tick_spacing` attribute and its fee has no `FeeAmount` variant.
22    async fn try_from_with_header(
23        snapshot: ComponentWithState,
24        _block: BlockHeader,
25        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
26        _all_tokens: &HashMap<Bytes, Token>,
27        _decoder_context: &DecoderContext,
28    ) -> Result<Self, Self::Error> {
29        let liq = snapshot
30            .state
31            .attributes
32            .get("liquidity")
33            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("liquidity".to_string()))?
34            .clone();
35
36        // This is a hotfix because if the liquidity has never been updated after creation, it's
37        // currently encoded as H256::zero(), therefore, we can't decode this as u128.
38        // We can remove this once it has been fixed on the tycho side.
39        let liq_16_bytes = if liq.len() == 32 {
40            // Make sure it only happens for 0 values, otherwise error.
41            if liq == Bytes::zero(32) {
42                Bytes::from([0; 16])
43            } else {
44                return Err(InvalidSnapshotError::ValueError(format!(
45                    "Liquidity bytes too long for {liq}, expected 16"
46                )));
47            }
48        } else {
49            liq
50        };
51
52        let liquidity = u128::from(liq_16_bytes);
53
54        let sqrt_price = U256::from_be_slice(
55            snapshot
56                .state
57                .attributes
58                .get("sqrt_price_x96")
59                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("sqrt_price".to_string()))?,
60        );
61
62        let fee = decode_fee_tier(&snapshot.component.static_attributes)?;
63
64        let tick = snapshot
65            .state
66            .attributes
67            .get("tick")
68            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick".to_string()))?
69            .clone();
70
71        // This is a hotfix because if the tick has never been updated after creation, it's
72        // currently encoded as H256::zero(), therefore, we can't decode this as i32. We can
73        // remove this this will be fixed on the tycho side.
74        let ticks_4_bytes = if tick.len() == 32 {
75            // Make sure it only happens for 0 values, otherwise error.
76            if tick == Bytes::zero(32) {
77                Bytes::from([0; 4])
78            } else {
79                return Err(InvalidSnapshotError::ValueError(format!(
80                    "Tick bytes too long for {tick}, expected 4"
81                )));
82            }
83        } else {
84            tick
85        };
86        let tick = i24_be_bytes_to_i32(&ticks_4_bytes);
87
88        let ticks: Result<Vec<_>, _> = snapshot
89            .state
90            .attributes
91            .iter()
92            .filter_map(|(key, value)| {
93                if key.starts_with("ticks/") {
94                    Some(
95                        key.split('/')
96                            .nth(1)?
97                            .parse::<i32>()
98                            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
99                            .and_then(|tick_index| {
100                                TickInfo::new(tick_index, i128::from(value.clone())).map_err(
101                                    |err| InvalidSnapshotError::ValueError(err.to_string()),
102                                )
103                            }),
104                    )
105                } else {
106                    None
107                }
108            })
109            .collect();
110
111        let mut ticks = match ticks {
112            Ok(ticks) if !ticks.is_empty() => ticks
113                .into_iter()
114                .filter(|t| t.net_liquidity != 0)
115                .collect::<Vec<_>>(),
116            _ => return Err(InvalidSnapshotError::MissingAttribute("tick_liquidities".to_string())),
117        };
118
119        ticks.sort_by_key(|tick| tick.index);
120
121        UniswapV3State::new(liquidity, sqrt_price, fee, tick, ticks)
122            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
123    }
124}
125
126/// Reads the pool's fee and tick spacing from its static attributes.
127///
128/// The `tick_spacing` attribute is authoritative when present, so any fee the factory enabled is
129/// accepted. Without it the spacing is implied by the fee, which only works for the fee amounts
130/// [`FeeAmount`] knows about.
131fn decode_fee_tier(
132    static_attributes: &HashMap<String, Bytes>,
133) -> Result<FeeTier, InvalidSnapshotError> {
134    let fee = static_attributes
135        .get("fee")
136        .ok_or_else(|| InvalidSnapshotError::MissingAttribute("fee".to_string()))?;
137    let fee = decode_i32("fee", fee)?;
138
139    let Some(tick_spacing) = static_attributes.get("tick_spacing") else {
140        return FeeAmount::try_from(fee)
141            .map(FeeTier::from)
142            .map_err(|_| InvalidSnapshotError::ValueError("Unsupported fee amount".to_string()));
143    };
144    let tick_spacing = decode_i32("tick_spacing", tick_spacing)?;
145
146    let fee = u32::try_from(fee)
147        .map_err(|_| InvalidSnapshotError::ValueError(format!("Negative fee {fee}")))?;
148    let tick_spacing = u16::try_from(tick_spacing).map_err(|_| {
149        InvalidSnapshotError::ValueError(format!("Tick spacing {tick_spacing} out of range"))
150    })?;
151    FeeTier::new(fee, tick_spacing).map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
152}
153
154fn decode_i32(name: &str, value: &Bytes) -> Result<i32, InvalidSnapshotError> {
155    if value.len() > 4 {
156        return Err(InvalidSnapshotError::ValueError(format!(
157            "Attribute {name} is {} bytes, expected at most 4",
158            value.len()
159        )));
160    }
161    Ok(i32::from(value.clone()))
162}
163
164#[cfg(test)]
165mod tests {
166    use std::str::FromStr;
167
168    use chrono::DateTime;
169    use rstest::rstest;
170    use tycho_common::models::{
171        protocol::{ProtocolComponent, ProtocolComponentState},
172        Chain, ChangeType,
173    };
174
175    use super::*;
176    use crate::evm::protocol::test_utils::try_decode_snapshot_with_defaults;
177
178    fn usv3_component() -> ProtocolComponent {
179        let creation_time = DateTime::from_timestamp(1622526000, 0)
180            .unwrap()
181            .naive_utc(); //Sample timestamp
182
183        // Add a static attribute "fee"
184        let mut static_attributes: HashMap<String, Bytes> = HashMap::new();
185        static_attributes.insert("fee".to_string(), Bytes::from(3000_i32.to_be_bytes().to_vec()));
186
187        ProtocolComponent {
188            id: "State1".to_string(),
189            protocol_system: "system1".to_string(),
190            protocol_type_name: "typename1".to_string(),
191            chain: Chain::Ethereum,
192            tokens: Vec::new(),
193            contract_addresses: Vec::new(),
194            static_attributes,
195            change: ChangeType::Creation,
196            creation_tx: Bytes::from_str("0x0000").unwrap(),
197            created_at: creation_time,
198        }
199    }
200
201    fn usv3_attributes() -> HashMap<String, Bytes> {
202        vec![
203            ("liquidity".to_string(), Bytes::from(100_u64.to_be_bytes().to_vec())),
204            ("sqrt_price_x96".to_string(), Bytes::from(200_u64.to_be_bytes().to_vec())),
205            ("tick".to_string(), Bytes::from(300_i32.to_be_bytes().to_vec())),
206            ("ticks/60/net_liquidity".to_string(), Bytes::from(400_i128.to_be_bytes().to_vec())),
207        ]
208        .into_iter()
209        .collect::<HashMap<String, Bytes>>()
210    }
211
212    #[tokio::test]
213    async fn test_usv3_try_from() {
214        let snapshot = ComponentWithState {
215            state: ProtocolComponentState {
216                component_id: "State1".to_owned(),
217                attributes: usv3_attributes(),
218                balances: HashMap::new(),
219            },
220            component: usv3_component(),
221            component_tvl: None,
222            entrypoints: Vec::new(),
223        };
224
225        let result = try_decode_snapshot_with_defaults::<UniswapV3State>(snapshot).await;
226
227        assert!(result.is_ok());
228        let expected = UniswapV3State::new(
229            100,
230            U256::from(200),
231            FeeAmount::Medium,
232            300,
233            vec![TickInfo::new(60, 400).unwrap()],
234        )
235        .unwrap();
236        assert_eq!(result.unwrap(), expected);
237    }
238
239    #[tokio::test]
240    #[rstest]
241    #[case::missing_liquidity("liquidity")]
242    #[case::missing_sqrt_price("sqrt_price")]
243    #[case::missing_tick("tick")]
244    #[case::missing_tick_liquidity("tick_liquidities")]
245    #[case::missing_fee("fee")]
246    async fn test_usv3_try_from_invalid(#[case] missing_attribute: String) {
247        // remove missing attribute
248        let mut attributes = usv3_attributes();
249        attributes.remove(&missing_attribute);
250
251        if missing_attribute == "tick_liquidities" {
252            attributes.remove("ticks/60/net_liquidity");
253        }
254
255        if missing_attribute == "sqrt_price" {
256            attributes.remove("sqrt_price_x96");
257        }
258
259        let mut component = usv3_component();
260        if missing_attribute == "fee" {
261            component
262                .static_attributes
263                .remove("fee");
264        }
265
266        let snapshot = ComponentWithState {
267            state: ProtocolComponentState {
268                component_id: "State1".to_owned(),
269                attributes,
270                balances: HashMap::new(),
271            },
272            component,
273            component_tvl: None,
274            entrypoints: Vec::new(),
275        };
276
277        let result = try_decode_snapshot_with_defaults::<UniswapV3State>(snapshot).await;
278
279        assert!(result.is_err());
280        assert!(matches!(
281            result.err().unwrap(),
282            InvalidSnapshotError::MissingAttribute(attr) if attr == missing_attribute
283        ));
284    }
285
286    fn usv3_snapshot(static_attributes: &[(&str, Vec<u8>)], tick: i32) -> ComponentWithState {
287        let mut component = usv3_component();
288        for (name, value) in static_attributes {
289            component
290                .static_attributes
291                .insert(name.to_string(), Bytes::from(value.clone()));
292        }
293        let mut attributes = usv3_attributes();
294        attributes.remove("ticks/60/net_liquidity");
295        attributes.insert(
296            format!("ticks/{tick}/net_liquidity"),
297            Bytes::from(400_i128.to_be_bytes().to_vec()),
298        );
299        ComponentWithState {
300            state: ProtocolComponentState {
301                component_id: "State1".to_owned(),
302                attributes,
303                balances: HashMap::new(),
304            },
305            component,
306            component_tvl: None,
307            entrypoints: Vec::new(),
308        }
309    }
310
311    #[tokio::test]
312    #[rstest]
313    // Substreams emit `fee` and `tick_spacing` as minimal big-endian signed bytes.
314    #[case::fee_outside_fee_amount(vec![0x32], vec![0x0a], 50, 10, 20)]
315    #[case::spacing_differs_from_fee_amount(vec![0x00, 0xc8], vec![0x04], 200, 4, 8)]
316    #[case::large_fee_small_spacing(vec![0x75, 0x30], vec![0x01], 30_000, 1, 7)]
317    async fn test_usv3_try_from_uses_tick_spacing_attribute(
318        #[case] fee_bytes: Vec<u8>,
319        #[case] tick_spacing_bytes: Vec<u8>,
320        #[case] fee: u32,
321        #[case] tick_spacing: u16,
322        #[case] tick: i32,
323    ) {
324        let snapshot =
325            usv3_snapshot(&[("fee", fee_bytes), ("tick_spacing", tick_spacing_bytes)], tick);
326
327        let result = try_decode_snapshot_with_defaults::<UniswapV3State>(snapshot).await;
328
329        let expected = UniswapV3State::new(
330            100,
331            U256::from(200),
332            FeeTier::new(fee, tick_spacing).unwrap(),
333            300,
334            vec![TickInfo::new(tick, 400).unwrap()],
335        )
336        .unwrap();
337        assert_eq!(result.unwrap(), expected);
338    }
339
340    #[tokio::test]
341    async fn test_usv3_try_from_rejects_tick_off_attribute_spacing() {
342        // Fee 200 implies spacing 2, so tick 2 is only invalid if the attribute's spacing 4 wins.
343        let snapshot = usv3_snapshot(&[("fee", vec![0x00, 0xc8]), ("tick_spacing", vec![0x04])], 2);
344
345        let result = try_decode_snapshot_with_defaults::<UniswapV3State>(snapshot).await;
346
347        assert!(matches!(
348            result,
349            Err(InvalidSnapshotError::ValueError(err)) if err.contains("not aligned")
350        ));
351    }
352
353    #[tokio::test]
354    #[rstest]
355    #[case::negative_fee(vec![0xff], vec![0x01], "Negative fee")]
356    #[case::fee_at_denominator(vec![0x0f, 0x42, 0x40], vec![0x01], "must be below")]
357    #[case::zero_spacing(vec![0x32], vec![0x00], "must be positive")]
358    #[case::negative_spacing(vec![0x32], vec![0xff], "out of range")]
359    #[case::spacing_above_u16(vec![0x32], vec![0x01, 0x00, 0x00], "out of range")]
360    #[case::spacing_wider_than_i32(vec![0x32], vec![0x00; 5], "expected at most 4")]
361    async fn test_usv3_try_from_invalid_tick_spacing_attribute(
362        #[case] fee_bytes: Vec<u8>,
363        #[case] tick_spacing_bytes: Vec<u8>,
364        #[case] expected_error: &str,
365    ) {
366        let snapshot =
367            usv3_snapshot(&[("fee", fee_bytes), ("tick_spacing", tick_spacing_bytes)], 0);
368
369        let result = try_decode_snapshot_with_defaults::<UniswapV3State>(snapshot).await;
370
371        assert!(matches!(
372            result,
373            Err(InvalidSnapshotError::ValueError(err)) if err.contains(expected_error)
374        ));
375    }
376
377    #[tokio::test]
378    async fn test_usv3_try_from_invalid_fee() {
379        // Without a `tick_spacing` attribute only `FeeAmount` fees can be decoded.
380        let mut component = usv3_component();
381        component
382            .static_attributes
383            .insert("fee".to_string(), Bytes::from(4000_i32.to_be_bytes().to_vec()));
384
385        let snapshot = ComponentWithState {
386            state: ProtocolComponentState {
387                component_id: "State1".to_owned(),
388                attributes: usv3_attributes(),
389                balances: HashMap::new(),
390            },
391            component,
392            component_tvl: None,
393            entrypoints: Vec::new(),
394        };
395
396        let result = try_decode_snapshot_with_defaults::<UniswapV3State>(snapshot).await;
397
398        assert!(result.is_err());
399        assert!(matches!(
400            result.err().unwrap(),
401            InvalidSnapshotError::ValueError(err) if err == *"Unsupported fee amount"
402        ));
403    }
404}