degenbot-simulation 0.6.0-alpha.14

In-process revm simulation executor + dispatch fan-out (pyo3-free core; ADR-019 D4 fold).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
//! Journal → typed pool post-state extractors — reverse decoders over the
//! frame-replay seam.
//!
//! Given a [`ReplayOutcome`]'s touched set + journalled words, recover the
//! typed post-state of every tracked pool the frame touched — V2 pair, V3
//! pool, or a V4 pool inside the `PoolManager` singleton. The V4 layout is
//! pinned in `docs/architecture/v4_poolmanager_storage_layout.md`
//! (`S_state = keccak256(abi.encode(poolId, 6))`, slot0 at `S_state`,
//! liquidity at `S_state+3`, ticks base `S_state+4`), so each KNOWN poolId
//! decodes through the shared `degenbot_pools` slot math. An empty known set
//! or slots matching no known poolId stay [`PoolPostKind::Unsupported`] —
//! never a guessed decode. This replaces the unbounded router-calldata
//! decode problem with a bounded per-pool-family one: one row table per
//! family, invariant across routers/aggregators, shared with the PACK
//! direction through `degenbot_pools::slot_layout`.
//!
//! # Output contract
//!
//! [`PoolPostState`] is consumed by both the admission and the validation
//! harness: pool address + family + typed fields + the touched per-tick
//! words. V3 tick indices are recovered from the journalled keccak
//! preimages by hashing `tick_spacing`-aligned candidates around the pre-tx
//! current tick hint and the post-tx current tick (decoded from the
//! journal's own slot0) — bounded, and exact because initialized ticks are
//! discrete. An unmatched slot fabricates NOTHING.
//!
//! Pool identity comes from the caller's descriptor map (the engine's
//! tracked-pool registry projects it); a touched address without a
//! descriptor contributes no state.
// Solidity/EVM identifiers (slot0, uint128, keccak, SSTORE, CacheDB) are
// ubiquitous here — match the frame_replay convention.
#![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;

/// The per-pool layout knowledge the extractor needs, projected from the
/// engine's tracked-pool registry by the caller.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PoolFamily {
    /// A V2 pair: reserves are packed at the layout table's slot 8.
    V2Pair,
    /// A concentrated-liquidity pool (V3-shaped): slot0/liquidity/per-tick
    /// words under the pool's fork layout, with the engine's pre-tx current
    /// tick as the preimage anchor (`None` = unknown, the journal's own
    /// slot0 then carries the only anchor).
    V3 {
        layout: ClSlotLayout,
        tick_spacing: i32,
        current_tick_hint: Option<i32>,
    },
    /// A V4 PoolManager singleton: pool state lives at keccak-derived
    /// per-poolId bases (`S_state = keccak256(abi.encode(poolId, 6))`). The
    /// KNOWN tracked pool set decodes per poolId; an empty set (the
    /// descriptor default until a caller can supply the index's known
    /// poolIds) or slots matching no known poolId returns
    /// [`PoolPostKind::Unsupported`] (skip it with
    /// `observe reason="v4_unsupported"`), never a guessed decode.
    V4PoolManager { pools: V4PoolSet },
}

/// The family-level tag of a [`PoolFamily`] — the journal-extraction
/// vocabulary's projection onto the `degenbot_pools` taxonomy (ADR-059 D1).
///
/// [`PoolFamily`] stays the data-carrying descriptor (the fork slot layout,
/// the live V4 roster); only the family discriminant projects, so a caller
/// holding a taxonomy [`Identity`] can name the extraction family without
/// inventing layout payload it does not have.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PoolFamilyTag {
    V2Pair,
    V3,
    V4PoolManager,
}

impl PoolFamilyTag {
    /// Project the pool taxonomy onto the journal-extraction family tag.
    ///
    /// The extraction vocabulary LAGS the taxonomy: it carries no
    /// balance-vector arm, and its `V2Pair` layout is the canonical
    /// Uniswap-V2 slot-8 pair — an Aerodrome V2 (Solidly) pair stores its
    /// reserves at different slots, so it projects to `None` rather than a
    /// false slot-8 claim.
    #[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),
            },
            // The extractor carries neither a balance-vector family nor a
            // bin-layout descriptor: both LAG the taxonomy (D8).
            Identity::BalanceVector { .. } | Identity::BinnedLiquidity { .. } => None,
        }
    }
}

impl PoolFamily {
    /// The family-level tag of this extraction descriptor (ADR-059 D1).
    #[must_use]
    pub const fn tag(&self) -> PoolFamilyTag {
        match self {
            Self::V2Pair => PoolFamilyTag::V2Pair,
            Self::V3 { .. } => PoolFamilyTag::V3,
            Self::V4PoolManager { .. } => PoolFamilyTag::V4PoolManager,
        }
    }
}

