tycho_simulation/evm/protocol/aerodrome_slipstreams/
decoder.rs1use 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 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 let liq_16_bytes = if liq.len() == 32 {
43 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 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 let ticks_4_bytes = if tick.len() == 32 {
132 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,
206 liquidity,
207 sqrt_price,
208 observation_index,
209 observation_cardinality,
210 default_fee,
211 tick_spacing,
212 tick,
213 ticks,
214 observations,
215 dynamic_fee_config,
216 )
217 .map(|state| state.with_position_assumption(decoder_context.block_position))
218 .map_err(|err| InvalidSnapshotError::ValueError(err.to_string()))
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use alloy::primitives::U256;
225 use rstest::rstest;
226 use tycho_client::feed::synchronizer::ComponentWithState;
227 use tycho_common::{
228 models::protocol::{ProtocolComponent, ProtocolComponentState},
229 Bytes,
230 };
231
232 use super::*;
233 use crate::evm::protocol::{
234 test_utils::try_decode_snapshot_with_defaults,
235 utils::{
236 slipstreams::{dynamic_fee_module::DynamicFeeConfig, observations::Observation},
237 uniswap::{tick_list::TickInfo, tick_math::get_sqrt_ratio_at_tick},
238 },
239 };
240
241 const STALE_DYNAMIC_FEE_MODULE: [u8; 20] =
242 hex_literal::hex!("DB45818A6db280ecfeB33cbeBd445423d0216b5D");
243 fn snapshot(dynamic_fee_module: Option<Bytes>) -> ComponentWithState {
244 let sqrt_price = get_sqrt_ratio_at_tick(0).expect("tick zero should have a sqrt price");
245 let initialized_observation = (U256::from(1) << 248_u32).to_be_bytes::<32>();
246 let mut attributes = HashMap::from([
247 ("liquidity".to_string(), Bytes::from(100_u128.to_be_bytes())),
248 ("sqrt_price_x96".to_string(), Bytes::from(sqrt_price.to_be_bytes::<32>())),
249 ("observationIndex".to_string(), Bytes::from(0_u16.to_be_bytes())),
250 ("observationCardinality".to_string(), Bytes::from(1_u16.to_be_bytes())),
251 ("dfc_baseFee".to_string(), Bytes::from(500_u32.to_be_bytes())),
252 ("dfc_scalingFactor".to_string(), Bytes::from(6_000_000_u64.to_be_bytes())),
253 ("dfc_feeCap".to_string(), Bytes::from(700_u32.to_be_bytes())),
254 ("dfc_initialFeeEnabled".to_string(), Bytes::from([1_u8])),
255 ("dfc_initialFee".to_string(), Bytes::from(30_u32.to_be_bytes())),
256 ("tick".to_string(), Bytes::from(0_i32.to_be_bytes())),
257 ("ticks/-1".to_string(), Bytes::from(1_i128.to_be_bytes())),
258 ("ticks/1".to_string(), Bytes::from((-1_i128).to_be_bytes())),
259 ("observations/0".to_string(), Bytes::from(initialized_observation)),
260 ]);
261 if let Some(dynamic_fee_module) = dynamic_fee_module {
262 attributes.insert("dynamic_fee_module".to_string(), dynamic_fee_module);
263 }
264
265 ComponentWithState {
266 state: ProtocolComponentState::new("test-pool", attributes, HashMap::new()),
267 component: ProtocolComponent {
268 id: "test-pool".to_string(),
269 static_attributes: HashMap::from([
270 ("default_fee".to_string(), Bytes::from(100_u32.to_be_bytes())),
271 ("tick_spacing".to_string(), Bytes::from(1_i32.to_be_bytes())),
272 ]),
273 ..Default::default()
274 },
275 component_tvl: None,
276 entrypoints: Vec::new(),
277 }
278 }
279
280 fn expected_state(dfc: DynamicFeeConfig) -> AerodromeSlipstreamsState {
281 AerodromeSlipstreamsState::new(
282 "test-pool".to_string(),
283 0,
284 100,
285 get_sqrt_ratio_at_tick(0).expect("tick zero should have a sqrt price"),
286 0,
287 1,
288 100,
289 1,
290 0,
291 vec![TickInfo::new(-1, 1).unwrap(), TickInfo::new(1, -1).unwrap()],
292 vec![Observation { initialized: true, index: 0, ..Default::default() }],
293 dfc,
294 )
295 .expect("test state should be valid")
296 }
297
298 #[rstest]
299 #[case::missing_module(None)]
300 #[case::stale_module(Some(Bytes::from(STALE_DYNAMIC_FEE_MODULE)))]
301 #[tokio::test]
302 async fn missing_or_unsupported_module_falls_back_to_default_config(
303 #[case] dynamic_fee_module: Option<Bytes>,
304 ) {
305 let decoded = try_decode_snapshot_with_defaults::<AerodromeSlipstreamsState>(snapshot(
307 dynamic_fee_module,
308 ))
309 .await
310 .expect("pools without a supported marker should remain decodable");
311
312 assert_eq!(decoded, expected_state(DynamicFeeConfig::default()));
313 }
314
315 #[rstest]
316 #[case::factory_5e7b(hex_literal::hex!("090b2A6bb475c00e2256e2095A60887cD710803b"))]
317 #[case::factory_ade6(hex_literal::hex!("F4Ecd78EBEB6d36CF7f80B5B6B41453515fe2785"))]
318 #[case::factory_f8f2(hex_literal::hex!("87D8f999BBa9343E8099552426775B51C338E8CB"))]
319 #[tokio::test]
320 async fn supported_module_defaults_missing_initial_fee_attributes(
321 #[case] dynamic_fee_module: [u8; 20],
322 ) {
323 let mut snapshot = snapshot(Some(Bytes::from(dynamic_fee_module)));
324 snapshot
325 .state
326 .attributes
327 .remove("dfc_initialFeeEnabled");
328 snapshot
329 .state
330 .attributes
331 .remove("dfc_initialFee");
332
333 let decoded = try_decode_snapshot_with_defaults::<AerodromeSlipstreamsState>(snapshot)
334 .await
335 .expect("new module updates should work with pre-upgrade pool state");
336
337 assert_eq!(decoded, expected_state(DynamicFeeConfig::new(500, 700, 6_000_000, false, 0)));
338 }
339}