#![expect(clippy::doc_markdown)]
use std::sync::Arc;
use alloy::primitives::{Address, B256, U256};
use degenbot_pools::slot_layout;
use degenbot_pools::{
v3_storage_slots, v4_storage_slots, ClSlotLayout, ConcentratedLiquidityVariant, Identity,
ReservePairVariant,
};
use hashbrown::HashMap;
use super::frame_replay::ReplayOutcome;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PoolFamily {
V2Pair,
V3 {
layout: ClSlotLayout,
tick_spacing: i32,
current_tick_hint: Option<i32>,
},
V4PoolManager { pools: V4PoolSet },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PoolFamilyTag {
V2Pair,
V3,
V4PoolManager,
}
impl PoolFamilyTag {
#[must_use]
pub fn from_identity(identity: &Identity) -> Option<Self> {
match identity {
Identity::ReservePair { variant, .. } => match variant {
ReservePairVariant::UniswapV2 => Some(Self::V2Pair),
ReservePairVariant::AerodromeV2 { .. } => None,
},
Identity::ConcentratedLiquidity { variant, .. } => match variant {
ConcentratedLiquidityVariant::UniswapV3 => Some(Self::V3),
ConcentratedLiquidityVariant::UniswapV4 => Some(Self::V4PoolManager),
},
Identity::BalanceVector { .. } | Identity::BinnedLiquidity { .. } => None,
}
}
}
impl PoolFamily {
#[must_use]
pub const fn tag(&self) -> PoolFamilyTag {
match self {
Self::V2Pair => PoolFamilyTag::V2Pair,
Self::V3 { .. } => PoolFamilyTag::V3,
Self::V4PoolManager { .. } => PoolFamilyTag::V4PoolManager,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct V4PoolDescriptor {
pub pool_id: B256,
pub tick_spacing: i32,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct V4PoolSet {
pools: Arc<[V4PoolDescriptor]>,
}
impl V4PoolSet {
#[must_use]
pub fn new(pools: Vec<V4PoolDescriptor>) -> Self {
Self {
pools: pools.into(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.pools.is_empty()
}
#[must_use]
pub fn as_slice(&self) -> &[V4PoolDescriptor] {
&self.pools
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TouchedTickWord {
pub tick: i32,
pub liquidity_gross: u128,
pub liquidity_net: i128,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TypedPoolPost {
V2 {
reserves: slot_layout::V2ReservesParts,
},
V3 {
sqrt_price_x96: Option<U256>,
tick: Option<i32>,
liquidity: Option<u128>,
touched_ticks: Vec<TouchedTickWord>,
},
V4 {
pool_id: B256,
sqrt_price_x96: Option<U256>,
tick: Option<i32>,
liquidity: Option<u128>,
touched_ticks: Vec<TouchedTickWord>,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PoolPostKind {
Typed(TypedPoolPost),
Unsupported,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PoolPostState {
pub address: Address,
pub family: PoolFamily,
pub kind: PoolPostKind,
}
#[must_use]
pub fn extract_pool_post_states(
outcome: &ReplayOutcome,
descriptors: &HashMap<Address, PoolFamily>,
) -> Vec<PoolPostState> {
let mut out = Vec::new();
for (address, slots) in &outcome.touched {
let Some(family) = descriptors.get(address) else {
continue;
};
let journal_word = |slot: U256| {
outcome
.state
.get(address)
.and_then(|account| account.storage.get(&slot))
.map(|s| s.present_value)
};
if let PoolFamily::V4PoolManager { pools } = family {
let typed = extract_v4_pool_states(pools, slots, &journal_word);
if typed.is_empty() {
out.push(PoolPostState {
address: *address,
family: family.clone(),
kind: PoolPostKind::Unsupported,
});
} else {
out.extend(typed.into_iter().map(|post| PoolPostState {
address: *address,
family: family.clone(),
kind: PoolPostKind::Typed(post),
}));
}
continue;
}
let kind = match family {
PoolFamily::V4PoolManager { .. } => unreachable!("V4 handled above"),
PoolFamily::V2Pair => PoolPostKind::Typed(TypedPoolPost::V2 {
reserves: journal_word(U256::from(slot_layout::V2_RESERVES_SLOT)).map_or_else(
|| slot_layout::decode_v2_reserves_word(U256::ZERO),
slot_layout::decode_v2_reserves_word,
),
}),
PoolFamily::V3 {
layout,
tick_spacing,
current_tick_hint,
} => PoolPostKind::Typed(TypedPoolPost::V3 {
sqrt_price_x96: journal_word(U256::ZERO)
.map(v3_storage_slots::decode_v3_slot0)
.map(|parts| parts.sqrt_price_x96),
tick: journal_word(U256::ZERO)
.map(v3_storage_slots::decode_v3_slot0)
.map(|parts| parts.tick),
liquidity: journal_word(U256::from(layout.liquidity_slot()))
.map(|w| (w & U256::from(u128::MAX)).to::<u128>()),
touched_ticks: {
let post_tick =
journal_word(U256::ZERO).map(|w| v3_storage_slots::decode_v3_slot0(w).tick);
let mut anchors: Vec<i32> =
(*current_tick_hint).into_iter().chain(post_tick).collect();
anchors.sort_unstable();
anchors.dedup();
recover_touched_ticks(
slots,
U256::from(layout.ticks_mapping_slot()),
&[U256::ZERO, U256::from(layout.liquidity_slot())],
*tick_spacing,
&anchors,
&journal_word,
)
},
}),
};
out.push(PoolPostState {
address: *address,
family: family.clone(),
kind,
});
}
out
}
fn extract_v4_pool_states(
pools: &V4PoolSet,
slots: &[U256],
journal_word: &impl Fn(U256) -> Option<U256>,
) -> Vec<TypedPoolPost> {
let mut out = Vec::new();
for pool in pools.pools.iter() {
let base = v4_storage_slots::v4_pool_state_base_slot(pool.pool_id);
let slot0_slot = v4_storage_slots::v4_slot0_slot(base);
let liquidity_slot = v4_storage_slots::v4_liquidity_slot(base);
let ticks_base = v4_storage_slots::v4_ticks_mapping_base_slot(base);
let slot0_parts = journal_word(slot0_slot).map(v4_storage_slots::decode_v4_slot0);
let liquidity =
journal_word(liquidity_slot).map(|w| (w & U256::from(u128::MAX)).to::<u128>());
let anchors: Vec<i32> = slot0_parts
.as_ref()
.map(|parts| parts.tick)
.into_iter()
.collect();
let touched_ticks = recover_touched_ticks(
slots,
ticks_base,
&[slot0_slot, liquidity_slot],
pool.tick_spacing,
&anchors,
journal_word,
);
if slot0_parts.is_none() && liquidity.is_none() && touched_ticks.is_empty() {
continue;
}
out.push(TypedPoolPost::V4 {
pool_id: pool.pool_id,
sqrt_price_x96: slot0_parts.as_ref().map(|parts| parts.sqrt_price_x96),
tick: slot0_parts.as_ref().map(|parts| parts.tick),
liquidity,
touched_ticks,
});
}
out
}
#[must_use]
fn recover_touched_ticks(
slots: &[U256],
ticks_base: U256,
skip_slots: &[U256],
tick_spacing: i32,
anchors: &[i32],
journal_word: &impl Fn(U256) -> Option<U256>,
) -> Vec<TouchedTickWord> {
let spacing = tick_spacing.max(1);
let mut anchors: Vec<i32> = anchors.to_vec();
anchors.sort_unstable();
anchors.dedup();
let spread = match (anchors.first(), anchors.last()) {
(Some(&low), Some(&high)) => i64::from(high - low),
_ => 0,
};
let window: i64 = spread + 4 * i64::from(spacing) + 4_096;
let mut ticks = Vec::new();
for slot in slots {
if *slot == U256::ZERO || skip_slots.contains(slot) {
continue;
}
let Some(tick) = slot_layout::recover_tick_from_slot_at_base(
*slot,
ticks_base,
spacing,
&anchors,
i32::try_from(window).unwrap_or(i32::MAX),
) else {
continue;
};
let Some(word) = journal_word(*slot) else {
continue;
};
let (liquidity_gross, liquidity_net) = slot_layout::decode_tick_word(word);
ticks.push(TouchedTickWord {
tick,
liquidity_gross,
liquidity_net,
});
}
ticks.sort_unstable_by_key(|t| t.tick);
ticks
}
#[cfg(test)]
mod taxonomy_tests {
use super::{PoolFamily, PoolFamilyTag, V4PoolSet};
use degenbot_pools::{
BalanceVectorVariant, BinnedLiquidityVariant, ClSlotLayout, ConcentratedLiquidityVariant,
Identity, ReservePairVariant,
};
fn reserve_pair(variant: ReservePairVariant) -> Identity {
Identity::ReservePair { variant, dex: None }
}
fn concentrated_liquidity(variant: ConcentratedLiquidityVariant) -> Identity {
Identity::ConcentratedLiquidity { variant, dex: None }
}
fn balance_vector(variant: BalanceVectorVariant) -> Identity {
Identity::BalanceVector { variant, dex: None }
}
#[test]
fn tag_covers_every_extraction_family() {
assert_eq!(PoolFamily::V2Pair.tag(), PoolFamilyTag::V2Pair);
assert_eq!(
PoolFamily::V3 {
layout: ClSlotLayout::UniswapV3,
tick_spacing: 10,
current_tick_hint: None,
}
.tag(),
PoolFamilyTag::V3,
);
assert_eq!(
PoolFamily::V4PoolManager {
pools: V4PoolSet::default(),
}
.tag(),
PoolFamilyTag::V4PoolManager,
);
}
#[test]
fn identity_projection_admits_only_the_structures_extraction_types() {
assert_eq!(
PoolFamilyTag::from_identity(&reserve_pair(ReservePairVariant::UniswapV2)),
Some(PoolFamilyTag::V2Pair),
);
assert_eq!(
PoolFamilyTag::from_identity(&reserve_pair(ReservePairVariant::AerodromeV2 {
stable: true
})),
None,
);
assert_eq!(
PoolFamilyTag::from_identity(&reserve_pair(ReservePairVariant::AerodromeV2 {
stable: false
})),
None,
);
assert_eq!(
PoolFamilyTag::from_identity(&concentrated_liquidity(
ConcentratedLiquidityVariant::UniswapV3
)),
Some(PoolFamilyTag::V3),
);
assert_eq!(
PoolFamilyTag::from_identity(&concentrated_liquidity(
ConcentratedLiquidityVariant::UniswapV4
)),
Some(PoolFamilyTag::V4PoolManager),
);
assert_eq!(
PoolFamilyTag::from_identity(&balance_vector(BalanceVectorVariant::Curve)),
None,
);
assert_eq!(
PoolFamilyTag::from_identity(&balance_vector(BalanceVectorVariant::BalancerWeighted)),
None,
);
assert_eq!(
PoolFamilyTag::from_identity(&balance_vector(BalanceVectorVariant::BalancerStable)),
None,
);
assert_eq!(
PoolFamilyTag::from_identity(&Identity::BinnedLiquidity {
variant: BinnedLiquidityVariant::Lfj,
dex: None,
}),
None,
"no bin-layout descriptor yet: binned liquidity must lag, not guess",
);
}
}