1use std::{borrow::Cow, collections::HashMap};
2
3use alloy::primitives::aliases::B32;
4use ekubo_sdk::{
5 chain::evm::{
6 EvmConcentratedPoolConfig, EvmConcentratedPoolKey, EvmConcentratedPoolState,
7 EvmFullRangePoolState, EvmOraclePoolKey, EvmPoolTypeConfig, EvmTwammPoolKey,
8 },
9 quoting::{
10 pools::{
11 full_range::{FullRangePoolKey, FullRangePoolState, FullRangePoolTypeConfig},
12 stableswap::{StableswapPoolKey, StableswapPoolState},
13 twamm::TwammPoolState,
14 },
15 types::{PoolConfig, Tick, TimeRateDelta},
16 util::find_nearest_initialized_tick_index,
17 },
18 U256,
19};
20use itertools::Itertools;
21use revm::primitives::Address;
22use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
23use tycho_common::{models::token::Token, Bytes};
24
25use super::{
26 addresses::{
27 BOOSTED_FEES_CONCENTRATED_ADDRESS, MEV_CAPTURE_ADDRESS, ORACLE_ADDRESS, TWAMM_ADDRESS,
28 },
29 attributes::{rate_deltas_from_attributes, ticks_from_attributes},
30 pool::{
31 boosted_fees::BoostedFeesPool, concentrated::ConcentratedPool, full_range::FullRangePool,
32 mev_capture::MevCapturePool, oracle::OraclePool, stableswap::StableswapPool,
33 twamm::TwammPool,
34 },
35 state::EkuboV3State,
36};
37use crate::protocol::{
38 errors::InvalidSnapshotError,
39 models::{DecoderContext, TryFromWithBlock},
40};
41
42pub enum ExtensionType {
43 NoSwapCallPoints,
44 Oracle,
45 Twamm,
46 MevCapture,
47 BoostedFees,
48}
49
50fn has_no_swap_call_points(extension: Address) -> bool {
51 extension[0] & 0b0110_0000 == 0
54}
55
56pub fn extension_type(extension: Address) -> Option<ExtensionType> {
57 Some(if has_no_swap_call_points(extension) {
58 ExtensionType::NoSwapCallPoints
59 } else if extension == ORACLE_ADDRESS {
60 ExtensionType::Oracle
61 } else if extension == TWAMM_ADDRESS {
62 ExtensionType::Twamm
63 } else if extension == MEV_CAPTURE_ADDRESS {
64 ExtensionType::MevCapture
65 } else if extension == BOOSTED_FEES_CONCENTRATED_ADDRESS {
66 ExtensionType::BoostedFees
67 } else {
68 return None;
69 })
70}
71
72struct TimedStateDetails {
73 rate_token0: u128,
74 rate_token1: u128,
75 last_time: u64,
76 rate_deltas: Vec<TimeRateDelta>,
77}
78
79impl TryFromWithBlock<ComponentWithState, BlockHeader> for EkuboV3State {
80 type Error = InvalidSnapshotError;
81
82 async fn try_from_with_header(
83 snapshot: ComponentWithState,
84 _block: BlockHeader,
85 _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
86 _all_tokens: &HashMap<Bytes, Token>,
87 _decoder_context: &DecoderContext,
88 ) -> Result<Self, Self::Error> {
89 let static_attrs = snapshot.component.static_attributes;
90 let state_attrs = snapshot.state.attributes;
91
92 let (token0, token1) = (
93 parse_address(attribute(&static_attrs, "token0")?, "token0")?,
94 parse_address(attribute(&static_attrs, "token1")?, "token1")?,
95 );
96
97 let fee = u64::from_be_bytes(
98 attribute(&static_attrs, "fee")?
99 .as_ref()
100 .try_into()
101 .map_err(|err| {
102 InvalidSnapshotError::ValueError(format!("fee length mismatch: {err:?}"))
103 })?,
104 );
105
106 let pool_type_config = EvmPoolTypeConfig::try_from(
107 B32::try_from(attribute(&static_attrs, "pool_type_config")?.as_ref()).map_err(
108 |err| {
109 InvalidSnapshotError::ValueError(format!(
110 "pool_type_config length mismatch: {err:?}"
111 ))
112 },
113 )?,
114 )
115 .map_err(|err| {
116 InvalidSnapshotError::ValueError(format!("parsing pool_type_config: {err}"))
117 })?;
118
119 let extension = parse_address(attribute(&static_attrs, "extension")?, "extension")?;
120
121 let liquidity = attribute(&state_attrs, "liquidity")?
122 .clone()
123 .into();
124
125 let sqrt_ratio = U256::try_from_be_slice(&attribute(&state_attrs, "sqrt_ratio")?[..])
126 .ok_or_else(|| InvalidSnapshotError::ValueError("invalid pool price".to_string()))?;
127
128 let concentrated_pool = |state_attrs,
129 pool_type_config|
130 -> Result<
131 (EvmConcentratedPoolKey, EvmConcentratedPoolState, i32, Vec<Tick>),
132 InvalidSnapshotError,
133 > {
134 let tick = attribute(state_attrs, "tick")?
135 .clone()
136 .into();
137
138 let mut ticks = ticks_from_attributes(
139 state_attrs
140 .iter()
141 .map(|(key, value)| (key.as_str(), Cow::Borrowed(value))),
142 )
143 .map_err(InvalidSnapshotError::ValueError)?;
144
145 ticks.sort_unstable_by_key(|tick| tick.index);
146
147 Ok((
148 EvmConcentratedPoolKey {
149 token0,
150 token1,
151 config: EvmConcentratedPoolConfig { extension, fee, pool_type_config },
152 },
153 EvmConcentratedPoolState {
154 sqrt_ratio,
155 liquidity,
156 active_tick_index: find_nearest_initialized_tick_index(&ticks, tick),
157 },
158 tick,
159 ticks,
160 ))
161 };
162
163 let ext_type = extension_type_from_attributes_or_address(&static_attrs, extension)?;
164
165 Ok(match ext_type {
166 ExtensionType::NoSwapCallPoints => match pool_type_config {
167 EvmPoolTypeConfig::FullRange(pool_type_config) => {
168 Self::FullRange(FullRangePool::new(
169 FullRangePoolKey {
170 token0,
171 token1,
172 config: PoolConfig { extension, fee, pool_type_config },
173 },
174 FullRangePoolState { sqrt_ratio, liquidity },
175 )?)
176 }
177 EvmPoolTypeConfig::Stableswap(pool_type_config) => {
178 Self::Stableswap(StableswapPool::new(
179 StableswapPoolKey {
180 token0,
181 token1,
182 config: PoolConfig { extension, fee, pool_type_config },
183 },
184 StableswapPoolState { sqrt_ratio, liquidity },
185 )?)
186 }
187 EvmPoolTypeConfig::Concentrated(pool_type_config) => {
188 let (key, state, tick, ticks) =
189 concentrated_pool(&state_attrs, pool_type_config)?;
190
191 Self::Concentrated(ConcentratedPool::new(key, state, tick, ticks)?)
192 }
193 },
194 ExtensionType::Oracle => Self::Oracle(OraclePool::new(
195 EvmOraclePoolKey {
196 token0,
197 token1,
198 config: PoolConfig {
199 extension,
200 fee,
201 pool_type_config: FullRangePoolTypeConfig,
202 },
203 },
204 EvmFullRangePoolState { sqrt_ratio, liquidity },
205 )?),
206 ExtensionType::Twamm => {
207 let TimedStateDetails {
208 rate_token0: token0_sale_rate,
209 rate_token1: token1_sale_rate,
210 last_time: last_execution_time,
211 rate_deltas: virtual_order_deltas,
212 } = timed_state_details(state_attrs)?;
213
214 Self::Twamm(TwammPool::new(
215 EvmTwammPoolKey {
216 token0,
217 token1,
218 config: PoolConfig {
219 extension,
220 fee,
221 pool_type_config: FullRangePoolTypeConfig,
222 },
223 },
224 TwammPoolState {
225 full_range_pool_state: FullRangePoolState { sqrt_ratio, liquidity },
226 token0_sale_rate,
227 token1_sale_rate,
228 last_execution_time,
229 },
230 virtual_order_deltas,
231 )?)
232 }
233 ExtensionType::MevCapture => {
234 let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
235 return Err(InvalidSnapshotError::ValueError(
236 "expected concentrated pool type config for MEVCapture pool".to_string(),
237 ));
238 };
239
240 let (key, concentrated_state, tick, ticks) =
241 concentrated_pool(&state_attrs, pool_type_config)?;
242
243 Self::MevCapture(MevCapturePool::new(key, tick, concentrated_state, ticks)?)
244 }
245 ExtensionType::BoostedFees => {
246 let EvmPoolTypeConfig::Concentrated(pool_type_config) = pool_type_config else {
247 return Err(InvalidSnapshotError::ValueError(
248 "expected concentrated pool type config for BoostedFees pool".to_string(),
249 ));
250 };
251
252 let (key, concentrated_pool_state, tick, ticks) =
253 concentrated_pool(&state_attrs, pool_type_config)?;
254
255 let TimedStateDetails {
256 rate_token0: donate_rate0,
257 rate_token1: donate_rate1,
258 last_time: last_donate_time,
259 rate_deltas: donate_rate_deltas,
260 } = timed_state_details(state_attrs)?;
261
262 Self::BoostedFees(BoostedFeesPool::new(
263 key,
264 concentrated_pool_state,
265 donate_rate0,
266 donate_rate1,
267 last_donate_time,
268 donate_rate_deltas,
269 ticks,
270 tick,
271 )?)
272 }
273 })
274 }
275}
276
277fn extension_type_from_attributes_or_address(
280 static_attrs: &HashMap<String, Bytes>,
281 extension: Address,
282) -> Result<ExtensionType, InvalidSnapshotError> {
283 if let Some(extension_id) = static_attrs.get("extension_id") {
286 match i32::from(extension_id.clone()) {
287 0 => {}
288 1 => return Ok(ExtensionType::NoSwapCallPoints),
289 2 => return Ok(ExtensionType::Oracle),
290 3 => return Ok(ExtensionType::Twamm),
291 4 => return Ok(ExtensionType::MevCapture),
292 _ => {}
293 }
294 }
295
296 extension_type(extension).ok_or_else(|| {
298 InvalidSnapshotError::ValueError(format!("unsupported extension {extension:x}"))
299 })
300}
301
302fn attribute<'a>(
303 map: &'a HashMap<String, Bytes>,
304 key: &str,
305) -> Result<&'a Bytes, InvalidSnapshotError> {
306 map.get(key)
307 .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
308}
309
310fn parse_address(bytes: &Bytes, attr_name: &str) -> Result<Address, InvalidSnapshotError> {
311 Address::try_from(&bytes[..])
312 .map_err(|err| InvalidSnapshotError::ValueError(format!("parsing {attr_name}: {err}")))
313}
314
315fn attribute_with_fallback<'a>(
317 map: &'a HashMap<String, Bytes>,
318 key: &str,
319 legacy_key: &str,
320) -> Result<&'a Bytes, InvalidSnapshotError> {
321 map.get(key)
322 .or_else(|| map.get(legacy_key))
323 .ok_or_else(|| InvalidSnapshotError::MissingAttribute(key.to_string()))
324}
325
326fn timed_state_details(
327 attrs: HashMap<String, Bytes>,
328) -> Result<TimedStateDetails, InvalidSnapshotError> {
329 let last_time = attribute_with_fallback(&attrs, "last_time", "last_execution_time")?
330 .clone()
331 .into();
332
333 Ok(TimedStateDetails {
334 rate_token0: attribute_with_fallback(&attrs, "rate_token0", "token0_sale_rate")?
335 .clone()
336 .into(),
337 rate_token1: attribute_with_fallback(&attrs, "rate_token1", "token1_sale_rate")?
338 .clone()
339 .into(),
340 last_time,
341 rate_deltas: rate_deltas_from_attributes(
342 attrs
343 .into_iter()
344 .map(|(key, value)| (key, Cow::Owned(value))),
345 last_time,
346 )
347 .map_err(InvalidSnapshotError::ValueError)?
348 .sorted_unstable_by_key(|delta| delta.time)
349 .collect(),
350 })
351}
352
353#[cfg(test)]
354mod tests {
355 use rstest::*;
356 use rstest_reuse::apply;
357 use tycho_common::models::protocol::ProtocolComponentState;
358
359 use super::*;
360 use crate::evm::protocol::{
361 ekubo_v3::test_cases::*, test_utils::try_decode_snapshot_with_defaults,
362 };
363
364 #[apply(all_cases)]
365 #[tokio::test]
366 async fn test_try_from_with_header(case: TestCase) {
367 let snapshot = ComponentWithState {
368 state: ProtocolComponentState {
369 component_id: String::new(),
370 attributes: case.state_attributes,
371 balances: HashMap::new(),
372 },
373 component: case.component,
374 component_tvl: None,
375 entrypoints: Vec::new(),
376 };
377
378 let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
379 .await
380 .expect("reconstructing state");
381
382 assert_eq!(result, case.state_before_transition);
383 }
384
385 #[apply(all_cases)]
392 #[tokio::test]
393 async fn test_try_from_legacy_format(case: TestCase) {
394 let extension_id: i32 = match &case.state_before_transition {
395 EkuboV3State::Concentrated(_) |
396 EkuboV3State::FullRange(_) |
397 EkuboV3State::Stableswap(_) => 1,
398 EkuboV3State::Oracle(_) => 2,
399 EkuboV3State::Twamm(_) => 3,
400 EkuboV3State::MevCapture(_) => 4,
401 EkuboV3State::BoostedFees(_) => return,
403 };
404
405 let mut component = case.component;
406 component
409 .static_attributes
410 .insert("extension_id".to_string(), extension_id.to_be_bytes().into());
411
412 let state_attributes = case
414 .state_attributes
415 .into_iter()
416 .map(|(key, value)| {
417 let key = key
418 .replace("tick/", "ticks/")
419 .replace("rate_delta/", "orders/");
420 let key = match key.as_str() {
421 "rate_token0" => "token0_sale_rate".to_string(),
422 "rate_token1" => "token1_sale_rate".to_string(),
423 "last_time" => "last_execution_time".to_string(),
424 _ => key,
425 };
426 (key, value)
427 })
428 .collect();
429
430 let snapshot = ComponentWithState {
431 state: ProtocolComponentState {
432 component_id: String::new(),
433 attributes: state_attributes,
434 balances: HashMap::new(),
435 },
436 component,
437 component_tvl: None,
438 entrypoints: Vec::new(),
439 };
440
441 let result = try_decode_snapshot_with_defaults::<EkuboV3State>(snapshot)
442 .await
443 .expect("reconstructing state from legacy format");
444
445 assert_eq!(result, case.state_before_transition);
446 }
447
448 #[apply(all_cases)]
449 #[tokio::test]
450 async fn test_try_from_invalid(case: TestCase) {
451 for missing_attribute in case.required_attributes {
452 let mut component = case.component.clone();
453 let mut attributes = case.state_attributes.clone();
454
455 component
456 .static_attributes
457 .remove(&missing_attribute);
458 attributes.remove(&missing_attribute);
459
460 let snapshot = ComponentWithState {
461 state: ProtocolComponentState {
462 attributes,
463 component_id: String::new(),
464 balances: HashMap::new(),
465 },
466 component,
467 component_tvl: None,
468 entrypoints: Vec::new(),
469 };
470
471 EkuboV3State::try_from_with_header(
472 snapshot,
473 BlockHeader::default(),
474 &HashMap::default(),
475 &HashMap::default(),
476 &DecoderContext::new(),
477 )
478 .await
479 .unwrap_err();
480 }
481 }
482}