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,
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 missing_or_unsupported_module_falls_back_to_default_config(
300 #[case] dynamic_fee_module: Option<Bytes>,
301 ) {
302 let decoded = try_decode_snapshot_with_defaults::<AerodromeSlipstreamsState>(snapshot(
304 dynamic_fee_module,
305 ))
306 .await
307 .expect("pools without a supported marker should remain decodable");
308
309 assert_eq!(decoded, expected_state(DynamicFeeConfig::default()));
310 }
311
312 #[rstest]
313 #[case::factory_5e7b(hex_literal::hex!("090b2A6bb475c00e2256e2095A60887cD710803b"))]
314 #[case::factory_ade6(hex_literal::hex!("F4Ecd78EBEB6d36CF7f80B5B6B41453515fe2785"))]
315 #[tokio::test]
316 async fn supported_module_defaults_missing_initial_fee_attributes(
317 #[case] dynamic_fee_module: [u8; 20],
318 ) {
319 let mut snapshot = snapshot(Some(Bytes::from(dynamic_fee_module)));
320 snapshot
321 .state
322 .attributes
323 .remove("dfc_initialFeeEnabled");
324 snapshot
325 .state
326 .attributes
327 .remove("dfc_initialFee");
328
329 let decoded = try_decode_snapshot_with_defaults::<AerodromeSlipstreamsState>(snapshot)
330 .await
331 .expect("new module updates should work with pre-upgrade pool state");
332
333 assert_eq!(decoded, expected_state(DynamicFeeConfig::new(500, 700, 6_000_000, false, 0)));
334 }
335}