/// One tracked V4 pool the singleton manages: the `poolId` the `_pools`
/// mapping is keyed by, plus the `tick_spacing` the tick-preimage hunt
/// aligns candidates to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct V4PoolDescriptor {
    pub pool_id: B256,
    pub tick_spacing: i32,
}

/// The tracked V4 pool set a [`PoolFamily::V4PoolManager`] descriptor
/// carries. [`Default`] is the EMPTY set: the extractor then reports the
/// family [`PoolPostKind::Unsupported`], preserving the explicit-unsupported
/// behavior until a caller supplies the index's known poolIds.
#[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
    }
}

/// One recovered per-tick word: the tick index (recovered from the keccak
/// preimage) + the decoded `liquidityGross`/`liquidityNet` fields.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TouchedTickWord {
    pub tick: i32,
    pub liquidity_gross: u128,
    pub liquidity_net: i128,
}

/// The typed post-state of one family.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TypedPoolPost {
    /// `reserves` decoded from the journalled slot-8 word (0-wei exact; the
    /// untracked `blockTimestampLast` reads through as 0 — the engine packs
    /// none).
    V2 {
        reserves: slot_layout::V2ReservesParts,
    },
    /// slot0 (`sqrtPriceX96` + `tick`), `liquidity`, and the touched per-tick
    /// words. `None` = that word was not touched by the frame.
    V3 {
        sqrt_price_x96: Option<U256>,
        tick: Option<i32>,
        liquidity: Option<u128>,
        touched_ticks: Vec<TouchedTickWord>,
    },
    /// A KNOWN V4 pool's `Pool.State`, decoded from the singleton's
    /// journalled slots at the poolId-derived bases. `None` = that scalar
    /// word was not touched; an empty `touched_ticks` = no recoverable tick
    /// word.
    V4 {
        pool_id: B256,
        sqrt_price_x96: Option<U256>,
        tick: Option<i32>,
        liquidity: Option<u128>,
        touched_ticks: Vec<TouchedTickWord>,
    },
}

/// Typed vs explicitly-unsupported extraction.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PoolPostKind {
    Typed(TypedPoolPost),
    /// The frame touched ONLY state the extractor cannot decode — a V4
    /// `PoolManager` whose slots match no known poolId, or an empty known
    /// set. Skipped upstream with `observe reason="v4_unsupported"`, never
    /// guessed.
    Unsupported,
}

/// The extracted post-state of ONE touched tracked pool.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PoolPostState {
    /// The pool contract (V2 pair / V3 pool; a V4 descriptor keys the
    /// singleton `PoolManager`).
    pub address: Address,
    pub family: PoolFamily,
    pub kind: PoolPostKind,
}

/// Extract the typed post-state of every touched pool that appears in
/// `descriptors`, in `ReplayOutcome::touched` order (address-sorted).
///
/// Reads journalled words ONLY from `outcome.state` (the settled, revert-
/// filtered journal) — a reverted frame surfaces no pool state at all.
#[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
}

/// Decode every KNOWN V4 pool whose `Pool.State` slots appear in the frame's
/// journalled set. A pool matches only when one of its slot0/liquidity words
/// (or a recoverable tick preimage) is present — a frame that touched the
/// singleton for other reasons contributes nothing, leaving the caller to
/// report the family [`PoolPostKind::Unsupported`].
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
}

/// Recover the per-tick words among a pool's journalled slots: every slot
/// that preimages to a `tick_spacing`-aligned tick near one of `anchors`
/// (the pool's known current ticks) decodes to a [`TouchedTickWord`]; every
/// OTHER slot (tickBitmap words, fee growth, fee-growth-outside words) is
/// deliberately NOT decoded — nothing is fabricated.
///
/// `ticks_base` is the pool's `ticks(tick)` mapping base (a V3 layout base,
/// or a V4 pool's `S_state+4`); `skip_slots` are the pool's scalar slots
/// (slot0/liquidity) that must never be swept as tick preimages.
#[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();
    // Every touched tick word of a swap lies between (plus one spacing past)
    // the anchors, so the window covers |post - pre| plus a floor for
    // frames that never moved slot0.
    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 {
    //! ADR-059 D1: the journal-extraction family tag is a projection of the
    //! pool taxonomy; the data-carrying [`PoolFamily`] never restates the
    //! family. Every structure x variant is pinned, including the legs this
    //! extraction vocabulary deliberately does not admit.
    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",
        );
    }
}