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 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 let liq_16_bytes = if liq.len() == 32 {
40 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 let ticks_4_bytes = if tick.len() == 32 {
75 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
126fn 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(); 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 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 #[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 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 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}