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