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