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