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, 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 or if the fee amount is not supported.
21    async fn try_from_with_header(
22        snapshot: ComponentWithState,
23        _block: BlockHeader,
24        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
25        _all_tokens: &HashMap<Bytes, Token>,
26        _decoder_context: &DecoderContext,
27    ) -> Result<Self, Self::Error> {
28        let liq = snapshot
29            .state
30            .attributes
31            .get("liquidity")
32            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("liquidity".to_string()))?
33            .clone();
34
35        // This is a hotfix because if the liquidity has never been updated after creation, it's
36        // currently encoded as H256::zero(), therefore, we can't decode this as u128.
37        // We can remove this once it has been fixed on the tycho side.
38        let liq_16_bytes = if liq.len() == 32 {
39            // Make sure it only happens for 0 values, otherwise error.
40            if liq == Bytes::zero(32) {
41                Bytes::from([0; 16])
42            } else {
43                return Err(InvalidSnapshotError::ValueError(format!(
44                    "Liquidity bytes too long for {liq}, expected 16"
45                )));
46            }
47        } else {
48            liq
49        };
50
51        let liquidity = u128::from(liq_16_bytes);
52
53        let sqrt_price = U256::from_be_slice(
54            snapshot
55                .state
56                .attributes
57                .get("sqrt_price_x96")
58                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("sqrt_price".to_string()))?,
59        );
60
61        let fee_value = i32::from(
62            snapshot
63                .component
64                .static_attributes
65                .get("fee")
66                .ok_or_else(|| InvalidSnapshotError::MissingAttribute("fee".to_string()))?
67                .clone(),
68        );
69        let fee = FeeAmount::try_from(fee_value)
70            .map_err(|_| InvalidSnapshotError::ValueError("Unsupported fee amount".to_string()))?;
71
72        let tick = snapshot
73            .state
74            .attributes
75            .get("tick")
76            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("tick".to_string()))?
77            .clone();
78
79        // This is a hotfix because if the tick has never been updated after creation, it's
80        // currently encoded as H256::zero(), therefore, we can't decode this as i32. We can
81        // remove this this will be fixed on the tycho side.
82        let ticks_4_bytes = if tick.len() == 32 {
83            // Make sure it only happens for 0 values, otherwise error.
84            if tick == Bytes::zero(32) {
85                Bytes::from([0; 4])
86            } else {
87                return Err(InvalidSnapshotError::ValueError(format!(
88                    "Tick bytes too long for {tick}, expected 4"
89                )));
90            }
91        } else {
92            tick
93        };
94        let tick = i24_be_bytes_to_i32(&ticks_4_bytes);
95
96        let ticks: Result<Vec<_>, _> = snapshot
97            .state
98            .attributes
99            .iter()
100            .filter_map(|(key, value)| {
101                if key.starts_with("ticks/") {
102                    Some(
103                        key.split('/')
104                            .nth(1)?
105                            .parse::<i32>()
106                            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
107                            .and_then(|tick_index| {
108                                TickInfo::new(tick_index, i128::from(value.clone())).map_err(
109                                    |err| InvalidSnapshotError::ValueError(err.to_string()),
110                                )
111                            }),
112                    )
113                } else {
114                    None
115                }
116            })
117            .collect();
118
119        let mut ticks = match ticks {
120            Ok(ticks) if !ticks.is_empty() => ticks
121                .into_iter()
122                .filter(|t| t.net_liquidity != 0)
123                .collect::<Vec<_>>(),
124            _ => return Err(InvalidSnapshotError::MissingAttribute("tick_liquidities".to_string())),
125        };
126
127        ticks.sort_by_key(|tick| tick.index);
128
129        UniswapV3State::new(liquidity, sqrt_price, fee, tick, ticks)
130            .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use std::str::FromStr;
137
138    use chrono::DateTime;
139    use rstest::rstest;
140    use tycho_common::dto::{Chain, ChangeType, ProtocolComponent, ResponseProtocolState};
141
142    use super::*;
143
144    fn usv3_component() -> ProtocolComponent {
145        let creation_time = DateTime::from_timestamp(1622526000, 0)
146            .unwrap()
147            .naive_utc(); //Sample timestamp
148
149        // Add a static attribute "fee"
150        let mut static_attributes: HashMap<String, Bytes> = HashMap::new();
151        static_attributes.insert("fee".to_string(), Bytes::from(3000_i32.to_be_bytes().to_vec()));
152
153        ProtocolComponent {
154            id: "State1".to_string(),
155            protocol_system: "system1".to_string(),
156            protocol_type_name: "typename1".to_string(),
157            chain: Chain::Ethereum,
158            tokens: Vec::new(),
159            contract_ids: Vec::new(),
160            static_attributes,
161            change: ChangeType::Creation,
162            creation_tx: Bytes::from_str("0x0000").unwrap(),
163            created_at: creation_time,
164        }
165    }
166
167    fn usv3_attributes() -> HashMap<String, Bytes> {
168        vec![
169            ("liquidity".to_string(), Bytes::from(100_u64.to_be_bytes().to_vec())),
170            ("sqrt_price_x96".to_string(), Bytes::from(200_u64.to_be_bytes().to_vec())),
171            ("tick".to_string(), Bytes::from(300_i32.to_be_bytes().to_vec())),
172            ("ticks/60/net_liquidity".to_string(), Bytes::from(400_i128.to_be_bytes().to_vec())),
173        ]
174        .into_iter()
175        .collect::<HashMap<String, Bytes>>()
176    }
177
178    fn header() -> BlockHeader {
179        BlockHeader {
180            number: 1,
181            hash: Bytes::from(vec![0; 32]),
182            parent_hash: Bytes::from(vec![0; 32]),
183            revert: false,
184            timestamp: 1,
185        }
186    }
187
188    #[tokio::test]
189    async fn test_usv3_try_from() {
190        let snapshot = ComponentWithState {
191            state: ResponseProtocolState {
192                component_id: "State1".to_owned(),
193                attributes: usv3_attributes(),
194                balances: HashMap::new(),
195            },
196            component: usv3_component(),
197            component_tvl: None,
198            entrypoints: Vec::new(),
199        };
200
201        let result = UniswapV3State::try_from_with_header(
202            snapshot,
203            header(),
204            &HashMap::new(),
205            &HashMap::new(),
206            &DecoderContext::new(),
207        )
208        .await;
209
210        assert!(result.is_ok());
211        let expected = UniswapV3State::new(
212            100,
213            U256::from(200),
214            FeeAmount::Medium,
215            300,
216            vec![TickInfo::new(60, 400).unwrap()],
217        )
218        .unwrap();
219        assert_eq!(result.unwrap(), expected);
220    }
221
222    #[tokio::test]
223    #[rstest]
224    #[case::missing_liquidity("liquidity")]
225    #[case::missing_sqrt_price("sqrt_price")]
226    #[case::missing_tick("tick")]
227    #[case::missing_tick_liquidity("tick_liquidities")]
228    #[case::missing_fee("fee")]
229    async fn test_usv3_try_from_invalid(#[case] missing_attribute: String) {
230        // remove missing attribute
231        let mut attributes = usv3_attributes();
232        attributes.remove(&missing_attribute);
233
234        if missing_attribute == "tick_liquidities" {
235            attributes.remove("ticks/60/net_liquidity");
236        }
237
238        if missing_attribute == "sqrt_price" {
239            attributes.remove("sqrt_price_x96");
240        }
241
242        let mut component = usv3_component();
243        if missing_attribute == "fee" {
244            component
245                .static_attributes
246                .remove("fee");
247        }
248
249        let snapshot = ComponentWithState {
250            state: ResponseProtocolState {
251                component_id: "State1".to_owned(),
252                attributes,
253                balances: HashMap::new(),
254            },
255            component,
256            component_tvl: None,
257            entrypoints: Vec::new(),
258        };
259
260        let result = UniswapV3State::try_from_with_header(
261            snapshot,
262            header(),
263            &HashMap::new(),
264            &HashMap::new(),
265            &DecoderContext::new(),
266        )
267        .await;
268
269        assert!(result.is_err());
270        assert!(matches!(
271            result.err().unwrap(),
272            InvalidSnapshotError::MissingAttribute(attr) if attr == missing_attribute
273        ));
274    }
275
276    #[tokio::test]
277    async fn test_usv3_try_from_invalid_fee() {
278        // set an invalid fee amount (100, 500, 3_000 and 10_000 are the only valid fee amounts)
279        let mut component = usv3_component();
280        component
281            .static_attributes
282            .insert("fee".to_string(), Bytes::from(4000_i32.to_be_bytes().to_vec()));
283
284        let snapshot = ComponentWithState {
285            state: ResponseProtocolState {
286                component_id: "State1".to_owned(),
287                attributes: usv3_attributes(),
288                balances: HashMap::new(),
289            },
290            component,
291            component_tvl: None,
292            entrypoints: Vec::new(),
293        };
294
295        let result = UniswapV3State::try_from_with_header(
296            snapshot,
297            header(),
298            &HashMap::new(),
299            &HashMap::new(),
300            &DecoderContext::new(),
301        )
302        .await;
303
304        assert!(result.is_err());
305        assert!(matches!(
306            result.err().unwrap(),
307            InvalidSnapshotError::ValueError(err) if err == *"Unsupported fee amount"
308        ));
309    }
310}