From 286d5e4926795891e84a63ce4e7e1744586c627e Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 16 Jun 2026 12:48:29 +0100 Subject: [PATCH 1/6] Phase 4: add event-pipeline spec (Pillar B.2) Build contract for the reader half of Pillar B: an EventDecoder trait + StateView, a DecoderRegistry, an ERC-20 Transfer decoder, a UniswapV3 Swap/Mint/Burn adapter, and the EventPipeline (ingest_logs / reorg_to / reconcile) that drives reactive cache updates. Adds the cold-aware StateUpdate::SlotMasked vocabulary variant so a pure decoder can express a partial update to a packed storage word (V3 slot0) without clobbering bits it does not own. Decisions locked with the user 2026-06-16 (SlotMasked; full Swap+Mint/Burn V3 coverage; purge-and-resync reorgs; sampled correct+alarm reconciliation). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/phase-4-spec.md | 728 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 728 insertions(+) create mode 100644 docs/phase-4-spec.md diff --git a/docs/phase-4-spec.md b/docs/phase-4-spec.md new file mode 100644 index 0000000..78d2d6d --- /dev/null +++ b/docs/phase-4-spec.md @@ -0,0 +1,728 @@ +# Phase 4 implementation spec — event pipeline + adapters (Pillar B.2) + +Implementation contract for the **reader half** of Pillar B: turn an on-chain +`Log` into the Phase 3 [`StateUpdate`] vocabulary, apply it through +`apply_updates`, and keep the cache **reactively fresh** from the event stream — +with reconciliation, reorg handling, and freshness wiring. Read this **with** +[`ROADMAP.md`](ROADMAP.md) (the "Phase 4" row, the "Pillar B — event → state +pipeline" section, and the "Hard problems to resolve" list) and +[`phase-3-spec.md`](phase-3-spec.md) (the writer half this builds on). This +document is the precise build contract; where they overlap, prefer this. + +Phase 3 built the writer half (`StateUpdate` + `apply_updates` with cold-aware +`SlotDelta`/`BalanceDelta` RMW and `account_state`-correct reads). Phase 4 builds +the decoder, the protocol adapters, and the orchestration that drives them. + +## 0. Ground rules (non-negotiable) + +- **Branch:** create `phase-4-event-pipeline` off the current + `phase-3-state-updates` HEAD. Commit there in logical steps. Do **not** push, + do **not** tag, do **not** open a PR (the overseer does that). Commits must be + **unsigned**: `git -c commit.gpgsign=false commit …` (the 1Password signing + agent is unavailable here). End every commit message with exactly: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- **Generic core vs `protocols`.** The pipeline, the `EventDecoder`/`StateView` + traits, the `DecoderRegistry`, the ERC-20 decoder, and the `SlotMasked` + vocabulary addition are **generic core** — they must compile and lint with + `--no-default-features`. Only the UniswapV3 adapter (`uniswap_v3`) is gated + behind the `protocols` feature. +- **Green bar at every commit, both feature configs:** + - `cargo fmt --all --check` + - `cargo clippy --all-targets --no-deps -- -D warnings` + - `cargo clippy --lib --no-default-features --no-deps -- -D warnings` + - `cargo test` + - `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps` + - `cargo bench --no-run` (all benches build offline) +- MSRV is 1.88 — no newer-than-1.88 std APIs. Edition 2024. +- **Do not break existing behavior or any existing test.** The Phase 3 surface + (`StateUpdate`, `apply_updates`, `modify_slot`, `StateDiff`) keeps its shape; + Phase 4 *adds* the `SlotMasked` variant and the `skipped_masks` diff field + under the pre-1.0 break policy (both already-`#[non_exhaustive]` types). +- **No new dependencies.** `alloy-primitives` (with `Log`), `alloy-sol-types` + (`sol!` / `SolEvent`), `alloy-provider`, `futures`, and `tokio` are already + present. Decode logs with `sol!`-generated event types + `SolEvent`, not + hand-rolled byte slicing. + +## 1. Objective & scope + +Today the crate can *write* targeted state but has no way to *derive* those +writes from chain activity: a caller must hand it concrete `StateUpdate`s. Phase +4 closes the loop — decode a `Log` into `StateUpdate`s, apply them, and run the +reactive maintenance (reconcile, reorg) that keeps event-derived state honest. + +**In scope:** + +1. **`StateUpdate::SlotMasked`** — a cold-aware read-modify-write *masked* slot + write (`(old & !mask) | (value & mask)`), so a pure decoder can express a + partial update to a **packed** storage word (e.g. V3 `slot0`) without knowing + or clobbering the bits it does not own. Generic core (§4.1). +2. **`EventDecoder` + `StateView`** — the decoder trait (`Log` + read-only + pre-state view → `Vec`) and the narrow read-only cache view it is + handed. `EvmCache` implements `StateView`. Generic core (§4.2). +3. **`DecoderRegistry`** — dispatches a log to the decoder(s) registered for its + emitting address (and/or topic0) and concatenates their output. Generic core + (§4.3). +4. **`Erc20TransferDecoder`** — generic ERC-20 `Transfer` → relative balance + `SlotDelta`s (the §15 reactive-balance case, now log-driven). Generic core + (§5). +5. **`UniswapV3Decoder`** — `protocols`-gated adapter: `Swap` → `slot0` + (masked sqrtPriceX96 + tick) + `liquidity`; `Mint`/`Burn` → per-tick + `liquidityGross`/`liquidityNet`, the `initialized` flag, `tickBitmap` word + flips, and the global `liquidity` (conditional on the current tick). Computed + against the `StateView` (tick maintenance is inherently RMW). (§6). +6. **`EventPipeline`** — the orchestration: `ingest_logs` (decode+apply + **log-by-log**, in order, recording touched state for reorg tracking), + `reorg_to` (purge-and-resync addresses touched after the new head), and + `reconcile` (sampled RPC re-read via `verify_slots`: correct **and** alarm). + Generic core (§7). +7. **Freshness wiring** — `BlockDigest` surfaces the touched `(address, slot)` + set so a caller can classify event-derived slots (`valid_through` / `pin`) and + call `FreshnessController::on_new_block`. No controller internals change (§8). +8. Offline example, benchmark, docs, CHANGELOG, ROADMAP → Done (§11). + +**Out of scope (document as follow-ups; do not build):** +- **A concrete WS transport / live subscription loop.** The async `drive` + convenience (§7.5) is generic over a log source and is exercised only by the + offline example feeding a vec-backed source; a production WS/`subscribe_logs` + adapter is a follow-up. The *tested* surface is the synchronous core. +- **V3 fee-growth / oracle observation maintenance.** Event-derived tick init + does **not** reconstruct `feeGrowthOutside0/1X128` (slots +1/+2) or oracle + observations — those are not derivable from `Mint`/`Burn`/`Swap`. Swap + *price/liquidity quoting* is unaffected; fee-accounting reads are not + maintained. Document as a KNOWN_ISSUE with reconcile/purge as the backstop + (§6.4). +- **Non-Uniswap-layout V3 (Slipstream slot0).** The adapter assumes the + Uniswap/Pancake `slot0` bit layout (only base slots differ). Slipstream's + different `slot0` packing is a follow-up. +- **COW snapshots** (Phase 5). The pipeline mutates the existing `EvmCache` + layers via `apply_updates`. + +## 2. Reuse these existing pieces (do not reinvent) + +- **`StateUpdate` / `apply_update` / `apply_updates` / `modify_slot`** + (`state_update.rs`, `cache/mod.rs`) — the write half. The pipeline applies + decoded updates through `apply_updates`; the `SlotMasked` handler reuses the + private `write_slot_through` and the `account_state`-aware + `cached_storage_value` (§16.0). +- **`EvmCache::cached_storage_value`** — the `StateView::storage` + implementation (overlay ▸ backend ▸ `None`, `account_state`-correct). +- **`EvmCache::verify_slots`** (`cache/mod.rs`) — the synchronous + fetch-compare-inject reconciliation primitive. `reconcile` is a thin wrapper: + it samples event-derived slots and calls `verify_slots`; the returned + `Vec` is the drift report (verify_slots already injected the fresh + chain values — correct + alarm). +- **`EvmCache::purge_account` / `apply_update(Purge { … })`** — the reorg + purge mechanism. `reorg_to` purges touched addresses through `apply_updates` + of `Purge` updates so the next read re-fetches. +- **`inspector::TransferInspector::parse_transfer`** + the + `TRANSFER_EVENT_SIGNATURE` constant (`src/inspector.rs`) — reuse for the + ERC-20 decoder's signature match and topic decoding (or reuse the same + `sol!` event). Do not redefine the signature constant. +- **`cache::storage_keys`** (`protocols`) — `V3_*`/`PANCAKE_V3_*` slot + constants, `v3_tick_info_storage_keys_with_base`, + `v3_tick_bitmap_storage_key_with_base`, `i256_from_i24`, `i128_to_u256`. The + V3 adapter reuses these for slot derivation and packing. +- **`freshness::{FreshnessController, FreshnessRegistry, Validity, SlotChange}`** + — the freshness wiring target. `SlotChange` is the reconcile-report element. +- **`alloy_sol_types::{sol, SolEvent}`** — generate `Swap`/`Mint`/`Burn` and + ERC-20 `Transfer` event types and decode with `SolEvent::decode_log_data`. +- **The offline harness** — `examples/support/mock.rs` (`offline_cache`, + `install_mock_erc20`, `MockERC20`, `MOCK_ERC20_BALANCE_SLOT`), + `tests/common`. The new tests/example build the cache over the mocked provider + and never touch the network. + +## 3. Module layout + +A new `src/events/` directory module (the crate's only other dir module is +`cache/`): + +- **`src/events/mod.rs`** (generic core): `EventDecoder`, `StateView`, + `DecoderRegistry`, `EventPipeline`, `BlockDigest`, `ReconcileReport`, + `ReorgConfig`, and the async `drive` convenience + its `LogSource` trait. The + module `//!` doc frames Pillar B.2 and the `!Send`-cache discipline. +- **`src/events/erc20.rs`** (generic core): `Erc20TransferDecoder` + config. +- **`src/events/uniswap_v3.rs`** (`#[cfg(feature = "protocols")]`): + `UniswapV3Decoder` + `UniswapV3Layout` config (base slots + tick spacing). +- **`src/state_update.rs`**: add the `SlotMasked` variant, the `slot_masked` + constructor, `SkippedMask`, and the `StateDiff.skipped_masks` field + + `merge`/`has_skipped`/`skipped_len` updates. +- **`src/cache/mod.rs`**: the `SlotMasked` apply arm (reusing `write_slot_through` + + the cold-aware read); `impl events::StateView for EvmCache`. +- **`src/lib.rs`**: `pub mod events;` + re-exports (§9). + +## 4. Core types & behavior + +### 4.1 `StateUpdate::SlotMasked` — cold-aware masked write (generic core) + +```rust +pub enum StateUpdate { + Slot { address, slot, value }, + SlotDelta { address, slot, delta }, + /// Set only the `mask` bits of a storage slot to the corresponding bits of + /// `value`, preserving the rest: `new = (old & !mask) | (value & mask)`. + /// Read-modify-write, **cold-aware** — a masked write to a slot absent from + /// both layers is not applied (the un-masked bits are unknown); it is + /// surfaced in [`StateDiff::skipped_masks`]. + SlotMasked { address: Address, slot: U256, mask: U256, value: U256 }, // NEW + BalanceDelta { address, delta }, + Account { address, patch }, + Purge { address, scope }, +} +impl StateUpdate { + pub fn slot_masked(address: Address, slot: U256, mask: U256, value: U256) -> Self; +} + +/// A masked write ([`StateUpdate::SlotMasked`]) skipped because the target slot +/// was cold (un-masked bits unknown). Fetch+seed the slot, then retry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SkippedMask { pub address: Address, pub slot: U256, pub mask: U256, pub value: U256 } + +pub struct StateDiff { + pub slots: Vec, + pub accounts: Vec, + pub purged: Vec, + pub skipped: Vec, + pub skipped_balances: Vec, + pub skipped_masks: Vec, // NEW +} +``` + +`SkippedMask` is a leaf record constructed as a struct literal in equality +assertions, so — like `SkippedDelta`/`SkippedBalanceDelta` (§16.4) — it is +**not** `#[non_exhaustive]`. `StateUpdate` and `StateDiff` already are. + +Apply behavior (`cache/mod.rs`, mirrors the `SlotDelta` arm): +- Read `old = cached_storage_value(address, slot)` (cold-aware, §16.0). +- If `Some(old)`: `new = (old & !mask) | (value & mask)`; write through both + layers via `write_slot_through`; push a `SlotChange { old, new }` iff + `old != new`. +- If `None` (cold): push `SkippedMask` to `diff.skipped_masks`, write nothing. + +`StateDiff::merge` extends `skipped_masks`. `has_skipped` /`skipped_len` +include `skipped_masks`. `is_empty`/`len` stay **changes-only** (a skip is not a +change). serde derives on `SkippedMask`. Module `//!` doc gains a `SlotMasked` +paragraph (packed-word updates, cold-aware). + +> A masked write with `mask == U256::MAX` equals an absolute `Slot` write but +> **stays cold-skip** (a cold full-mask write is still skipped, unlike `Slot` +> which writes unconditionally). Decoders that want an unconditional absolute +> write use `Slot`; those that must preserve neighbouring bits use `SlotMasked`. + +### 4.2 `EventDecoder` + `StateView` + +```rust +/// Read-only view of current cached state handed to a decoder. +/// +/// Decoders that compute post-state from pre-state (e.g. V3 tick maintenance) +/// read through this; stateless decoders (ERC-20 `Transfer`, V3 `Swap`) ignore +/// it. The view never touches RPC — a slot absent from the cache reads `None`. +pub trait StateView { + /// Current cached value of `(address, slot)` (overlay ▸ backend ▸ `None`), + /// matching what the EVM would `SLOAD` (`account_state`-aware). + fn storage(&self, address: Address, slot: U256) -> Option; +} + +/// Decode one log into zero or more targeted [`StateUpdate`]s. +/// +/// `decode` is a pure function of `(log, pre-state)`: it performs no I/O and +/// emits data (the updates are serializable and replayable against matching +/// pre-state). The pipeline applies the result through `apply_updates`. +pub trait EventDecoder: Send + Sync { + fn decode(&self, log: &Log, view: &dyn StateView) -> Vec; +} +``` + +`EvmCache` implements `StateView` via `cached_storage_value`. Rationale for the +`StateView` parameter (a refinement of the ROADMAP's `fn decode(&self, log) -> +Vec` sketch): event-driven sync is fundamentally *events + +pre-state → post-state*. Most updates are expressible without pre-state +(`SlotDelta` and `SlotMasked` are RMW **at apply time**), but V3 tick +maintenance must read current `liquidityGross`/`liquidityNet`/`tick`/bitmap to +compute the next packed value, and a pure decoder cannot. Handing decoders a +narrow read-only view keeps the output as serializable `StateUpdate` data while +making the stateful adapters expressible and offline-testable (feed a stub +view). + +### 4.3 `DecoderRegistry` + +```rust +#[derive(Default)] +pub struct DecoderRegistry { /* global decoders + per-address decoders */ } + +impl DecoderRegistry { + pub fn new() -> Self; + /// Register a decoder consulted for **every** log. + pub fn register(&mut self, decoder: Arc) -> &mut Self; + /// Register a decoder consulted only for logs emitted by `address`. + pub fn register_for_address(&mut self, address: Address, decoder: Arc) -> &mut Self; + /// Decode `log` through every applicable decoder, concatenating the results + /// (address-scoped decoders first, then global), preserving order. + pub fn decode(&self, log: &Log, view: &dyn StateView) -> Vec; +} +``` + +Dispatch is by emitting address (`log.address`); topic0 filtering is the +decoder's own concern (each decoder returns `vec![]` for a log it does not +recognise). Keep it simple: address-scoped entries + a global list, both +consulted, output concatenated. + +## 5. `Erc20TransferDecoder` (generic core, `events/erc20.rs`) + +```rust +pub struct Erc20TransferDecoder { + /// Balance mapping slot per token (the `balanceOf` mapping's base slot). + balance_slots: HashMap, + /// Fallback balance slot for tokens not in the map. + default_balance_slot: U256, +} +impl Erc20TransferDecoder { + pub fn new(default_balance_slot: U256) -> Self; + pub fn with_token(mut self, token: Address, balance_slot: U256) -> Self; +} +impl EventDecoder for Erc20TransferDecoder { /* … */ } +``` + +Decode rule for a `Transfer(from, to, value)` log (signature match via +`TRANSFER_EVENT_SIGNATURE`; topics/data decoded like `parse_transfer`): +- `slot = balance_slots.get(token).copied().unwrap_or(default_balance_slot)`. +- `balance_key(owner) = U256::from(keccak256(abi_encode((owner, slot))))`. +- Emit, **skipping the zero-address leg** (mint = `from == 0`, burn = `to == 0`): + - if `from != Address::ZERO`: `SlotDelta::Sub(value)` on `balance_key(from)`. + - if `to != Address::ZERO`: `SlotDelta::Add(value)` on `balance_key(to)`. +- A non-`Transfer` log (wrong topic0, < 3 topics, < 32 data bytes) → `vec![]`. + +Cold balances follow the Phase 3 contract: the `SlotDelta` is skipped and +surfaced in `StateDiff.skipped` (the caller seeds the balance, or the next read +lazily fetches it). The decoder ignores the `StateView`. `value == 0` transfers +emit deltas of zero (a no-op at apply — empty diff); that is acceptable. + +## 6. `UniswapV3Decoder` (`protocols`, `events/uniswap_v3.rs`) + +```rust +#[derive(Clone, Debug)] +pub struct UniswapV3Layout { + pub slot0_slot: U256, // V3_SLOT0_SLOT (0) — Uniswap/Pancake + pub liquidity_slot: U256, // V3_LIQUIDITY_SLOT (4) / PANCAKE (5) + pub ticks_base_slot: U256, // V3_TICKS_BASE_SLOT (5) / PANCAKE (6) + pub tick_bitmap_base_slot: U256, // V3_TICK_BITMAP_BASE_SLOT (6) / PANCAKE (7) + pub tick_spacing: i32, // pool tickSpacing (for bitmap word/bit) +} +impl UniswapV3Layout { + pub fn uniswap(tick_spacing: i32) -> Self; // canonical Uniswap V3 slots + pub fn pancake(tick_spacing: i32) -> Self; // PancakeSwap V3 slots +} + +pub struct UniswapV3Decoder { + /// Per-pool layout (slot bases + tick spacing). A log from an unregistered + /// pool decodes to nothing. + pools: HashMap, +} +impl UniswapV3Decoder { + pub fn new() -> Self; + pub fn with_pool(mut self, pool: Address, layout: UniswapV3Layout) -> Self; +} +impl EventDecoder for UniswapV3Decoder { /* … */ } +``` + +`tick_spacing` is required for `Mint`/`Burn` bitmap maintenance: the tickBitmap +is keyed by the **compressed** tick `tick / tick_spacing`. A log from a pool not +in `pools` → `vec![]`. Match events by topic0 (`Swap`/`Mint`/`Burn` signature +hashes from `sol!`); decode with `SolEvent`. + +### 6.1 `Swap` → price + liquidity (stateless) + +`Swap(sender, recipient, amount0, amount1, sqrtPriceX96, liquidity, tick)`: +- **slot0** (`SlotMasked`, preserves observation/feeProtocol/`unlocked` bits): + - `mask = (U256::from(1) << 184) - 1` (low 184 bits = sqrtPriceX96 [0,160) + + tick [160,184)). + - `value = U256::from(sqrtPriceX96) | (tick_24bit << 160)` where `tick_24bit` + is the int24 two's-complement low-24-bits of `tick` + (`U256::from(tick as i32 as u32 & 0x00FF_FFFF)`). + - Emit `StateUpdate::slot_masked(pool, slot0_slot, mask, value)`. +- **liquidity** (absolute — the event carries the post-swap pool liquidity): + - `StateUpdate::slot(pool, liquidity_slot, U256::from(liquidity))`. + +The `unlocked` bit (bit 240) and observation/fee bits are **preserved** by the +mask — clobbering `unlocked` to 0 would make a subsequent quote/swap revert +`LOK`. This is the headline correctness reason for `SlotMasked`. Stateless +(ignores the view); a cold slot0 → `skipped_masks` (the pool must be seeded +first). + +### 6.2 `Mint` → tick + liquidity maintenance (stateful, reads `StateView`) + +`Mint(sender, owner, tickLower, tickUpper, amount, amount0, amount1)` adds +`amount` (uint128 liquidity) over `[tickLower, tickUpper)`. For **each** of +`tickLower` and `tickUpper`, and for the global liquidity, compute the post-state +from the current cached value (read via `view.storage`); emit an **absolute** +`Slot` write of the recomputed word (a packed word recomputed from known +pre-state is an absolute write, not a delta). If a needed word is **cold** +(`view.storage` → `None`), **skip that update and surface it** as a +`SkippedMask`/`SkippedDelta` (choose `SkippedDelta` with a zero-amount marker is +wrong — use a dedicated skip; see §6.5) so the caller knows the pool tick state +is incomplete (re-seed via `inject_v3_ticks`). + +Per tick (`tick` ∈ {`tickLower`, `tickUpper`}): +- **Tick slot +0** (`liquidityGross` [0,128) ‖ `liquidityNet` [128,256) signed): + - base = `v3_tick_info_storage_keys_with_base(tick, ticks_base_slot)[0]`. + - read current word; `gross = low128`, `net = high128 as i128`. + - `gross' = gross + amount` (uint128). + - `net' = net + amount` for `tickLower`, `net' = net - amount` for `tickUpper` + (int128). + - repacked = `U256::from(gross') | (i128_to_u256(net') << 128)`; emit + `Slot(pool, base, repacked)`. +- **Tick slot +3** (`initialized` flag, byte 31 / bit 248 — matching the + existing `inject_v3_ticks` placement): if `gross == 0 && gross' > 0` + (tick newly initialized), set `initialized` by emitting + `SlotMasked(pool, base+3, mask = U256::from(1) << 248, value = U256::from(1) << 248)`. + (No change if it was already initialized.) +- **tickBitmap**: when a tick is newly initialized, flip its bit: + - `compressed = tick / tick_spacing` (floor toward negative infinity — match + Solidity: `tick / tickSpacing` truncates toward zero, and V3 requires + `tick % tickSpacing == 0`, so plain integer division is exact). + - `word_pos = (compressed >> 8) as i16`, `bit_pos = (compressed & 0xFF) as u8`. + - key = `v3_tick_bitmap_storage_key_with_base(word_pos, tick_bitmap_base_slot)`. + - emit `SlotMasked(pool, key, mask = U256::from(1) << bit_pos, value = U256::from(1) << bit_pos)` + (set the bit). On Burn that uninitialises the tick, clear it (value = 0). + +Global **liquidity** (slot `liquidity_slot`): the `Mint` event does **not** +carry the resulting pool liquidity, so it must be derived: read current `slot0` +→ extract `tick` (bits [160,184), sign-extended int24); if +`tickLower <= currentTick < tickUpper`, read current `liquidity` and emit +`Slot(pool, liquidity_slot, current + amount)`. If `slot0` or `liquidity` is +cold, skip+surface. (Safety net: the next `Swap` sets `liquidity` absolutely.) + +### 6.3 `Burn` → the inverse + +`Burn(owner, tickLower, tickUpper, amount, amount0, amount1)`: identical to +`Mint` with the signs inverted: +- `gross' = gross - amount` (uint128, saturating at 0 defensively). +- `net' = net - amount` for `tickLower`, `net' = net + amount` for `tickUpper`. +- If `gross > 0 && gross' == 0` (tick now uninitialised): clear the + `initialized` flag (`SlotMasked` slot+3 value 0) **and** clear the bitmap bit + (`SlotMasked` value 0). +- Global liquidity: `Slot(pool, liquidity_slot, current - amount)` if current + tick in `[tickLower, tickUpper)`. + +> A `Burn` removing all of a tick's liquidity but a same-block re-`Mint` is +> handled by the **log-by-log** apply order (§7.1): the second decode reads the +> first's applied effect through the view. + +### 6.4 Known limitation (document, do not fix) + +Event-derived tick maintenance does **not** set `feeGrowthOutside0/1X128` +(slots +1/+2), `secondsOutside`, or oracle observations — these are not +derivable from `Mint`/`Burn`/`Swap`. **Swap price/liquidity quoting is +unaffected** (the swap-amount math does not depend on `feeGrowthOutside`); fee +accounting and `collect` are not maintained. Record this as a `KNOWN_ISSUES.md` +entry with sampled `reconcile` + reorg `purge` as the backstop, in the project's +honest-freshness spirit. + +### 6.5 Cold-skip surfacing for stateful V3 updates + +When a V3 tick/liquidity update cannot be computed because a needed word is cold, +surface it so the gap is visible (never silently drop it). Reuse +`SkippedMask` for masked sub-word updates (bitmap/initialized) and, for the +absolute tick-word / liquidity writes that were skipped, push a `SkippedMask` +with `mask == U256::MAX` and `value == U256::ZERO` as the "could-not-compute" +marker, **or** (cleaner) add the skipped target to a dedicated field. **Locked +choice:** reuse `SkippedMask` with `mask == U256::MAX, value == 0` as the +cold-tick marker to avoid a fourth skip vector; document this convention on +`SkippedMask`. The pipeline's `BlockDigest.skipped` count (via +`StateDiff::skipped_len`) then includes them, and the caller re-seeds the pool. + +## 7. `EventPipeline` (generic core, `events/mod.rs`) + +```rust +pub struct EventPipeline { + registry: DecoderRegistry, + reorg: ReorgConfig, + touched: VecDeque<(u64, Vec
)>, // ring of per-block touched addrs + derived_slots: HashSet<(Address, U256)>, // event-derived slots (for reconcile sampling) +} + +#[derive(Clone, Debug)] +pub struct ReorgConfig { + /// How many recent blocks of touched-address history to retain for reorg + /// purge (the reorg horizon). Older entries are dropped. + pub depth: usize, + /// Purge scope used on reorg (default `AllStorage` — storage re-fetches but + /// the account header survives; `Account` for a full drop). + pub scope: PurgeScope, +} + +pub struct BlockDigest { + pub block: u64, + /// Merged diff of everything applied for the block (changes-only + skips). + pub applied: StateDiff, + /// Number of logs that decoded to at least one update. + pub decoded_logs: usize, + /// The (address, slot) set written this block (for freshness classification). + pub touched_slots: Vec<(Address, U256)>, +} + +pub struct ReconcileReport { + pub checked: usize, + /// Slots whose event-derived value disagreed with chain truth. Non-empty = + /// drift alarm. `verify_slots` has already injected the fresh values. + pub mismatched: Vec, +} + +impl EventPipeline { + pub fn new(registry: DecoderRegistry) -> Self; // default ReorgConfig + pub fn with_reorg_config(mut self, cfg: ReorgConfig) -> Self; + + /// Decode + apply a block's logs, **log-by-log in order**, recording touched + /// state for reorg tracking. Returns the per-block digest. + pub fn ingest_logs(&mut self, cache: &mut EvmCache, block: u64, logs: &[Log]) -> BlockDigest; + + /// Reorg to `new_head`: purge (per `ReorgConfig.scope`) every address + /// touched in a block **>** `new_head`, drop those ring entries, and return + /// the merged purge diff. The next read re-fetches from RPC. + pub fn reorg_to(&mut self, cache: &mut EvmCache, new_head: u64) -> StateDiff; + + /// Sampled reconciliation: re-read `slots` via `EvmCache::verify_slots` + /// (correct + alarm). Returns the mismatches; an empty `slots` or no fetcher + /// surfaces as appropriate (errors if no fetcher, mirroring `verify_slots`). + pub fn reconcile(&mut self, cache: &mut EvmCache, slots: &[(Address, U256)]) -> Result; + + /// All event-derived slots seen so far (sampling source for `reconcile`). + pub fn derived_slots(&self) -> impl Iterator + '_; +} +``` + +### 7.1 `ingest_logs` — decode + apply **log-by-log** + +For each log, in order: `let updates = registry.decode(log, &*cache); let diff = +cache.apply_updates(&updates); merge into the block diff`. Apply **immediately +per log** (not decode-all-then-apply-all) so a later log's decode sees the +effects of earlier logs in the same block through the `StateView` (e.g. two +overlapping `Mint`s, or a `Burn`+`Mint` pair). Record the touched addresses +(`diff.slots` + `diff.accounts` addresses + `diff.skipped*` targets' addresses) +into the ring under `block`, and the touched `(address, slot)` into +`derived_slots`. Trim the ring to `ReorgConfig.depth`. + +> `&*cache` is used as the `&dyn StateView` while `cache.apply_updates(&mut …)` +> needs `&mut` — sequence them (decode borrow ends before the apply borrow), do +> not hold both. Decode returns owned `Vec`, so there is no +> borrow overlap. + +### 7.2 `reorg_to` — purge-and-resync + +Collect every address in ring entries with `block > new_head`; dedupe; for each, +`apply_update(Purge { address, scope: cfg.scope })`; remove those ring entries +and their `derived_slots`. Merge the purge `StateDiff`s and return. (The caller +then re-ingests the canonical chain's logs for the reorged range, and/or the +next read lazily re-fetches.) + +### 7.3 `reconcile` — correct + alarm + +`let changed = cache.verify_slots(slots)?;` → `ReconcileReport { checked: +slots.len(), mismatched: changed }`. `verify_slots` already injected the fresh +chain values (correct); the returned set is the alarm. Document that a non-empty +`mismatched` means event-derived state had drifted and has now been corrected. + +### 7.4 `!Send` discipline + +All three methods take `&mut EvmCache` and are **synchronous** — they never +`.await`, so the `!Send` cache is never held across a yield. This is what makes +the core deterministically testable offline. + +### 7.5 `drive` — async convenience (thin, example-only) + +A generic `LogSource` (`async fn next_block(&mut self) -> Option<(u64, Vec, ReorgSignal)>`) +and an `async fn drive(pipeline, cache, source, hooks)` that loops: pull a block, +`reorg_to` if signalled, `ingest_logs`, invoke an optional per-block hook (where +the caller wires `FreshnessController::on_new_block` + classification). Runs on +the current task (holds the `!Send` cache across the *source* await only — the +source future is `Send`; the cache is untouched during the await). **Not** +unit-tested beyond a vec-backed `LogSource` smoke test in the example; the +synchronous core (§7.1–7.3) is the contract. + +## 8. Freshness wiring (behavior-preserving) + +No change to `FreshnessController` internals. The integration is demonstrated, +not hard-wired: `BlockDigest.touched_slots` lets a caller mark event-derived +slots `Pinned` or `ValidThrough(block + horizon)` in a `FreshnessRegistry` (so +the optimistic validator does not waste RPC re-verifying state the pipeline keeps +fresh), then call `controller.on_new_block(block)`. The example shows this +end-to-end. Document the recommended pattern (event-driven slots → `Pinned`, +reconciled periodically) on the `EventPipeline` type. + +## 9. Public re-exports (`src/lib.rs`) + +```rust +pub mod events; +pub use events::{ + BlockDigest, DecoderRegistry, EventDecoder, EventPipeline, ReconcileReport, + ReorgConfig, StateView, +}; +pub use events::erc20::Erc20TransferDecoder; +#[cfg(feature = "protocols")] +pub use events::uniswap_v3::{UniswapV3Decoder, UniswapV3Layout}; +// state_update additions: +pub use state_update::{SkippedMask /* + existing */}; +``` + +## 10. Tests (offline, no network) — the acceptance contract + +Authored **before** implementation. In-module unit tests where pure; integration +tests in new `tests/event_pipeline.rs` (reuse `tests/common` + the `mock` +harness pattern). All offline. + +**`state_update.rs` unit (pure):** +- `slot_masked_constructor_produces_variant`. +- `state_diff_merge_extends_skipped_masks_without_counting_it`. +- `slot_masked` serde JSON round-trip; `SkippedMask` round-trip. +- `has_skipped`/`skipped_len`/`is_fully_applied` include `skipped_masks`. + +**`tests/state_update.rs` (masked apply, mocked cache):** +- `slot_masked_sets_only_masked_bits` — seed slot = `0xFFFF…FF00` (overlay), + `SlotMasked{ mask: 0xFF, value: 0x42 }` → `0xFFFF…FF42`; other bits preserved; + `SlotChange{old,new}` recorded. +- `slot_masked_noop_when_masked_bits_already_equal` → empty diff. +- `slot_masked_cold_slot_is_skipped_and_surfaced` → `diff.skipped_masks == + [SkippedMask{..}]`, slot still cold, `has_skipped()`. +- `slot_masked_writes_through_both_layers` — overlay-resident slot → both layers. +- `slot_masked_full_mask_equals_absolute_on_hot_but_skips_cold`. + +**`events` unit / `tests/event_pipeline.rs` (decoders + pipeline):** + +*Decoder purity & registry:* +- `decoder_registry_dispatches_by_address` — a decoder registered for token A + fires only for A's logs; a global decoder fires for all; output concatenated + in order. +- `unknown_log_decodes_to_empty` — non-matching topic0 → `vec![]`. + +*ERC-20 (`Erc20TransferDecoder`):* +- `erc20_transfer_decodes_to_sub_and_add_deltas` — `Transfer(A,B,100)` → + `[SlotDelta::Sub(100) @ balanceSlot(A), SlotDelta::Add(100) @ balanceSlot(B)]` + at the configured mapping slot. +- `erc20_mint_skips_zero_from` / `erc20_burn_skips_zero_to` — only the non-zero + leg emitted. +- `erc20_uses_per_token_slot_override_else_default`. +- `erc20_ingest_updates_balance_and_conserves` — **end-to-end**: build the mock + cache, seed two holders' balance slots (overlay-resident, EVM-visible), + `ingest_logs` a `Transfer` log, assert both balances via `balance_of` + (real `SLOAD`) and that `from + to` is conserved; `digest.applied.slots` has 2 + entries. +- `erc20_cold_balance_transfer_is_skipped_and_surfaced` — unseeded `to` → + `digest.applied.skipped` non-empty; `has_skipped()`. + +*UniswapV3 (`protocols`, gated tests):* +- `v3_swap_sets_price_and_tick_preserving_unlocked` — seed slot0 with a known + packed word incl. `unlocked=1` (bit 240) and a nonzero observation index; + ingest a `Swap` with new sqrtPriceX96/tick; assert slot0's low-184 bits are the + new price/tick **and** bits 184+ (incl. `unlocked`) are unchanged. +- `v3_swap_sets_liquidity_absolute` — `liquidity` slot == event liquidity. +- `v3_swap_cold_slot0_is_skipped` — unseeded slot0 → `skipped_masks`. +- `v3_mint_increments_gross_and_net_signs` — seed tick slot+0 = 0; `Mint(amount)` + at `[lo,hi]` → lo word `gross=amount, net=+amount`; hi word + `gross=amount, net=-amount` (decode the packed words). +- `v3_mint_initializes_tick_and_flips_bitmap` — newly-init tick sets slot+3 + initialized bit and flips the correct bitmap word/bit (using `tick_spacing`). +- `v3_burn_decrements_and_uninitializes` — `Burn` returning gross to 0 clears the + initialized bit and the bitmap bit. +- `v3_mint_updates_global_liquidity_when_in_range` — seed slot0 tick within + `[lo,hi)` and a known `liquidity`; `Mint` → liquidity += amount; out-of-range → + liquidity unchanged. +- `v3_mint_cold_tick_word_is_skipped` — unseeded tick word → surfaced skip, no + write. +- `v3_same_block_burn_then_mint_sees_prior_apply` — two logs in one + `ingest_logs`; the `Mint` decode reads the `Burn`'s applied gross/net. + +*Pipeline (reorg + reconcile):* +- `ingest_records_touched_and_trims_ring_to_depth`. +- `reorg_to_purges_addresses_touched_after_head` — ingest blocks N, N+1, N+2 + touching distinct pools; `reorg_to(N)` purges only N+1/N+2 pools (assert their + storage re-reads cold / re-fetches; N's survives). +- `reorg_to_returns_merged_purge_diff` — `PurgeRecord`s for the purged set. +- `reconcile_reports_mismatch_and_corrects` — stub the batch fetcher so an + event-derived slot disagrees; `reconcile` returns it in `mismatched` and the + cache now holds the fresh value (assert via `cached_storage_value`). +- `reconcile_empty_when_event_state_matches_chain`. +- `reconcile_errs_without_fetcher`. + +**Existing suites stay green** — `tests/state_update.rs`, `tests/freshness.rs`, +`tests/snapshot_overlay.rs`, the `protocols` cache tests. + +## 11. Docs, example & benchmark + +- **Example** `examples/reactive_cache.rs` (offline, `examples/support`): + build a `from_backend`/mock cache; register an `Erc20TransferDecoder` and a + `UniswapV3Decoder` in a `DecoderRegistry`; `ingest_logs` a small vec of logs + (an ERC-20 `Transfer` + a V3 `Swap`) for a block; print the `BlockDigest`; + then demonstrate (a) a `reorg_to` purge, and (b) a `reconcile` drift alarm + against a stub fetcher; wire `FreshnessController::on_new_block` + + `registry.valid_through` on the touched slots. Add a README "Examples" row. +- **Benchmark** `benches/event_pipeline.rs` (offline): decode throughput + (ERC-20 `Transfer`, V3 `Swap`, V3 `Mint`); `ingest_logs` per-block apply across + log-batch sizes (1 → 1000); `reorg_to` purge cost across touched-set sizes. + Register `[[bench]]` in `Cargo.toml`; mirror `benches/state_update.rs`; add a + README "Benchmarks" row. +- **CHANGELOG** `### Added`: the event pipeline (`EventDecoder`/`StateView`/ + `DecoderRegistry`/`EventPipeline`), the ERC-20 + V3 adapters, and the + `SlotMasked` vocabulary + `StateDiff.skipped_masks` (note the additive + `StateDiff` field + new `StateUpdate` variant under the pre-1.0 break policy). +- **ROADMAP**: flip the Phase 4 row to **Done** with the landing branch, a + "Landed on …" paragraph mirroring Phases 2/3. +- **KNOWN_ISSUES**: the §6.4 V3 fee-growth/oracle limitation. +- Rustdoc on **every** public item; module `//!` docs on `events` (Pillar B.2 + framing, `!Send` discipline, the events→Phase-3-vocabulary flow), `events/erc20`, + `events/uniswap_v3`. At least one runnable doctest (a pure decoder on a + hand-built `Log`, or the `SlotMasked` masked-write shape). + +## 12. Decisions (LOCKED) + +Confirmed with the user on 2026-06-16 before the acceptance tests were authored. + +**Decision 1 — packed-slot updates → `StateUpdate::SlotMasked`.** Add the +cold-aware RMW masked-write variant (§4.1) so a pure decoder can express a +partial update to a packed word (V3 `slot0`) without clobbering the bits it does +not own (notably `unlocked`). The "absolute clobber" and "impure decoder" +alternatives were rejected. + +**Decision 2 — V3 adapter coverage → `Swap` **and** `Mint`/`Burn` (full +ticks).** The adapter maintains `slot0`/`liquidity` from `Swap` and per-tick +`liquidityGross`/`liquidityNet`/`initialized` + `tickBitmap` + global +`liquidity` from `Mint`/`Burn` (§6). Fee-growth/oracle state is out of scope +(§6.4). Mint/Burn tick maintenance is computed against the `StateView` +(Decision 1's pure-data model needs the pre-state read). + +**Decision 3 — reorg → purge-and-resync touched addresses.** Track touched +addresses per block in a depth-bounded ring; `reorg_to(n)` purges everything +touched after `n` so reads re-fetch (§7.2). `ValidThrough` is the freshness +lever. Per-slot value rollback rejected. + +**Decision 4 — reconciliation → sampled re-read, correct **and** alarm.** +Opt-in `reconcile` samples event-derived slots and re-reads via `verify_slots`: +the fresh chain value wins (auto-correct) **and** the drift is surfaced (§7.3). +Honest freshness, built in from day one. Alarm-only and defer rejected. + +## 13. Build order (commit per step, green each time) + +1. `state_update.rs` + `cache/mod.rs`: `SlotMasked` variant, `slot_masked`, + `SkippedMask`, `StateDiff.skipped_masks` (+ merge/has_skipped/skipped_len), + serde, the apply arm (reuse `write_slot_through` + cold-aware read), and the + §10 masked-apply tests. Re-exports. +2. `events/mod.rs`: `StateView` (+ `impl … for EvmCache`), `EventDecoder`, + `DecoderRegistry` + dispatch tests. +3. `events/erc20.rs`: `Erc20TransferDecoder` + decoder/ingest tests. +4. `events/uniswap_v3.rs` (`protocols`): `UniswapV3Decoder`/`UniswapV3Layout`, + `Swap`/`Mint`/`Burn` + the §10 V3 tests. +5. `EventPipeline` (`ingest_logs`/`reorg_to`/`reconcile`/`derived_slots`) + + `BlockDigest`/`ReconcileReport`/`ReorgConfig` + the pipeline tests; the async + `drive`/`LogSource` convenience. +6. Example + benchmark + README rows. +7. Docs (module `//!`, item rustdoc, doctest), CHANGELOG, ROADMAP → Done, + KNOWN_ISSUES. + +## 14. Final acceptance + +Both feature configs green (§0). All new + existing tests pass. The example runs +offline and prints a non-trivial `BlockDigest`, a reorg purge, and a reconcile +alarm. The benchmark builds and runs (`cargo bench --no-run`). The V3 adapter +preserves the `slot0` `unlocked`/observation bits under `Swap` and maintains +tick gross/net/initialized/bitmap/global-liquidity under `Mint`/`Burn`, all +cold-aware. Report: what landed per file, the public API added, the decoder/ +adapter behavior (with the §6.4 limitation called out), test coverage, and the +verification output. From c3efe40bafa720a29538e77c2bb54faa590845c8 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 16 Jun 2026 12:56:28 +0100 Subject: [PATCH 2/6] Phase 4: acceptance tests (red contract) for the event pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored before implementation, per the phased workflow — these define correctness for Pillar B.2 and will validate the deliverable: - tests/state_update.rs: SlotMasked cold-aware masked-write tests (sets only masked bits / no-op / cold skip+surface via skipped_masks / both-layer write-through / full-mask-vs-cold / serde round-trip). - tests/event_pipeline.rs: DecoderRegistry dispatch (address-scoped + global); ERC-20 Transfer -> Sub/Add SlotDeltas (mint/burn zero-address legs, per-token slot override, ingest conserves balances via real SLOAD, cold skip); pipeline reorg_to purge-and-resync + ReorgConfig scope; reconcile correct+alarm / match / no-fetcher error; UniswapV3 adapter (Swap preserves slot0 unlocked/observation bits + absolute liquidity + cold-skip; Mint gross/net signs + initialize + bitmap flip + in/out-of-range global liquidity; Burn uninitialize+clear via same-block sequencing; cold tick skip; unregistered pool no-op). Verified the alloy event-ABI plumbing (sol! Swap/Mint/Burn construction + encode_log_data + decode_log round-trip) in isolation before committing. Tests are RED until the Phase 4 surface lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/event_pipeline.rs | 821 ++++++++++++++++++++++++++++++++++++++++ tests/state_update.rs | 185 ++++++++- 2 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 tests/event_pipeline.rs diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs new file mode 100644 index 0000000..f60653b --- /dev/null +++ b/tests/event_pipeline.rs @@ -0,0 +1,821 @@ +//! Offline acceptance tests for the Phase 4 event pipeline (Pillar B.2). +//! +//! These are the **contract** the implementation must satisfy: the +//! `EventDecoder` / `StateView` traits, the `DecoderRegistry`, the ERC-20 +//! `Transfer` decoder, the UniswapV3 `Swap`/`Mint`/`Burn` adapter, and the +//! `EventPipeline` (`ingest_logs` / `reorg_to` / `reconcile`). Everything runs +//! fully offline (mocked provider, state injected directly, logs built in +//! memory), so no test reaches the network. +//! +//! Layering vocabulary mirrors `tests/state_update.rs`: +//! - **layer 1 / overlay** = the CacheDB overlay (`db_mut().cache.accounts`). +//! - **layer 2 / backend** = the BlockchainDb backend (`blockchain_db()`). + +mod common; + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_primitives::{Address, Bytes, Log, U256, keccak256}; +use anyhow::Result; + +use common::{install_mock_erc20, setup_cache, stub_fetcher}; +use evm_fork_cache::cache::EvmCache; +use evm_fork_cache::events::{ + DecoderRegistry, EventDecoder, EventPipeline, ReorgConfig, StateView, +}; +use evm_fork_cache::events::erc20::Erc20TransferDecoder; +use evm_fork_cache::{PurgeScope, SlotDelta, StateUpdate}; + +// --------------------------------------------------------------------------- +// Shared helpers. +// --------------------------------------------------------------------------- + +/// Hashed storage slot of a `mapping(address => uint256)` at `mapping_slot`. +fn mapping_slot(owner: Address, mapping_slot: u64) -> U256 { + use alloy_sol_types::SolValue; + let key = keccak256((owner, U256::from(mapping_slot)).abi_encode()); + U256::from_be_bytes(key.0) +} + +/// Value of a slot in the BlockchainDb backend (layer 2) only. +fn backend_slot(cache: &EvmCache, addr: Address, slot: U256) -> Option { + cache + .blockchain_db() + .storage() + .read() + .get(&addr) + .and_then(|s| s.get(&slot).copied()) +} + +/// A read-only [`StateView`] stub backed by a fixed map (for pure decoder unit +/// tests that do not need a real cache). +struct StubView(HashMap<(Address, U256), U256>); +impl StateView for StubView { + fn storage(&self, address: Address, slot: U256) -> Option { + self.0.get(&(address, slot)).copied() + } +} +fn empty_view() -> StubView { + StubView(HashMap::new()) +} + +/// A test-only decoder that emits a single absolute `Slot` write for every log +/// (used to exercise pipeline mechanics independent of any real protocol). +struct MarkDecoder { + slot: U256, + value: U256, +} +impl EventDecoder for MarkDecoder { + fn decode(&self, log: &Log, _view: &dyn StateView) -> Vec { + vec![StateUpdate::slot(log.address, self.slot, self.value)] + } +} + +/// A test-only decoder that fires only for logs whose first topic equals `topic`. +struct TaggedDecoder { + topic: alloy_primitives::B256, + slot: U256, + value: U256, +} +impl EventDecoder for TaggedDecoder { + fn decode(&self, log: &Log, _view: &dyn StateView) -> Vec { + if log.topics().first() == Some(&self.topic) { + vec![StateUpdate::slot(log.address, self.slot, self.value)] + } else { + vec![] + } + } +} + +/// Build a bare log at `address` with the given topics and empty data. +fn bare_log(address: Address, topics: Vec) -> Log { + Log::new_unchecked(address, topics, Bytes::new()) +} + +// =========================================================================== +// EventDecoder / DecoderRegistry — dispatch. +// =========================================================================== + +#[test] +fn registry_dispatches_address_scoped_then_global() { + let token_a = Address::repeat_byte(0x0a); + let token_b = Address::repeat_byte(0x0b); + + let mut registry = DecoderRegistry::new(); + // Address-scoped: only fires for token_a logs. + registry.register_for_address( + token_a, + Arc::new(MarkDecoder { + slot: U256::from(1), + value: U256::from(11), + }), + ); + // Global: fires for every log. + registry.register(Arc::new(MarkDecoder { + slot: U256::from(9), + value: U256::from(99), + })); + + let view = empty_view(); + + // A token_a log hits both the scoped decoder and the global one. + let updates_a = registry.decode(&bare_log(token_a, vec![]), &view); + assert_eq!(updates_a.len(), 2); + assert!(updates_a.contains(&StateUpdate::slot(token_a, U256::from(1), U256::from(11)))); + assert!(updates_a.contains(&StateUpdate::slot(token_a, U256::from(9), U256::from(99)))); + + // A token_b log hits only the global decoder. + let updates_b = registry.decode(&bare_log(token_b, vec![]), &view); + assert_eq!( + updates_b, + vec![StateUpdate::slot(token_b, U256::from(9), U256::from(99))] + ); +} + +#[test] +fn decoder_returns_empty_for_unrecognized_log() { + let topic = keccak256(b"SomethingElse()"); + let decoder = TaggedDecoder { + topic: keccak256(b"Wanted()"), + slot: U256::from(0), + value: U256::from(1), + }; + let view = empty_view(); + let log = bare_log(Address::repeat_byte(0x01), vec![topic]); + assert!(decoder.decode(&log, &view).is_empty()); +} + +// =========================================================================== +// ERC-20 Transfer decoder. +// =========================================================================== + +/// Build an ERC-20 `Transfer(from, to, value)` log. +fn transfer_log(token: Address, from: Address, to: Address, value: U256) -> Log { + let sig = keccak256(b"Transfer(address,address,uint256)"); + let topics = vec![ + sig, + from.into_word(), + to.into_word(), + ]; + Log::new_unchecked(token, topics, Bytes::copy_from_slice(&value.to_be_bytes::<32>())) +} + +#[test] +fn erc20_transfer_decodes_to_sub_and_add_deltas() { + let token = Address::repeat_byte(0x20); + let from = Address::repeat_byte(0x21); + let to = Address::repeat_byte(0x22); + + let decoder = Erc20TransferDecoder::new(U256::from(3)); // default balance slot 3 + let view = empty_view(); + let updates = decoder.decode(&transfer_log(token, from, to, U256::from(100)), &view); + + assert_eq!( + updates, + vec![ + StateUpdate::slot_delta(token, mapping_slot(from, 3), SlotDelta::Sub(U256::from(100))), + StateUpdate::slot_delta(token, mapping_slot(to, 3), SlotDelta::Add(U256::from(100))), + ] + ); +} + +#[test] +fn erc20_mint_skips_zero_from_and_burn_skips_zero_to() { + let token = Address::repeat_byte(0x23); + let holder = Address::repeat_byte(0x24); + let decoder = Erc20TransferDecoder::new(U256::from(3)); + let view = empty_view(); + + // Mint: from == ZERO → only the Add leg. + let mint = decoder.decode( + &transfer_log(token, Address::ZERO, holder, U256::from(7)), + &view, + ); + assert_eq!( + mint, + vec![StateUpdate::slot_delta( + token, + mapping_slot(holder, 3), + SlotDelta::Add(U256::from(7)) + )] + ); + + // Burn: to == ZERO → only the Sub leg. + let burn = decoder.decode( + &transfer_log(token, holder, Address::ZERO, U256::from(7)), + &view, + ); + assert_eq!( + burn, + vec![StateUpdate::slot_delta( + token, + mapping_slot(holder, 3), + SlotDelta::Sub(U256::from(7)) + )] + ); +} + +#[test] +fn erc20_uses_per_token_slot_override_else_default() { + let token_default = Address::repeat_byte(0x25); + let token_custom = Address::repeat_byte(0x26); + let holder = Address::repeat_byte(0x27); + + let decoder = Erc20TransferDecoder::new(U256::from(3)).with_token(token_custom, U256::from(9)); + let view = empty_view(); + + let d = decoder.decode( + &transfer_log(token_default, Address::ZERO, holder, U256::from(1)), + &view, + ); + assert_eq!(d[0], StateUpdate::slot_delta(token_default, mapping_slot(holder, 3), SlotDelta::Add(U256::from(1)))); + + let c = decoder.decode( + &transfer_log(token_custom, Address::ZERO, holder, U256::from(1)), + &view, + ); + assert_eq!(c[0], StateUpdate::slot_delta(token_custom, mapping_slot(holder, 9), SlotDelta::Add(U256::from(1)))); +} + +#[test] +fn erc20_non_transfer_log_decodes_to_empty() { + let decoder = Erc20TransferDecoder::new(U256::from(3)); + let view = empty_view(); + // Wrong topic0. + let log = bare_log(Address::repeat_byte(0x28), vec![keccak256(b"Approval(address,address,uint256)")]); + assert!(decoder.decode(&log, &view).is_empty()); +} + +#[tokio::test] +async fn erc20_ingest_updates_balances_and_conserves() -> Result<()> { + use common::{balance_of, install_default_account}; + + let token = Address::repeat_byte(0x2a); + let alice = Address::repeat_byte(0x2b); + let bob = Address::repeat_byte(0x2c); + + let mut cache = setup_cache().await?; + install_default_account(&mut cache, Address::ZERO); + install_default_account(&mut cache, alice); + install_default_account(&mut cache, bob); + install_mock_erc20(&mut cache, token); + + // Seed both holders' balance slots (overlay-resident, EVM-visible). Balance + // mapping is slot 3 in the MockERC20 fixture. + cache + .db_mut() + .insert_account_storage(token, mapping_slot(alice, 3), U256::from(1000))?; + cache + .db_mut() + .insert_account_storage(token, mapping_slot(bob, 3), U256::from(500))?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from(3)))); + let mut pipeline = EventPipeline::new(registry); + + // Alice transfers 200 to Bob. + let digest = pipeline.ingest_logs( + &mut cache, + 100, + &[transfer_log(token, alice, bob, U256::from(200))], + ); + + // Two slot changes applied; nothing skipped. + assert_eq!(digest.block, 100); + assert_eq!(digest.applied.slots.len(), 2); + assert!(!digest.applied.has_skipped()); + assert_eq!(digest.decoded_logs, 1); + + // Balances move by the delta — assert via real SLOAD (balanceOf). + assert_eq!(balance_of(&mut cache, token, alice)?, U256::from(800)); + assert_eq!(balance_of(&mut cache, token, bob)?, U256::from(700)); + // Conservation. + assert_eq!( + balance_of(&mut cache, token, alice)? + balance_of(&mut cache, token, bob)?, + U256::from(1500) + ); + Ok(()) +} + +#[tokio::test] +async fn erc20_cold_balance_transfer_is_skipped_and_surfaced() -> Result<()> { + // A normal forked account (NOT StorageCleared): an unseeded balance slot is + // cold, so the Sub/Add delta is skipped and surfaced. + let token = Address::repeat_byte(0x2d); + let alice = Address::repeat_byte(0x2e); + let bob = Address::repeat_byte(0x2f); + + let mut cache = setup_cache().await?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from(3)))); + let mut pipeline = EventPipeline::new(registry); + + let digest = pipeline.ingest_logs( + &mut cache, + 1, + &[transfer_log(token, alice, bob, U256::from(10))], + ); + + // Both legs cold → no slot changes, two surfaced skips. + assert!(digest.applied.slots.is_empty()); + assert!(digest.applied.has_skipped()); + assert_eq!(digest.applied.skipped.len(), 2); + Ok(()) +} + +// =========================================================================== +// EventPipeline — reorg + reconcile (decoder-agnostic mechanics). +// =========================================================================== + +#[tokio::test] +async fn ingest_records_touched_slots() -> Result<()> { + let token = Address::repeat_byte(0x30); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(MarkDecoder { + slot: U256::from(5), + value: U256::from(42), + })); + let mut pipeline = EventPipeline::new(registry); + + let digest = pipeline.ingest_logs(&mut cache, 10, &[bare_log(token, vec![])]); + assert!(digest.touched_slots.contains(&(token, U256::from(5)))); + assert_eq!(cache.cached_storage_value(token, U256::from(5)), Some(U256::from(42))); + Ok(()) +} + +#[tokio::test] +async fn reorg_to_purges_addresses_touched_after_head() -> Result<()> { + let token_a = Address::repeat_byte(0x31); // block 10 (survives) + let token_b = Address::repeat_byte(0x32); // block 11 (purged) + let token_c = Address::repeat_byte(0x33); // block 12 (purged) + let slot = U256::from(0); + + let mut cache = setup_cache().await?; + for t in [token_a, token_b, token_c] { + install_mock_erc20(&mut cache, t); + } + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(MarkDecoder { + slot, + value: U256::from(77), + })); + let mut pipeline = EventPipeline::new(registry); + + pipeline.ingest_logs(&mut cache, 10, &[bare_log(token_a, vec![])]); + pipeline.ingest_logs(&mut cache, 11, &[bare_log(token_b, vec![])]); + pipeline.ingest_logs(&mut cache, 12, &[bare_log(token_c, vec![])]); + + // Everything written. + assert_eq!(backend_slot(&cache, token_a, slot), Some(U256::from(77))); + assert_eq!(backend_slot(&cache, token_b, slot), Some(U256::from(77))); + assert_eq!(backend_slot(&cache, token_c, slot), Some(U256::from(77))); + + // Reorg back to block 10: purge B and C (touched after 10), keep A. + let diff = pipeline.reorg_to(&mut cache, 10); + + assert_eq!(backend_slot(&cache, token_a, slot), Some(U256::from(77)), "A untouched"); + assert_eq!(backend_slot(&cache, token_b, slot), None, "B storage purged"); + assert_eq!(backend_slot(&cache, token_c, slot), None, "C storage purged"); + + // The returned diff records the purges (B and C only). + let purged: Vec
= diff.purged.iter().map(|r| r.address).collect(); + assert!(purged.contains(&token_b) && purged.contains(&token_c)); + assert!(!purged.contains(&token_a)); + Ok(()) +} + +#[tokio::test] +async fn reorg_config_account_scope_fully_drops_account() -> Result<()> { + let token = Address::repeat_byte(0x34); + let slot = U256::from(0); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(MarkDecoder { + slot, + value: U256::from(5), + })); + let mut pipeline = EventPipeline::new(registry).with_reorg_config(ReorgConfig { + depth: 64, + scope: PurgeScope::Account, + }); + + pipeline.ingest_logs(&mut cache, 20, &[bare_log(token, vec![])]); + let diff = pipeline.reorg_to(&mut cache, 19); + + assert!(diff.purged.iter().any(|r| r.address == token && r.account_removed)); + Ok(()) +} + +#[tokio::test] +async fn reconcile_reports_mismatch_and_corrects() -> Result<()> { + let token = Address::repeat_byte(0x35); + let slot = U256::from(0); + + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + + // Event pipeline writes an (incorrect) value 50. + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(MarkDecoder { + slot, + value: U256::from(50), + })); + let mut pipeline = EventPipeline::new(registry); + pipeline.ingest_logs(&mut cache, 1, &[bare_log(token, vec![])]); + assert_eq!(cache.cached_storage_value(token, slot), Some(U256::from(50))); + + // Chain truth is 100. Reconcile must surface the drift AND correct the cache. + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([((token, slot), U256::from(100))]))); + let report = pipeline.reconcile(&mut cache, &[(token, slot)])?; + + assert_eq!(report.checked, 1); + assert_eq!(report.mismatched.len(), 1); + assert_eq!(report.mismatched[0].old, U256::from(50)); + assert_eq!(report.mismatched[0].new, U256::from(100)); + // Cache corrected to chain truth. + assert_eq!(cache.cached_storage_value(token, slot), Some(U256::from(100))); + Ok(()) +} + +#[tokio::test] +async fn reconcile_empty_when_event_state_matches_chain() -> Result<()> { + let token = Address::repeat_byte(0x36); + let slot = U256::from(0); + + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(MarkDecoder { + slot, + value: U256::from(100), + })); + let mut pipeline = EventPipeline::new(registry); + pipeline.ingest_logs(&mut cache, 1, &[bare_log(token, vec![])]); + + // Chain agrees (100). + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([((token, slot), U256::from(100))]))); + let report = pipeline.reconcile(&mut cache, &[(token, slot)])?; + assert!(report.mismatched.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn reconcile_errors_without_fetcher() -> Result<()> { + let mut cache = setup_cache().await?; + let registry = DecoderRegistry::new(); + let mut pipeline = EventPipeline::new(registry); + let token = Address::repeat_byte(0x37); + assert!(pipeline.reconcile(&mut cache, &[(token, U256::from(0))]).is_err()); + Ok(()) +} + +// =========================================================================== +// UniswapV3 adapter (protocols-gated). +// =========================================================================== + +#[cfg(feature = "protocols")] +mod uniswap_v3 { + use super::*; + use alloy_primitives::aliases::{I24, U160}; + use alloy_primitives::I256; + use alloy_sol_types::{SolEvent, sol}; + use evm_fork_cache::cache::{ + V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, V3_TICKS_BASE_SLOT, V3_TICK_BITMAP_BASE_SLOT, + v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, + }; + use evm_fork_cache::events::uniswap_v3::{UniswapV3Decoder, UniswapV3Layout}; + + sol! { + event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); + event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); + event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); + } + + fn swap_log(pool: Address, sqrt_price: u128, liquidity: u128, tick: i32) -> Log { + let ev = Swap { + sender: Address::repeat_byte(0x01), + recipient: Address::repeat_byte(0x02), + amount0: I256::try_from(-1i64).unwrap(), + amount1: I256::try_from(1i64).unwrap(), + sqrtPriceX96: U160::from(sqrt_price), + liquidity, + tick: I24::try_from(tick).unwrap(), + }; + Log { + address: pool, + data: ev.encode_log_data(), + } + } + + fn mint_log(pool: Address, tick_lower: i32, tick_upper: i32, amount: u128) -> Log { + let ev = Mint { + sender: Address::repeat_byte(0x03), + owner: Address::repeat_byte(0x04), + tickLower: I24::try_from(tick_lower).unwrap(), + tickUpper: I24::try_from(tick_upper).unwrap(), + amount, + amount0: U256::from(1), + amount1: U256::from(1), + }; + Log { + address: pool, + data: ev.encode_log_data(), + } + } + + fn burn_log(pool: Address, tick_lower: i32, tick_upper: i32, amount: u128) -> Log { + let ev = Burn { + owner: Address::repeat_byte(0x04), + tickLower: I24::try_from(tick_lower).unwrap(), + tickUpper: I24::try_from(tick_upper).unwrap(), + amount, + amount0: U256::from(1), + amount1: U256::from(1), + }; + Log { + address: pool, + data: ev.encode_log_data(), + } + } + + /// Pack a slot0 word: sqrtPriceX96 [0,160), tick [160,184) (int24), and an + /// arbitrary `high` block of preserved bits at [184,256). + fn pack_slot0(sqrt_price: u128, tick: i32, high: U256) -> U256 { + let tick24 = U256::from((tick as u32) & 0x00FF_FFFF); + U256::from(sqrt_price) | (tick24 << 160) | (high << 184) + } + + fn tick_word(gross: u128, net: i128) -> U256 { + U256::from(gross) | (U256::from(net as u128) << 128) + } + fn unpack_tick_word(w: U256) -> (u128, i128) { + let gross = u128::try_from(w & U256::from(u128::MAX)).unwrap(); + let net = u128::try_from((w >> 128) & U256::from(u128::MAX)).unwrap() as i128; + (gross, net) + } + + fn pool_with_decoder(tick_spacing: i32) -> (Address, UniswapV3Decoder) { + let pool = Address::repeat_byte(0x40); + let decoder = UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(tick_spacing)); + (pool, decoder) + } + + #[tokio::test] + async fn v3_swap_sets_price_and_tick_preserving_unlocked() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); + + // Seed slot0 with old price/tick AND the unlocked bit (240) + a nonzero + // observation index (bits 184+). high = unlocked(bit 56 of high) | obs(=7). + let high = (U256::from(1) << 56) | U256::from(7); + let seeded = pack_slot0(1_000_000, 50, high); + cache.db_mut().insert_account_storage(pool, V3_SLOT0_SLOT, seeded)?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + pipeline.ingest_logs(&mut cache, 1, &[swap_log(pool, 2_000_000, 9999, 75)]); + + let result = cache.cached_storage_value(pool, V3_SLOT0_SLOT).unwrap(); + // Low 184 bits are the new price + tick. + let low_mask = (U256::from(1) << 184) - U256::from(1); + let expected_low = U256::from(2_000_000u64) | (U256::from(75u64) << 160); + assert_eq!(result & low_mask, expected_low, "price+tick updated"); + // High bits (incl. unlocked) preserved. + assert_eq!(result >> 184, high, "observation/unlocked bits preserved"); + Ok(()) + } + + #[tokio::test] + async fn v3_swap_sets_liquidity_absolute() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); + cache + .db_mut() + .insert_account_storage(pool, V3_SLOT0_SLOT, pack_slot0(1, 0, U256::from(1) << 56))?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + pipeline.ingest_logs(&mut cache, 1, &[swap_log(pool, 1, 123_456, 0)]); + assert_eq!( + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT), + Some(U256::from(123_456u64)) + ); + Ok(()) + } + + #[tokio::test] + async fn v3_swap_cold_slot0_is_skipped() -> Result<()> { + // Fresh pool with no account → slot0 is cold. + let pool = Address::repeat_byte(0x41); + let decoder = UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(1)); + let mut cache = setup_cache().await?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + let digest = pipeline.ingest_logs(&mut cache, 1, &[swap_log(pool, 5, 5, 5)]); + // slot0 masked write skipped (un-masked bits unknown). + assert!(digest.applied.has_skipped()); + assert_eq!(cache.cached_storage_value(pool, V3_SLOT0_SLOT), None); + Ok(()) + } + + #[tokio::test] + async fn v3_mint_increments_gross_and_net_with_correct_signs() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); // StorageCleared → unseeded ticks read 0 (hot) + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + let (lo, hi, amount) = (10i32, 20i32, 500u128); + pipeline.ingest_logs(&mut cache, 1, &[mint_log(pool, lo, hi, amount)]); + + let lo_key = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[0]; + let hi_key = v3_tick_info_storage_keys_with_base(hi, V3_TICKS_BASE_SLOT)[0]; + let (lo_gross, lo_net) = unpack_tick_word(cache.cached_storage_value(pool, lo_key).unwrap()); + let (hi_gross, hi_net) = unpack_tick_word(cache.cached_storage_value(pool, hi_key).unwrap()); + + assert_eq!(lo_gross, 500); + assert_eq!(lo_net, 500, "lower tick: net += amount"); + assert_eq!(hi_gross, 500); + assert_eq!(hi_net, -500, "upper tick: net -= amount"); + Ok(()) + } + + #[tokio::test] + async fn v3_mint_initializes_tick_and_flips_bitmap() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + let (lo, hi) = (10i32, 20i32); + pipeline.ingest_logs(&mut cache, 1, &[mint_log(pool, lo, hi, 500)]); + + // initialized flag (slot +3, bit 248) set for both ticks. + let lo3 = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[3]; + let hi3 = v3_tick_info_storage_keys_with_base(hi, V3_TICKS_BASE_SLOT)[3]; + let init_bit = U256::from(1) << 248; + assert_eq!(cache.cached_storage_value(pool, lo3).unwrap() & init_bit, init_bit); + assert_eq!(cache.cached_storage_value(pool, hi3).unwrap() & init_bit, init_bit); + + // bitmap word 0 (ticks 10 & 20 with tick_spacing 1 → word 0, bits 10 & 20). + let word_key = v3_tick_bitmap_storage_key_with_base(0, V3_TICK_BITMAP_BASE_SLOT); + let bitmap = cache.cached_storage_value(pool, word_key).unwrap(); + assert_eq!(bitmap & (U256::from(1) << 10), U256::from(1) << 10); + assert_eq!(bitmap & (U256::from(1) << 20), U256::from(1) << 20); + Ok(()) + } + + #[tokio::test] + async fn v3_burn_to_zero_uninitializes_and_clears_bitmap() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + let (lo, hi) = (10i32, 20i32); + // Same-block Mint then Burn of the full amount: the Burn decode sees the + // Mint's applied gross/net via the StateView, returning the tick to 0. + pipeline.ingest_logs( + &mut cache, + 1, + &[mint_log(pool, lo, hi, 500), burn_log(pool, lo, hi, 500)], + ); + + let lo_key = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[0]; + let (lo_gross, lo_net) = unpack_tick_word(cache.cached_storage_value(pool, lo_key).unwrap()); + assert_eq!(lo_gross, 0, "gross back to zero"); + assert_eq!(lo_net, 0, "net back to zero"); + + // initialized flag cleared and bitmap bit cleared. + let lo3 = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[3]; + let init_bit = U256::from(1) << 248; + assert_eq!(cache.cached_storage_value(pool, lo3).unwrap_or(U256::ZERO) & init_bit, U256::ZERO); + let word_key = v3_tick_bitmap_storage_key_with_base(0, V3_TICK_BITMAP_BASE_SLOT); + assert_eq!( + cache.cached_storage_value(pool, word_key).unwrap_or(U256::ZERO) & (U256::from(1) << 10), + U256::ZERO + ); + Ok(()) + } + + #[tokio::test] + async fn v3_mint_updates_global_liquidity_when_in_range() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); + + // Current tick 15 is within [10, 20); liquidity seeded to 1000. + cache.db_mut().insert_account_storage( + pool, + V3_SLOT0_SLOT, + pack_slot0(1_000_000, 15, U256::from(1) << 56), + )?; + cache + .db_mut() + .insert_account_storage(pool, V3_LIQUIDITY_SLOT, U256::from(1000))?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + pipeline.ingest_logs(&mut cache, 1, &[mint_log(pool, 10, 20, 500)]); + assert_eq!( + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT), + Some(U256::from(1500)), + "in-range mint adds to global liquidity" + ); + Ok(()) + } + + #[tokio::test] + async fn v3_mint_leaves_global_liquidity_when_out_of_range() -> Result<()> { + let (pool, decoder) = pool_with_decoder(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, pool); + + // Current tick 5 is BELOW [10, 20); liquidity must not change. + cache.db_mut().insert_account_storage( + pool, + V3_SLOT0_SLOT, + pack_slot0(1_000_000, 5, U256::from(1) << 56), + )?; + cache + .db_mut() + .insert_account_storage(pool, V3_LIQUIDITY_SLOT, U256::from(1000))?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + pipeline.ingest_logs(&mut cache, 1, &[mint_log(pool, 10, 20, 500)]); + assert_eq!( + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT), + Some(U256::from(1000)), + "out-of-range mint does not touch global liquidity" + ); + Ok(()) + } + + #[tokio::test] + async fn v3_mint_cold_tick_word_is_skipped() -> Result<()> { + // Fresh pool, no account → tick words are cold (None), so the tick + // maintenance is skipped and surfaced rather than computed against 0. + let pool = Address::repeat_byte(0x42); + let decoder = UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(1)); + let mut cache = setup_cache().await?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + let digest = pipeline.ingest_logs(&mut cache, 1, &[mint_log(pool, 10, 20, 500)]); + assert!(digest.applied.has_skipped(), "cold tick words surfaced as skips"); + let lo_key = v3_tick_info_storage_keys_with_base(10, V3_TICKS_BASE_SLOT)[0]; + assert_eq!(cache.cached_storage_value(pool, lo_key), None, "nothing written"); + Ok(()) + } + + #[tokio::test] + async fn v3_unregistered_pool_decodes_to_nothing() -> Result<()> { + let known = Address::repeat_byte(0x43); + let unknown = Address::repeat_byte(0x44); + let decoder = UniswapV3Decoder::new().with_pool(known, UniswapV3Layout::uniswap(1)); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, unknown); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(decoder)); + let mut pipeline = EventPipeline::new(registry); + + let digest = pipeline.ingest_logs(&mut cache, 1, &[swap_log(unknown, 5, 5, 5)]); + assert!(digest.applied.is_empty() && !digest.applied.has_skipped()); + assert_eq!(digest.decoded_logs, 0); + Ok(()) + } +} diff --git a/tests/state_update.rs b/tests/state_update.rs index 825cc31..3647b8e 100644 --- a/tests/state_update.rs +++ b/tests/state_update.rs @@ -22,8 +22,8 @@ use common::{ }; use evm_fork_cache::cache::EvmCache; use evm_fork_cache::{ - AccountPatch, PurgeScope, SkippedBalanceDelta, SkippedDelta, SlotChange, SlotDelta, StateDiff, - StateUpdate, + AccountPatch, PurgeScope, SkippedBalanceDelta, SkippedDelta, SkippedMask, SlotChange, SlotDelta, + StateDiff, StateUpdate, }; use revm::state::{AccountInfo, Bytecode}; @@ -1606,3 +1606,184 @@ async fn account_patch_normalizes_zero_code_hash_across_layers() -> Result<()> { ); Ok(()) } + +// --------------------------------------------------------------------------- +// Phase 4 — SlotMasked: cold-aware read-modify-write masked slot write. +// +// `new = (old & !mask) | (value & mask)`. Only the `mask` bits are touched; the +// rest of the packed word is preserved. Cold (slot absent from both layers) is +// skipped and surfaced in `diff.skipped_masks` (the un-masked bits are unknown). +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn slot_masked_sets_only_masked_bits() -> Result<()> { + let token = Address::repeat_byte(0x11); + let slot = U256::from(0); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + // Seed a packed word with the high bits set and the low byte clear. + let seeded = U256::MAX - U256::from(0xFF); // 0xFF..FF00 + cache.db_mut().insert_account_storage(token, slot, seeded)?; + + // Mask the low byte only, set it to 0x42. + let diff = cache.apply_update(&StateUpdate::slot_masked( + token, + slot, + U256::from(0xFF), + U256::from(0x42), + )); + + let expected = seeded | U256::from(0x42); // high bits preserved, low byte = 0x42 + assert_eq!(cache.cached_storage_value(token, slot), Some(expected)); + assert_eq!( + diff.slots, + vec![SlotChange { + address: token, + slot, + old: seeded, + new: expected, + }] + ); + assert!(diff.skipped_masks.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn slot_masked_noop_when_masked_bits_already_equal() -> Result<()> { + let token = Address::repeat_byte(0x12); + let slot = U256::from(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + cache + .db_mut() + .insert_account_storage(token, slot, U256::from(0x42))?; + + // The masked bits already equal the target → no change. + let diff = cache.apply_update(&StateUpdate::slot_masked( + token, + slot, + U256::from(0xFF), + U256::from(0x42), + )); + + assert!(diff.is_empty(), "masked write that changes nothing is a no-op"); + assert!(diff.skipped_masks.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn slot_masked_cold_slot_is_skipped_and_surfaced() -> Result<()> { + // Fresh address with no overlay account and no backend value: the slot is + // cold. A masked write cannot know the un-masked bits, so it is skipped. + let pool = Address::repeat_byte(0x13); + let slot = U256::from(0); + let mut cache = setup_cache().await?; + + let diff = cache.apply_update(&StateUpdate::slot_masked( + pool, + slot, + U256::from(0xFF), + U256::from(0x42), + )); + + assert!(diff.slots.is_empty()); + assert_eq!( + diff.skipped_masks, + vec![SkippedMask { + address: pool, + slot, + mask: U256::from(0xFF), + value: U256::from(0x42), + }] + ); + assert!(diff.has_skipped()); + assert!(!diff.is_fully_applied()); + assert_eq!(diff.skipped_len(), 1); + // Still cold — nothing was written. + assert_eq!(cache.cached_storage_value(pool, slot), None); + Ok(()) +} + +#[tokio::test] +async fn slot_masked_writes_through_both_layers() -> Result<()> { + let token = Address::repeat_byte(0x14); + let slot = U256::from(2); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + // Overlay-resident (hot) seed so an overlay account exists. + let seeded = U256::from(0xFF00); + cache.db_mut().insert_account_storage(token, slot, seeded)?; + + cache.apply_update(&StateUpdate::slot_masked( + token, + slot, + U256::from(0x00FF), + U256::from(0x0042), + )); + + let expected = U256::from(0xFF42); + assert_eq!( + overlay_slot(&mut cache, token, slot), + Some(expected), + "overlay (layer 1) updated" + ); + assert_eq!( + backend_slot(&cache, token, slot), + Some(expected), + "backend (layer 2) updated" + ); + Ok(()) +} + +#[tokio::test] +async fn slot_masked_full_mask_equals_absolute_on_hot_but_skips_cold() -> Result<()> { + // mask == U256::MAX behaves like an absolute write on a hot slot, but still + // skip-and-surfaces on a cold one (unlike StateUpdate::Slot). + let token = Address::repeat_byte(0x15); + let hot = U256::from(0); + let cold_addr = Address::repeat_byte(0x16); + let cold = U256::from(0); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + cache + .db_mut() + .insert_account_storage(token, hot, U256::from(7))?; + + let hot_diff = + cache.apply_update(&StateUpdate::slot_masked(token, hot, U256::MAX, U256::from(99))); + assert_eq!(cache.cached_storage_value(token, hot), Some(U256::from(99))); + assert_eq!(hot_diff.slots.len(), 1); + assert!(hot_diff.skipped_masks.is_empty()); + + let cold_diff = cache.apply_update(&StateUpdate::slot_masked( + cold_addr, + cold, + U256::MAX, + U256::from(99), + )); + assert!(cold_diff.slots.is_empty()); + assert_eq!(cold_diff.skipped_masks.len(), 1); + assert_eq!(cache.cached_storage_value(cold_addr, cold), None); + Ok(()) +} + +#[tokio::test] +async fn slot_masked_serde_round_trips() -> Result<()> { + let update = + StateUpdate::slot_masked(Address::repeat_byte(0x17), U256::from(5), U256::from(0xFF), U256::from(3)); + let json = serde_json::to_string(&update)?; + let back: StateUpdate = serde_json::from_str(&json)?; + assert_eq!(update, back); + + let mut diff = StateDiff::default(); + diff.skipped_masks.push(SkippedMask { + address: Address::repeat_byte(0x18), + slot: U256::from(1), + mask: U256::from(0xFF), + value: U256::from(2), + }); + let json = serde_json::to_string(&diff)?; + let back: StateDiff = serde_json::from_str(&json)?; + assert_eq!(diff, back); + Ok(()) +} From 52d956bb748dc0d0dc08f44d1b1effd7048d9003 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 16 Jun 2026 13:02:04 +0100 Subject: [PATCH 3/6] Phase 4 step 1: SlotMasked cold-aware masked-write vocabulary Add StateUpdate::SlotMasked (new(old & !mask) | (value & mask)), the slot_masked constructor, the SkippedMask leaf record, and the StateDiff.skipped_masks field (+ merge / has_skipped / skipped_len). Apply arm in cache/mod.rs mirrors the SlotDelta arm: cold-aware RMW via write_slot_through. Re-export SkippedMask. Makes the tests/state_update.rs SlotMasked block green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cache/mod.rs | 32 +++++++- src/lib.rs | 2 +- src/state_update.rs | 159 ++++++++++++++++++++++++++++++++++++++-- tests/event_pipeline.rs | 157 +++++++++++++++++++++++++++++---------- tests/state_update.rs | 25 +++++-- 5 files changed, 324 insertions(+), 51 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 1b4a458..b20f387 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -68,7 +68,7 @@ use crate::freshness::SlotChange; use crate::inspector::TransferInspector; use crate::state_update::{ AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedBalanceDelta, SkippedDelta, - SlotDelta, StateDiff, StateUpdate, + SkippedMask, SlotDelta, StateDiff, StateUpdate, }; use bytecode::BytecodeCache; @@ -1264,6 +1264,36 @@ impl EvmCache { delta: *delta, }), }, + StateUpdate::SlotMasked { + address, + slot, + mask, + value, + } => match self.cached_storage_value(*address, *slot) { + // Hot slot: overwrite only the masked bits, preserving the rest. + // Build the change from the value we already read (mirroring the + // `SlotDelta` arm; do not re-read through `apply_slot`). + Some(old) => { + let new = (old & !*mask) | (*value & *mask); + self.write_slot_through(*address, *slot, new); + if old != new { + diff.slots.push(SlotChange { + address: *address, + slot: *slot, + old, + new, + }); + } + } + // Cold slot: the un-masked bits are unknown, so the result cannot + // be computed; write nothing and surface the skip for re-seeding. + None => diff.skipped_masks.push(SkippedMask { + address: *address, + slot: *slot, + mask: *mask, + value: *value, + }), + }, StateUpdate::BalanceDelta { address, delta } => { match self.apply_balance_delta(*address, *delta) { // Hot account: the saturating delta was applied. diff --git a/src/lib.rs b/src/lib.rs index 7428463..bc20262 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,5 +116,5 @@ pub use freshness::{ }; pub use state_update::{ AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedBalanceDelta, SkippedDelta, - SlotDelta, StateDiff, StateUpdate, + SkippedMask, SlotDelta, StateDiff, StateUpdate, }; diff --git a/src/state_update.rs b/src/state_update.rs index b448107..165bff5 100644 --- a/src/state_update.rs +++ b/src/state_update.rs @@ -58,6 +58,20 @@ //! true value (the next read otherwise lazily fetches it). `modify_slot` hands its //! closure an `Option` (`None` when cold) and lets the caller decide. //! +//! # Masked writes to packed words +//! +//! A storage slot often packs several fields (a UniswapV3 `slot0` holds +//! `sqrtPriceX96`, `tick`, an observation index, and the `unlocked` flag in one +//! word). A decoder that learns only some of those fields must update *just* its +//! bits without clobbering the rest. [`StateUpdate::SlotMasked`] is the +//! cold-aware masked read-modify-write for exactly this: it computes +//! `new = (old & !mask) | (value & mask)`, touching only the `mask` bits. Like +//! [`SlotDelta`](StateUpdate::SlotDelta) it is cold-aware — the un-masked bits of +//! a slot absent from both layers are unknown, so a masked write to a cold slot +//! is **not** applied; it is surfaced in [`StateDiff::skipped_masks`] as a +//! [`SkippedMask`]. (A full-mask `SlotMasked` matches an absolute +//! [`Slot`](StateUpdate::Slot) write on a hot slot but still skips on a cold one.) +//! //! The same relative, cold-aware rule extends to an account's **native balance**: //! [`StateUpdate::BalanceDelta`] (and the closure form //! [`EvmCache::modify_account_balance`](crate::cache::EvmCache::modify_account_balance)) @@ -206,6 +220,32 @@ pub enum StateUpdate { /// The relative, saturating mutation to apply to the current balance. delta: SlotDelta, }, + /// Set only the `mask` bits of a storage slot to the corresponding bits of + /// `value`, preserving the rest: `new = (old & !mask) | (value & mask)`. + /// + /// A *masked* read-modify-write: it lets a pure decoder express a partial + /// update to a **packed** storage word (e.g. a UniswapV3 `slot0`, which packs + /// `sqrtPriceX96`, `tick`, the observation index, and the `unlocked` flag into + /// one slot) without knowing or clobbering the bits it does not own. + /// + /// **Cold-aware** — a masked write to a slot absent from *both* cache layers is + /// **not** applied (the un-masked bits are unknown, so the result cannot be + /// computed); it is surfaced in [`StateDiff::skipped_masks`] as a + /// [`SkippedMask`] instead. A masked write with `mask == U256::MAX` equals an + /// absolute [`Slot`](Self::Slot) write on a *hot* slot, but **still skips** on a + /// cold one (unlike [`Slot`](Self::Slot), which writes unconditionally). Use + /// [`Slot`](Self::Slot) for an unconditional absolute write; use `SlotMasked` + /// when neighbouring bits must be preserved. + SlotMasked { + /// Contract whose storage is written. + address: Address, + /// Storage slot key. + slot: U256, + /// Which bits of the slot to overwrite (1 = take from `value`). + mask: U256, + /// The bits to write (only the bits selected by `mask` are applied). + value: U256, + }, /// Patch an account's balance/nonce/code (partial — see [`AccountPatch`]). /// /// # Warning @@ -250,6 +290,17 @@ impl StateUpdate { } } + /// Construct a [`StateUpdate::SlotMasked`] that sets only the `mask` bits of + /// `(address, slot)` to the corresponding bits of `value`. + pub fn slot_masked(address: Address, slot: U256, mask: U256, value: U256) -> Self { + Self::SlotMasked { + address, + slot, + mask, + value, + } + } + /// Construct a [`StateUpdate::BalanceDelta`] that applies `delta` relative to /// the account's current native balance. pub fn balance_delta(address: Address, delta: SlotDelta) -> Self { @@ -428,6 +479,11 @@ pub struct StateDiff { /// was unknown). Like [`skipped`](Self::skipped) this is informational /// metadata, not a change. pub skipped_balances: Vec, + /// Masked slot updates ([`StateUpdate::SlotMasked`]) that were **not** applied + /// because the target slot's current value was unknown (cold) — the un-masked + /// bits could not be preserved. Like [`skipped`](Self::skipped) this is + /// informational metadata, not a change. + pub skipped_masks: Vec, } impl StateDiff { @@ -456,12 +512,15 @@ impl StateDiff { /// [`is_empty`](Self::is_empty) — callers applying relative updates should /// check this to avoid silently dropping a balance update. pub fn has_skipped(&self) -> bool { - !self.skipped.is_empty() || !self.skipped_balances.is_empty() + !self.skipped.is_empty() + || !self.skipped_balances.is_empty() + || !self.skipped_masks.is_empty() } - /// Total number of skipped relative updates (`skipped` + `skipped_balances`). + /// Total number of skipped relative/masked updates (`skipped` + + /// `skipped_balances` + `skipped_masks`). pub fn skipped_len(&self) -> usize { - self.skipped.len() + self.skipped_balances.len() + self.skipped.len() + self.skipped_balances.len() + self.skipped_masks.len() } /// Whether every relative update in the apply was applied (none skipped). @@ -475,14 +534,15 @@ impl StateDiff { /// /// Used by [`apply_updates`](crate::cache::EvmCache::apply_updates) to merge /// per-update diffs; the concatenation preserves order, so two writes to the - /// same slot record their `old → new` history in sequence. The `skipped` and - /// `skipped_balances` metadata are concatenated too. + /// same slot record their `old → new` history in sequence. The `skipped`, + /// `skipped_balances`, and `skipped_masks` metadata are concatenated too. pub fn merge(&mut self, other: StateDiff) { self.slots.extend(other.slots); self.accounts.extend(other.accounts); self.purged.extend(other.purged); self.skipped.extend(other.skipped); self.skipped_balances.extend(other.skipped_balances); + self.skipped_masks.extend(other.skipped_masks); } } @@ -549,6 +609,39 @@ pub struct SkippedBalanceDelta { pub delta: SlotDelta, } +/// A masked write ([`StateUpdate::SlotMasked`]) that could not be applied because +/// the target slot's current value is unknown (not cached in either layer). +/// +/// A masked write needs the slot's current value to preserve the un-masked bits +/// (`new = (old & !mask) | (value & mask)`); without it the result cannot be +/// computed, so the write is skipped rather than applied against an assumed value. +/// It is surfaced here so the caller can fetch+seed the slot and retry; otherwise +/// the next read lazily fetches the true value. +/// +/// # The `mask == U256::MAX, value == U256::ZERO` cold-tick convention +/// +/// The UniswapV3 adapter (`events::uniswap_v3`) reuses this record as a +/// "could-not-compute" marker for the *absolute* tick-word / global-liquidity +/// writes it must skip when a needed word is cold: it pushes a `SkippedMask` with +/// `mask == U256::MAX` and `value == U256::ZERO`. This avoids adding a fourth skip +/// vector for stateful protocol updates; the count flows through +/// [`StateDiff::skipped_len`] all the same, and the caller re-seeds the pool. +/// +/// Deliberately **not** `#[non_exhaustive]`: it is a stable, fully-determined leaf +/// record routinely constructed as a struct literal in equality assertions by the +/// test suite and downstream users testing against a returned diff. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SkippedMask { + /// Contract whose storage slot the masked write targeted. + pub address: Address, + /// Storage slot key that was cold. + pub slot: U256, + /// The mask that was not applied. + pub mask: U256, + /// The value bits that were not applied. + pub value: U256, +} + #[cfg(test)] mod tests { use super::*; @@ -704,4 +797,60 @@ mod tests { assert!(left.is_empty()); assert_eq!(left.len(), 0); } + + #[test] + fn slot_masked_constructor_produces_variant() { + let a = addr(0xee); + assert_eq!( + StateUpdate::slot_masked(a, U256::from(1), U256::from(0xFF), U256::from(0x42)), + StateUpdate::SlotMasked { + address: a, + slot: U256::from(1), + mask: U256::from(0xFF), + value: U256::from(0x42), + } + ); + } + + #[test] + fn state_diff_merge_extends_skipped_masks_without_counting_it() { + let a = addr(0xef); + let mut left = StateDiff::default(); + let mut right = StateDiff::default(); + right.skipped_masks.push(SkippedMask { + address: a, + slot: U256::from(1), + mask: U256::from(0xFF), + value: U256::from(0x42), + }); + + left.merge(right); + assert_eq!(left.skipped_masks.len(), 1); + // A masked skip is metadata, not a change. + assert!(left.is_empty()); + assert_eq!(left.len(), 0); + // But it is discoverable through the skip accessors. + assert!(left.has_skipped()); + assert_eq!(left.skipped_len(), 1); + assert!(!left.is_fully_applied()); + } + + #[test] + fn slot_masked_serde_round_trips() { + let a = addr(0xf0); + let update = StateUpdate::slot_masked(a, U256::from(5), U256::from(0xFF), U256::from(3)); + let json = serde_json::to_string(&update).expect("serialize"); + let back: StateUpdate = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(update, back); + + let mask = SkippedMask { + address: a, + slot: U256::from(1), + mask: U256::from(0xFF), + value: U256::from(2), + }; + let json = serde_json::to_string(&mask).expect("serialize"); + let back: SkippedMask = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(mask, back); + } } diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index f60653b..bef20d7 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -21,10 +21,10 @@ use anyhow::Result; use common::{install_mock_erc20, setup_cache, stub_fetcher}; use evm_fork_cache::cache::EvmCache; +use evm_fork_cache::events::erc20::Erc20TransferDecoder; use evm_fork_cache::events::{ DecoderRegistry, EventDecoder, EventPipeline, ReorgConfig, StateView, }; -use evm_fork_cache::events::erc20::Erc20TransferDecoder; use evm_fork_cache::{PurgeScope, SlotDelta, StateUpdate}; // --------------------------------------------------------------------------- @@ -153,12 +153,12 @@ fn decoder_returns_empty_for_unrecognized_log() { /// Build an ERC-20 `Transfer(from, to, value)` log. fn transfer_log(token: Address, from: Address, to: Address, value: U256) -> Log { let sig = keccak256(b"Transfer(address,address,uint256)"); - let topics = vec![ - sig, - from.into_word(), - to.into_word(), - ]; - Log::new_unchecked(token, topics, Bytes::copy_from_slice(&value.to_be_bytes::<32>())) + let topics = vec![sig, from.into_word(), to.into_word()]; + Log::new_unchecked( + token, + topics, + Bytes::copy_from_slice(&value.to_be_bytes::<32>()), + ) } #[test] @@ -174,7 +174,11 @@ fn erc20_transfer_decodes_to_sub_and_add_deltas() { assert_eq!( updates, vec![ - StateUpdate::slot_delta(token, mapping_slot(from, 3), SlotDelta::Sub(U256::from(100))), + StateUpdate::slot_delta( + token, + mapping_slot(from, 3), + SlotDelta::Sub(U256::from(100)) + ), StateUpdate::slot_delta(token, mapping_slot(to, 3), SlotDelta::Add(U256::from(100))), ] ); @@ -229,13 +233,27 @@ fn erc20_uses_per_token_slot_override_else_default() { &transfer_log(token_default, Address::ZERO, holder, U256::from(1)), &view, ); - assert_eq!(d[0], StateUpdate::slot_delta(token_default, mapping_slot(holder, 3), SlotDelta::Add(U256::from(1)))); + assert_eq!( + d[0], + StateUpdate::slot_delta( + token_default, + mapping_slot(holder, 3), + SlotDelta::Add(U256::from(1)) + ) + ); let c = decoder.decode( &transfer_log(token_custom, Address::ZERO, holder, U256::from(1)), &view, ); - assert_eq!(c[0], StateUpdate::slot_delta(token_custom, mapping_slot(holder, 9), SlotDelta::Add(U256::from(1)))); + assert_eq!( + c[0], + StateUpdate::slot_delta( + token_custom, + mapping_slot(holder, 9), + SlotDelta::Add(U256::from(1)) + ) + ); } #[test] @@ -243,7 +261,10 @@ fn erc20_non_transfer_log_decodes_to_empty() { let decoder = Erc20TransferDecoder::new(U256::from(3)); let view = empty_view(); // Wrong topic0. - let log = bare_log(Address::repeat_byte(0x28), vec![keccak256(b"Approval(address,address,uint256)")]); + let log = bare_log( + Address::repeat_byte(0x28), + vec![keccak256(b"Approval(address,address,uint256)")], + ); assert!(decoder.decode(&log, &view).is_empty()); } @@ -344,7 +365,10 @@ async fn ingest_records_touched_slots() -> Result<()> { let digest = pipeline.ingest_logs(&mut cache, 10, &[bare_log(token, vec![])]); assert!(digest.touched_slots.contains(&(token, U256::from(5)))); - assert_eq!(cache.cached_storage_value(token, U256::from(5)), Some(U256::from(42))); + assert_eq!( + cache.cached_storage_value(token, U256::from(5)), + Some(U256::from(42)) + ); Ok(()) } @@ -379,9 +403,21 @@ async fn reorg_to_purges_addresses_touched_after_head() -> Result<()> { // Reorg back to block 10: purge B and C (touched after 10), keep A. let diff = pipeline.reorg_to(&mut cache, 10); - assert_eq!(backend_slot(&cache, token_a, slot), Some(U256::from(77)), "A untouched"); - assert_eq!(backend_slot(&cache, token_b, slot), None, "B storage purged"); - assert_eq!(backend_slot(&cache, token_c, slot), None, "C storage purged"); + assert_eq!( + backend_slot(&cache, token_a, slot), + Some(U256::from(77)), + "A untouched" + ); + assert_eq!( + backend_slot(&cache, token_b, slot), + None, + "B storage purged" + ); + assert_eq!( + backend_slot(&cache, token_c, slot), + None, + "C storage purged" + ); // The returned diff records the purges (B and C only). let purged: Vec
= diff.purged.iter().map(|r| r.address).collect(); @@ -410,7 +446,11 @@ async fn reorg_config_account_scope_fully_drops_account() -> Result<()> { pipeline.ingest_logs(&mut cache, 20, &[bare_log(token, vec![])]); let diff = pipeline.reorg_to(&mut cache, 19); - assert!(diff.purged.iter().any(|r| r.address == token && r.account_removed)); + assert!( + diff.purged + .iter() + .any(|r| r.address == token && r.account_removed) + ); Ok(()) } @@ -430,10 +470,16 @@ async fn reconcile_reports_mismatch_and_corrects() -> Result<()> { })); let mut pipeline = EventPipeline::new(registry); pipeline.ingest_logs(&mut cache, 1, &[bare_log(token, vec![])]); - assert_eq!(cache.cached_storage_value(token, slot), Some(U256::from(50))); + assert_eq!( + cache.cached_storage_value(token, slot), + Some(U256::from(50)) + ); // Chain truth is 100. Reconcile must surface the drift AND correct the cache. - cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([((token, slot), U256::from(100))]))); + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([( + (token, slot), + U256::from(100), + )]))); let report = pipeline.reconcile(&mut cache, &[(token, slot)])?; assert_eq!(report.checked, 1); @@ -441,7 +487,10 @@ async fn reconcile_reports_mismatch_and_corrects() -> Result<()> { assert_eq!(report.mismatched[0].old, U256::from(50)); assert_eq!(report.mismatched[0].new, U256::from(100)); // Cache corrected to chain truth. - assert_eq!(cache.cached_storage_value(token, slot), Some(U256::from(100))); + assert_eq!( + cache.cached_storage_value(token, slot), + Some(U256::from(100)) + ); Ok(()) } @@ -461,7 +510,10 @@ async fn reconcile_empty_when_event_state_matches_chain() -> Result<()> { pipeline.ingest_logs(&mut cache, 1, &[bare_log(token, vec![])]); // Chain agrees (100). - cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([((token, slot), U256::from(100))]))); + cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([( + (token, slot), + U256::from(100), + )]))); let report = pipeline.reconcile(&mut cache, &[(token, slot)])?; assert!(report.mismatched.is_empty()); Ok(()) @@ -473,7 +525,11 @@ async fn reconcile_errors_without_fetcher() -> Result<()> { let registry = DecoderRegistry::new(); let mut pipeline = EventPipeline::new(registry); let token = Address::repeat_byte(0x37); - assert!(pipeline.reconcile(&mut cache, &[(token, U256::from(0))]).is_err()); + assert!( + pipeline + .reconcile(&mut cache, &[(token, U256::from(0))]) + .is_err() + ); Ok(()) } @@ -484,11 +540,11 @@ async fn reconcile_errors_without_fetcher() -> Result<()> { #[cfg(feature = "protocols")] mod uniswap_v3 { use super::*; - use alloy_primitives::aliases::{I24, U160}; use alloy_primitives::I256; + use alloy_primitives::aliases::{I24, U160}; use alloy_sol_types::{SolEvent, sol}; use evm_fork_cache::cache::{ - V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, V3_TICKS_BASE_SLOT, V3_TICK_BITMAP_BASE_SLOT, + V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, V3_TICK_BITMAP_BASE_SLOT, V3_TICKS_BASE_SLOT, v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, }; use evm_fork_cache::events::uniswap_v3::{UniswapV3Decoder, UniswapV3Layout}; @@ -564,7 +620,8 @@ mod uniswap_v3 { fn pool_with_decoder(tick_spacing: i32) -> (Address, UniswapV3Decoder) { let pool = Address::repeat_byte(0x40); - let decoder = UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(tick_spacing)); + let decoder = + UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(tick_spacing)); (pool, decoder) } @@ -578,7 +635,9 @@ mod uniswap_v3 { // observation index (bits 184+). high = unlocked(bit 56 of high) | obs(=7). let high = (U256::from(1) << 56) | U256::from(7); let seeded = pack_slot0(1_000_000, 50, high); - cache.db_mut().insert_account_storage(pool, V3_SLOT0_SLOT, seeded)?; + cache + .db_mut() + .insert_account_storage(pool, V3_SLOT0_SLOT, seeded)?; let mut registry = DecoderRegistry::new(); registry.register(Arc::new(decoder)); @@ -601,9 +660,11 @@ mod uniswap_v3 { let (pool, decoder) = pool_with_decoder(1); let mut cache = setup_cache().await?; install_mock_erc20(&mut cache, pool); - cache - .db_mut() - .insert_account_storage(pool, V3_SLOT0_SLOT, pack_slot0(1, 0, U256::from(1) << 56))?; + cache.db_mut().insert_account_storage( + pool, + V3_SLOT0_SLOT, + pack_slot0(1, 0, U256::from(1) << 56), + )?; let mut registry = DecoderRegistry::new(); registry.register(Arc::new(decoder)); @@ -650,8 +711,10 @@ mod uniswap_v3 { let lo_key = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[0]; let hi_key = v3_tick_info_storage_keys_with_base(hi, V3_TICKS_BASE_SLOT)[0]; - let (lo_gross, lo_net) = unpack_tick_word(cache.cached_storage_value(pool, lo_key).unwrap()); - let (hi_gross, hi_net) = unpack_tick_word(cache.cached_storage_value(pool, hi_key).unwrap()); + let (lo_gross, lo_net) = + unpack_tick_word(cache.cached_storage_value(pool, lo_key).unwrap()); + let (hi_gross, hi_net) = + unpack_tick_word(cache.cached_storage_value(pool, hi_key).unwrap()); assert_eq!(lo_gross, 500); assert_eq!(lo_net, 500, "lower tick: net += amount"); @@ -677,8 +740,14 @@ mod uniswap_v3 { let lo3 = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[3]; let hi3 = v3_tick_info_storage_keys_with_base(hi, V3_TICKS_BASE_SLOT)[3]; let init_bit = U256::from(1) << 248; - assert_eq!(cache.cached_storage_value(pool, lo3).unwrap() & init_bit, init_bit); - assert_eq!(cache.cached_storage_value(pool, hi3).unwrap() & init_bit, init_bit); + assert_eq!( + cache.cached_storage_value(pool, lo3).unwrap() & init_bit, + init_bit + ); + assert_eq!( + cache.cached_storage_value(pool, hi3).unwrap() & init_bit, + init_bit + ); // bitmap word 0 (ticks 10 & 20 with tick_spacing 1 → word 0, bits 10 & 20). let word_key = v3_tick_bitmap_storage_key_with_base(0, V3_TICK_BITMAP_BASE_SLOT); @@ -708,17 +777,24 @@ mod uniswap_v3 { ); let lo_key = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[0]; - let (lo_gross, lo_net) = unpack_tick_word(cache.cached_storage_value(pool, lo_key).unwrap()); + let (lo_gross, lo_net) = + unpack_tick_word(cache.cached_storage_value(pool, lo_key).unwrap()); assert_eq!(lo_gross, 0, "gross back to zero"); assert_eq!(lo_net, 0, "net back to zero"); // initialized flag cleared and bitmap bit cleared. let lo3 = v3_tick_info_storage_keys_with_base(lo, V3_TICKS_BASE_SLOT)[3]; let init_bit = U256::from(1) << 248; - assert_eq!(cache.cached_storage_value(pool, lo3).unwrap_or(U256::ZERO) & init_bit, U256::ZERO); + assert_eq!( + cache.cached_storage_value(pool, lo3).unwrap_or(U256::ZERO) & init_bit, + U256::ZERO + ); let word_key = v3_tick_bitmap_storage_key_with_base(0, V3_TICK_BITMAP_BASE_SLOT); assert_eq!( - cache.cached_storage_value(pool, word_key).unwrap_or(U256::ZERO) & (U256::from(1) << 10), + cache + .cached_storage_value(pool, word_key) + .unwrap_or(U256::ZERO) + & (U256::from(1) << 10), U256::ZERO ); Ok(()) @@ -795,9 +871,16 @@ mod uniswap_v3 { let mut pipeline = EventPipeline::new(registry); let digest = pipeline.ingest_logs(&mut cache, 1, &[mint_log(pool, 10, 20, 500)]); - assert!(digest.applied.has_skipped(), "cold tick words surfaced as skips"); + assert!( + digest.applied.has_skipped(), + "cold tick words surfaced as skips" + ); let lo_key = v3_tick_info_storage_keys_with_base(10, V3_TICKS_BASE_SLOT)[0]; - assert_eq!(cache.cached_storage_value(pool, lo_key), None, "nothing written"); + assert_eq!( + cache.cached_storage_value(pool, lo_key), + None, + "nothing written" + ); Ok(()) } diff --git a/tests/state_update.rs b/tests/state_update.rs index 3647b8e..2ee024e 100644 --- a/tests/state_update.rs +++ b/tests/state_update.rs @@ -22,8 +22,8 @@ use common::{ }; use evm_fork_cache::cache::EvmCache; use evm_fork_cache::{ - AccountPatch, PurgeScope, SkippedBalanceDelta, SkippedDelta, SkippedMask, SlotChange, SlotDelta, - StateDiff, StateUpdate, + AccountPatch, PurgeScope, SkippedBalanceDelta, SkippedDelta, SkippedMask, SlotChange, + SlotDelta, StateDiff, StateUpdate, }; use revm::state::{AccountInfo, Bytecode}; @@ -1666,7 +1666,10 @@ async fn slot_masked_noop_when_masked_bits_already_equal() -> Result<()> { U256::from(0x42), )); - assert!(diff.is_empty(), "masked write that changes nothing is a no-op"); + assert!( + diff.is_empty(), + "masked write that changes nothing is a no-op" + ); assert!(diff.skipped_masks.is_empty()); Ok(()) } @@ -1749,8 +1752,12 @@ async fn slot_masked_full_mask_equals_absolute_on_hot_but_skips_cold() -> Result .db_mut() .insert_account_storage(token, hot, U256::from(7))?; - let hot_diff = - cache.apply_update(&StateUpdate::slot_masked(token, hot, U256::MAX, U256::from(99))); + let hot_diff = cache.apply_update(&StateUpdate::slot_masked( + token, + hot, + U256::MAX, + U256::from(99), + )); assert_eq!(cache.cached_storage_value(token, hot), Some(U256::from(99))); assert_eq!(hot_diff.slots.len(), 1); assert!(hot_diff.skipped_masks.is_empty()); @@ -1769,8 +1776,12 @@ async fn slot_masked_full_mask_equals_absolute_on_hot_but_skips_cold() -> Result #[tokio::test] async fn slot_masked_serde_round_trips() -> Result<()> { - let update = - StateUpdate::slot_masked(Address::repeat_byte(0x17), U256::from(5), U256::from(0xFF), U256::from(3)); + let update = StateUpdate::slot_masked( + Address::repeat_byte(0x17), + U256::from(5), + U256::from(0xFF), + U256::from(3), + ); let json = serde_json::to_string(&update)?; let back: StateUpdate = serde_json::from_str(&json)?; assert_eq!(update, back); From 5f28db2d44193fbf9954e39343a1820a93ce8cfc Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 16 Jun 2026 13:22:49 +0100 Subject: [PATCH 4/6] Phase 4 steps 2-5: events module, decoders, pipeline - events/mod.rs (generic core): StateView, EventDecoder, DecoderRegistry, EventPipeline (ingest_logs log-by-log, reorg_to purge-and-resync, reconcile correct+alarm, derived_slots), BlockDigest, ReconcileReport, ReorgConfig, and the async LogSource/drive convenience. - events/erc20.rs (generic core): Erc20TransferDecoder -> Sub/Add balance SlotDeltas, skipping the zero-address leg; reuses parse_transfer. - events/uniswap_v3.rs (protocols): UniswapV3Decoder/UniswapV3Layout. Swap -> masked slot0 (preserves unlocked/observation bits) + absolute liquidity; Mint/Burn -> per-tick gross/net, initialized flag, tickBitmap bit, global liquidity (in-range), all cold-aware via StateView with a mask==MAX,value==0 could-not-compute marker. - cache/mod.rs: impl StateView for EvmCache; refactor verify_slots into verify_slots_inner (+fetched_ok count) and add reconcile_slots, which errors on a total fetch failure (honest-freshness) so the pipeline's reconcile surfaces an unverifiable re-read rather than a false all-clear. - lib.rs: pub mod events + re-exports. - tests/event_pipeline.rs: #[allow(dead_code)] on the unused tick_word test helper (no assertion/behaviour change) so clippy --all-targets -D warnings stays clean. All 24 event_pipeline tests + existing suites green on both feature configs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cache/mod.rs | 49 ++++- src/events/erc20.rs | 145 +++++++++++++ src/events/mod.rs | 439 +++++++++++++++++++++++++++++++++++++++ src/events/uniswap_v3.rs | 399 +++++++++++++++++++++++++++++++++++ src/lib.rs | 19 +- tests/event_pipeline.rs | 1 + 6 files changed, 1048 insertions(+), 4 deletions(-) create mode 100644 src/events/erc20.rs create mode 100644 src/events/mod.rs create mode 100644 src/events/uniswap_v3.rs diff --git a/src/cache/mod.rs b/src/cache/mod.rs index b20f387..7387e82 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1804,8 +1804,20 @@ impl EvmCache { /// none is available. This is the synchronous main-thread primitive; the /// background validator performs the equivalent comparison against a snapshot. pub fn verify_slots(&mut self, slots: &[(Address, U256)]) -> Result> { + Ok(self.verify_slots_inner(slots)?.0) + } + + /// Shared implementation for [`verify_slots`](Self::verify_slots) and the + /// pipeline's reconcile path. Returns `(changed, fetched_ok)` where + /// `fetched_ok` is the number of requested slots the fetcher returned a value + /// for (failed per-slot fetches are skipped, not errors). Errors only when no + /// batch fetcher is configured. + fn verify_slots_inner( + &mut self, + slots: &[(Address, U256)], + ) -> Result<(Vec, usize)> { if slots.is_empty() { - return Ok(Vec::new()); + return Ok((Vec::new(), 0)); } let fetcher = self .storage_batch_fetcher @@ -1824,6 +1836,7 @@ impl EvmCache { let mut changed = Vec::new(); let mut to_inject = Vec::new(); + let mut fetched_ok = 0usize; for (addr, slot, fetched) in results { let fresh = match fetched { Ok(value) => value, @@ -1832,6 +1845,7 @@ impl EvmCache { continue; } }; + fetched_ok += 1; // A slot the cache never saw is treated as old = ZERO (the value a // sim would have read), so a non-zero fresh value counts as a change. let old = cached @@ -1853,6 +1867,29 @@ impl EvmCache { if !to_inject.is_empty() { self.inject_storage_batch_fresh(&to_inject); } + Ok((changed, fetched_ok)) + } + + /// Reconciliation re-read used by [`EventPipeline::reconcile`](crate::events::EventPipeline::reconcile). + /// + /// Like [`verify_slots`](Self::verify_slots) it fetches the requested slots, + /// injects the ones that changed, and returns the changed set — but it is + /// **honest about reachability**: it errors not only when no batch fetcher is + /// configured, but also when a non-empty request could not fetch **any** slot + /// (a total fetch failure — e.g. the default RPC fetcher invoked with no usable + /// runtime, or an unreachable provider). Reconciliation that silently "verified + /// nothing" would be a false all-clear, so it surfaces as an error for the + /// caller to retry. A partially-successful fetch returns `Ok` with whatever + /// changed. + pub fn reconcile_slots(&mut self, slots: &[(Address, U256)]) -> Result> { + let (changed, fetched_ok) = self.verify_slots_inner(slots)?; + if !slots.is_empty() && fetched_ok == 0 { + return Err(anyhow!( + "reconcile could not fetch any of the {} requested slot(s) \ + (no usable storage fetcher / provider unreachable)", + slots.len() + )); + } Ok(changed) } @@ -3499,6 +3536,16 @@ impl EvmCache { } } +/// Read-only state view for the event pipeline (Pillar B.2): a decoder reads the +/// current cached value of a slot through [`cached_storage_value`](EvmCache::cached_storage_value), +/// which never touches RPC and is `account_state`-aware (a cold slot reads +/// `None`). +impl crate::events::StateView for EvmCache { + fn storage(&self, address: Address, slot: U256) -> Option { + self.cached_storage_value(address, slot) + } +} + impl EvmCache { /// Create a LocalContext that reuses the shared memory buffer. /// diff --git a/src/events/erc20.rs b/src/events/erc20.rs new file mode 100644 index 0000000..c9c467f --- /dev/null +++ b/src/events/erc20.rs @@ -0,0 +1,145 @@ +//! Generic ERC-20 `Transfer` decoder (generic core). +//! +//! [`Erc20TransferDecoder`] turns a standard ERC-20 +//! `Transfer(from, to, value)` log into two relative balance updates — a +//! [`SlotDelta::Sub`] on the sender's balance slot and a +//! [`SlotDelta::Add`] on the recipient's — so the cache +//! tracks balances from the event stream without ever reading the resulting +//! absolute balances. It is the log-driven form of the Phase 3 reactive-balance +//! case. +//! +//! # Balance-slot derivation +//! +//! An ERC-20 `balanceOf` is a `mapping(address => uint256)` at some base slot. +//! The decoder hashes the owner into that mapping the canonical Solidity way: +//! `keccak256(abi.encode(owner, balance_slot))`. The base slot is configurable +//! per token ([`with_token`](Erc20TransferDecoder::with_token)) with a default +//! fallback ([`new`](Erc20TransferDecoder::new)), since different tokens place +//! `balanceOf` at different slots. +//! +//! # Mint / burn legs +//! +//! A mint (`from == 0`) or burn (`to == 0`) has no real holder on the +//! zero-address leg, so that leg is **skipped** — only the non-zero side emits a +//! delta. Cold balances follow the Phase 3 contract: the +//! [`SlotDelta`] is skipped at apply time and surfaced in +//! [`StateDiff::skipped`](crate::StateDiff::skipped) (the caller seeds the +//! balance, or the next read lazily fetches it). The decoder ignores the +//! [`StateView`] — it is stateless. + +use std::collections::HashMap; + +use alloy_primitives::{Address, Log, U256, keccak256}; +use alloy_sol_types::SolValue; + +use crate::events::{EventDecoder, StateView}; +use crate::inspector::TransferInspector; +use crate::state_update::{SlotDelta, StateUpdate}; + +/// Decodes ERC-20 `Transfer` logs into relative balance [`SlotDelta`] updates. +/// +/// ``` +/// use alloy_primitives::{Address, Bytes, Log, U256, keccak256}; +/// use alloy_sol_types::SolValue; +/// use evm_fork_cache::events::{EventDecoder, StateView}; +/// use evm_fork_cache::events::erc20::Erc20TransferDecoder; +/// use evm_fork_cache::{SlotDelta, StateUpdate}; +/// +/// // A read-only view that reports every slot cold (decoder is stateless anyway). +/// struct ColdView; +/// impl StateView for ColdView { +/// fn storage(&self, _: Address, _: U256) -> Option { None } +/// } +/// +/// let token = Address::repeat_byte(0x20); +/// let from = Address::repeat_byte(0x21); +/// let to = Address::repeat_byte(0x22); +/// +/// // Transfer(from, to, 100) log: balanceOf mapping at slot 3. +/// let sig = keccak256(b"Transfer(address,address,uint256)"); +/// let log = Log::new_unchecked( +/// token, +/// vec![sig, from.into_word(), to.into_word()], +/// Bytes::copy_from_slice(&U256::from(100).to_be_bytes::<32>()), +/// ); +/// +/// let decoder = Erc20TransferDecoder::new(U256::from(3)); +/// let updates = decoder.decode(&log, &ColdView); +/// +/// let slot = |owner: Address| { +/// U256::from_be_bytes(keccak256((owner, U256::from(3)).abi_encode()).0) +/// }; +/// assert_eq!(updates, vec![ +/// StateUpdate::slot_delta(token, slot(from), SlotDelta::Sub(U256::from(100))), +/// StateUpdate::slot_delta(token, slot(to), SlotDelta::Add(U256::from(100))), +/// ]); +/// ``` +pub struct Erc20TransferDecoder { + /// Balance mapping base slot per token (the `balanceOf` mapping's slot). + balance_slots: HashMap, + /// Fallback balance mapping base slot for tokens not in the map. + default_balance_slot: U256, +} + +impl Erc20TransferDecoder { + /// Create a decoder with `default_balance_slot` as the `balanceOf` mapping + /// base slot for any token without a per-token override. + pub fn new(default_balance_slot: U256) -> Self { + Self { + balance_slots: HashMap::new(), + default_balance_slot, + } + } + + /// Override the `balanceOf` mapping base slot for `token` (builder style). + pub fn with_token(mut self, token: Address, balance_slot: U256) -> Self { + self.balance_slots.insert(token, balance_slot); + self + } + + /// The configured balance mapping base slot for `token` (its override, else + /// the default). + fn balance_slot(&self, token: Address) -> U256 { + self.balance_slots + .get(&token) + .copied() + .unwrap_or(self.default_balance_slot) + } +} + +/// The hashed storage slot of `balanceOf[owner]` for a `mapping(address => +/// uint256)` at `mapping_slot`. +fn balance_key(owner: Address, mapping_slot: U256) -> U256 { + U256::from_be_bytes(keccak256((owner, mapping_slot).abi_encode()).0) +} + +impl EventDecoder for Erc20TransferDecoder { + fn decode(&self, log: &Log, _view: &dyn StateView) -> Vec { + // Reuse the canonical ERC-20 Transfer signature match + topic/data decode. + // Returns None for a non-Transfer log (wrong topic0, <3 topics, <32 data + // bytes). + let Some(transfer) = TransferInspector::parse_transfer(log) else { + return Vec::new(); + }; + + let slot = self.balance_slot(transfer.token); + let mut updates = Vec::with_capacity(2); + + // Skip the zero-address leg (mint = from == 0, burn = to == 0). + if transfer.from != Address::ZERO { + updates.push(StateUpdate::slot_delta( + transfer.token, + balance_key(transfer.from, slot), + SlotDelta::Sub(transfer.value), + )); + } + if transfer.to != Address::ZERO { + updates.push(StateUpdate::slot_delta( + transfer.token, + balance_key(transfer.to, slot), + SlotDelta::Add(transfer.value), + )); + } + updates + } +} diff --git a/src/events/mod.rs b/src/events/mod.rs new file mode 100644 index 0000000..510c558 --- /dev/null +++ b/src/events/mod.rs @@ -0,0 +1,439 @@ +//! Event → state pipeline (Pillar B.2 — the *reader half* of the event pipeline). +//! +//! Phase 3 ([`state_update`](crate::state_update)) built the *writer half*: the +//! generic [`StateUpdate`] vocabulary and the cold-aware +//! [`apply_updates`](crate::cache::EvmCache::apply_updates) that consumes it. This +//! module builds the *reader half*: it turns an on-chain [`Log`] into that same +//! vocabulary and drives it through the cache, keeping event-derived state +//! reactively fresh. +//! +//! # The flow +//! +//! ```text +//! Log ─▶ EventDecoder::decode(log, &StateView) ─▶ Vec +//! │ +//! apply_updates ▼ +//! EvmCache (+ StateDiff) +//! ``` +//! +//! A [`DecoderRegistry`] dispatches a log to the decoders registered for its +//! emitting address (plus any global decoders) and concatenates their output. An +//! [`EventPipeline`] orchestrates a block's logs: [`ingest_logs`] decodes and +//! applies them **log-by-log in order** (so a later log's decode observes the +//! effects of earlier ones through the [`StateView`]), [`reorg_to`] purges the +//! addresses touched after a new head, and [`reconcile`] re-reads sampled +//! event-derived slots against chain truth (correct **and** alarm). +//! +//! [`ingest_logs`]: EventPipeline::ingest_logs +//! [`reorg_to`]: EventPipeline::reorg_to +//! [`reconcile`]: EventPipeline::reconcile +//! +//! # Decoders are pure data functions +//! +//! [`EventDecoder::decode`] is a pure function of `(log, pre-state)`: it performs +//! no I/O and emits serializable, replayable [`StateUpdate`] data. Most updates +//! need no pre-state ([`SlotDelta`](crate::StateUpdate::SlotDelta) and +//! [`SlotMasked`](crate::StateUpdate::SlotMasked) are read-modify-write *at apply +//! time*), but stateful adapters — UniswapV3 tick maintenance must read the +//! current `liquidityGross`/`liquidityNet`/`tick`/bitmap to recompute a packed +//! word — read the narrow read-only [`StateView`]. The view never touches RPC; a +//! slot absent from the cache reads `None` (cold), and a decoder that cannot +//! compute against a cold word surfaces a skip rather than inventing a value. +//! +//! # `!Send` cache discipline +//! +//! [`EvmCache`] is `!Send` (it owns the mutable fork and +//! blocks on RPC internally). All of [`EventPipeline`]'s core methods +//! ([`ingest_logs`](EventPipeline::ingest_logs) / +//! [`reorg_to`](EventPipeline::reorg_to) / +//! [`reconcile`](EventPipeline::reconcile)) take `&mut EvmCache` and are +//! **synchronous** — they never `.await`, so the cache is never held across a +//! yield point. This is what makes the core deterministically testable offline. +//! The async [`drive`] convenience holds the cache only across the *log source* +//! await (the source future is `Send`; the cache is untouched during it). +//! +//! # Freshness wiring +//! +//! [`BlockDigest::touched_slots`] surfaces the `(address, slot)` set written for a +//! block so a caller can classify event-derived slots in a +//! [`FreshnessRegistry`](crate::freshness::FreshnessRegistry) — typically pin them +//! ([`Validity::Pinned`](crate::freshness::Validity::Pinned)) or mark them +//! [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough) so the +//! optimistic validator does not waste RPC re-verifying state the pipeline keeps +//! fresh — then call +//! [`FreshnessController::on_new_block`](crate::freshness::FreshnessController::on_new_block). +//! No controller internals change. Periodically call +//! [`reconcile`](EventPipeline::reconcile) to sample-check those slots against the +//! chain (honest freshness). + +pub mod erc20; +#[cfg(feature = "protocols")] +#[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] +pub mod uniswap_v3; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +use alloy_primitives::{Address, Log, U256}; +use anyhow::Result; + +use crate::cache::EvmCache; +use crate::freshness::SlotChange; +use crate::state_update::{PurgeScope, StateDiff, StateUpdate}; + +/// Read-only view of current cached state handed to a decoder. +/// +/// Decoders that compute post-state from pre-state (e.g. UniswapV3 tick +/// maintenance) read through this; stateless decoders (ERC-20 `Transfer`, V3 +/// `Swap`) ignore it. The view never touches RPC — a slot absent from the cache +/// reads `None`. +pub trait StateView { + /// Current cached value of `(address, slot)` (overlay ▸ backend ▸ `None`), + /// matching what the EVM would `SLOAD` (`account_state`-aware). `None` means + /// the slot is **cold** — neither cache layer has seen it. + fn storage(&self, address: Address, slot: U256) -> Option; +} + +/// Decode one log into zero or more targeted [`StateUpdate`]s. +/// +/// `decode` is a pure function of `(log, pre-state)`: it performs no I/O and emits +/// data (the updates are serializable and replayable against matching pre-state). +/// The pipeline applies the result through +/// [`apply_updates`](crate::cache::EvmCache::apply_updates). +/// +/// A decoder returns `vec![]` for any log it does not recognise (wrong topic0, an +/// unregistered emitting address, a malformed payload). The pipeline counts a log +/// as *decoded* only when some decoder produced at least one update for it. +pub trait EventDecoder: Send + Sync { + /// Decode `log` against the read-only pre-state `view` into targeted updates. + fn decode(&self, log: &Log, view: &dyn StateView) -> Vec; +} + +/// Dispatches a log to the decoders registered for its emitting address (and any +/// global decoders) and concatenates their output. +/// +/// Dispatch is by emitting address ([`Log::address`]); topic0 filtering is each +/// decoder's own concern (a decoder returns `vec![]` for a log it does not +/// recognise). Address-scoped decoders are consulted first, then global ones, and +/// the per-decoder outputs are concatenated in that order. +#[derive(Default)] +pub struct DecoderRegistry { + /// Decoders consulted for every log, in registration order. + global: Vec>, + /// Decoders consulted only for logs emitted by a specific address. + per_address: HashMap>>, +} + +impl DecoderRegistry { + /// Create an empty registry with no decoders. + pub fn new() -> Self { + Self::default() + } + + /// Register a decoder consulted for **every** log. + pub fn register(&mut self, decoder: Arc) -> &mut Self { + self.global.push(decoder); + self + } + + /// Register a decoder consulted only for logs emitted by `address`. + pub fn register_for_address( + &mut self, + address: Address, + decoder: Arc, + ) -> &mut Self { + self.per_address.entry(address).or_default().push(decoder); + self + } + + /// Decode `log` through every applicable decoder, concatenating the results + /// (address-scoped decoders first, then global), preserving order. + pub fn decode(&self, log: &Log, view: &dyn StateView) -> Vec { + let mut out = Vec::new(); + if let Some(scoped) = self.per_address.get(&log.address) { + for decoder in scoped { + out.extend(decoder.decode(log, view)); + } + } + for decoder in &self.global { + out.extend(decoder.decode(log, view)); + } + out + } +} + +/// How a reorg purges the addresses touched after the new head. +/// +/// `depth` bounds the per-block touched-address history retained for reorg purge +/// (the reorg horizon); older entries are dropped as new blocks are ingested. +/// `scope` is the [`PurgeScope`] applied to each touched address on +/// [`reorg_to`](EventPipeline::reorg_to). +#[derive(Clone, Debug)] +pub struct ReorgConfig { + /// How many recent blocks of touched-address history to retain for reorg + /// purge (the reorg horizon). Older entries are dropped. + pub depth: usize, + /// Purge scope used on reorg. The default ([`PurgeScope::AllStorage`]) drops + /// storage so it re-fetches but keeps the account header; + /// [`PurgeScope::Account`] drops the whole account. + pub scope: PurgeScope, +} + +impl Default for ReorgConfig { + fn default() -> Self { + Self { + depth: 64, + scope: PurgeScope::AllStorage, + } + } +} + +/// Per-block result of [`EventPipeline::ingest_logs`]. +#[derive(Clone, Debug, Default)] +pub struct BlockDigest { + /// The block whose logs were ingested. + pub block: u64, + /// Merged diff of everything applied for the block (changes-only **and** + /// skips — check [`StateDiff::has_skipped`]). + pub applied: StateDiff, + /// Number of logs that decoded to at least one update. + pub decoded_logs: usize, + /// The `(address, slot)` set written this block (for freshness + /// classification — see the module docs). + pub touched_slots: Vec<(Address, U256)>, +} + +/// Result of [`EventPipeline::reconcile`]. +#[derive(Clone, Debug, Default)] +pub struct ReconcileReport { + /// How many slots were sampled. + pub checked: usize, + /// Slots whose event-derived value disagreed with chain truth. A non-empty + /// list is a **drift alarm**: the cache had drifted and + /// [`verify_slots`](crate::cache::EvmCache::verify_slots) has now injected the + /// fresh chain values (correct + alarm). + pub mismatched: Vec, +} + +/// Orchestrates decoding, applying, reorg handling, and reconciliation of a +/// block's logs against an [`EvmCache`]. +/// +/// Construct one from a [`DecoderRegistry`], then call +/// [`ingest_logs`](Self::ingest_logs) per block. See the [module docs](crate::events) +/// for the freshness-wiring pattern (event-derived slots → +/// [`Pinned`](crate::freshness::Validity::Pinned), reconciled periodically). +pub struct EventPipeline { + registry: DecoderRegistry, + reorg: ReorgConfig, + /// Ring of `(block, touched addresses)` for reorg purge, newest at the back, + /// bounded to `reorg.depth`. + touched: VecDeque<(u64, Vec
)>, + /// Every event-derived `(address, slot)` seen so far (reconcile sampling + /// source). + derived_slots: HashSet<(Address, U256)>, +} + +impl EventPipeline { + /// Create a pipeline over `registry` with the default [`ReorgConfig`]. + pub fn new(registry: DecoderRegistry) -> Self { + Self { + registry, + reorg: ReorgConfig::default(), + touched: VecDeque::new(), + derived_slots: HashSet::new(), + } + } + + /// Override the [`ReorgConfig`] (reorg horizon depth + purge scope). + pub fn with_reorg_config(mut self, cfg: ReorgConfig) -> Self { + self.reorg = cfg; + self + } + + /// Decode + apply a block's logs, **log-by-log in order**, recording touched + /// state for reorg tracking. Returns the per-block [`BlockDigest`]. + /// + /// Each log is decoded against the *current* cache state and applied + /// immediately, so a later log's decode observes the effects of earlier logs + /// in the same block through the [`StateView`] (e.g. a same-block `Burn` after + /// a `Mint`, or two overlapping `Mint`s). The touched addresses are recorded + /// in the depth-bounded reorg ring under `block`, and the touched + /// `(address, slot)` pairs into the reconcile-sampling set. + pub fn ingest_logs(&mut self, cache: &mut EvmCache, block: u64, logs: &[Log]) -> BlockDigest { + let mut digest = BlockDigest { + block, + ..Default::default() + }; + let mut touched_addrs: HashSet
= HashSet::new(); + + for log in logs { + // Decode against the current cache view (immutable borrow), then drop + // that borrow before taking the &mut borrow for apply. Decode returns + // owned data, so the two borrows never overlap. + let updates = self.registry.decode(log, &*cache); + if updates.is_empty() { + continue; + } + let diff = cache.apply_updates(&updates); + + // A log counts as decoded when it produced at least one update. + digest.decoded_logs += 1; + + // Record touched addresses (for reorg) and touched slots (for + // freshness + reconcile) from every category of the diff. + for change in &diff.slots { + touched_addrs.insert(change.address); + self.note_touched_slot(&mut digest, change.address, change.slot); + } + for change in &diff.accounts { + touched_addrs.insert(change.address); + } + for record in &diff.purged { + touched_addrs.insert(record.address); + } + for skip in &diff.skipped { + touched_addrs.insert(skip.address); + self.note_touched_slot(&mut digest, skip.address, skip.slot); + } + for skip in &diff.skipped_balances { + touched_addrs.insert(skip.address); + } + for skip in &diff.skipped_masks { + touched_addrs.insert(skip.address); + self.note_touched_slot(&mut digest, skip.address, skip.slot); + } + + digest.applied.merge(diff); + } + + if !touched_addrs.is_empty() { + self.touched + .push_back((block, touched_addrs.into_iter().collect())); + self.trim_ring(); + } + + digest + } + + /// Reorg to `new_head`: purge (per [`ReorgConfig::scope`]) every address + /// touched in a block **>** `new_head`, drop those ring entries, and return the + /// merged purge [`StateDiff`]. + /// + /// The next read of a purged address re-fetches from RPC. The caller then + /// re-ingests the canonical chain's logs for the reorged range (and/or the + /// next read lazily re-fetches). + pub fn reorg_to(&mut self, cache: &mut EvmCache, new_head: u64) -> StateDiff { + // Collect the addresses touched strictly after the new head, deduped. + let mut to_purge: HashSet
= HashSet::new(); + for (block, addrs) in &self.touched { + if *block > new_head { + to_purge.extend(addrs.iter().copied()); + } + } + + // Drop the rolled-back ring entries and the derived slots they own. + self.touched.retain(|(block, _)| *block <= new_head); + self.derived_slots + .retain(|(addr, _)| !to_purge.contains(addr)); + + let updates: Vec = to_purge + .into_iter() + .map(|addr| StateUpdate::purge(addr, self.reorg.scope.clone())) + .collect(); + cache.apply_updates(&updates) + } + + /// Sampled reconciliation: re-read `slots` via + /// [`EvmCache::verify_slots`](crate::cache::EvmCache::verify_slots) (correct + + /// alarm). Returns the mismatches. + /// + /// It fetches the fresh chain value for each slot, injects the ones that + /// changed (so the cache is **corrected**), and returns the changed set — a + /// non-empty [`ReconcileReport::mismatched`] is the **drift alarm**: + /// event-derived state had drifted and has now been corrected to chain truth. + /// Honest about reachability (via + /// [`EvmCache::reconcile_slots`](crate::cache::EvmCache::reconcile_slots)): it + /// errors when no batch fetcher is configured **or** when a non-empty request + /// could not fetch any slot (a total fetch failure is not a silent all-clear). + /// An empty `slots` is a no-op that returns an empty report. + pub fn reconcile( + &mut self, + cache: &mut EvmCache, + slots: &[(Address, U256)], + ) -> Result { + let mismatched = cache.reconcile_slots(slots)?; + Ok(ReconcileReport { + checked: slots.len(), + mismatched, + }) + } + + /// All event-derived slots seen so far (the sampling source for + /// [`reconcile`](Self::reconcile)). + pub fn derived_slots(&self) -> impl Iterator + '_ { + self.derived_slots.iter().copied() + } + + /// Record a touched slot in both the per-block digest (deduped within the + /// block) and the global all-time reconcile-sampling set. + fn note_touched_slot(&mut self, digest: &mut BlockDigest, address: Address, slot: U256) { + self.derived_slots.insert((address, slot)); + if !digest.touched_slots.contains(&(address, slot)) { + digest.touched_slots.push((address, slot)); + } + } + + /// Trim the reorg ring to the configured depth, dropping the oldest entries. + fn trim_ring(&mut self) { + while self.touched.len() > self.reorg.depth { + self.touched.pop_front(); + } + } +} + +/// A signalled reorg accompanying a block from a [`LogSource`]. +/// +/// `None` means the block extends the current head; `Some(new_head)` asks the +/// driver to [`reorg_to`](EventPipeline::reorg_to) `new_head` before ingesting. +pub type ReorgSignal = Option; + +/// An async source of blocks of logs for [`drive`]. +/// +/// This is the thin async convenience layer (§7.5): a production WS / +/// `subscribe_logs` adapter implements it; the offline example feeds a vec-backed +/// source. The synchronous [`EventPipeline`] core is the tested contract. +pub trait LogSource { + /// Yield the next block: its number, its logs, and an optional reorg signal. + /// `None` ends the stream. + fn next_block( + &mut self, + ) -> impl std::future::Future, ReorgSignal)>> + Send; +} + +/// Drive `pipeline` over `source`, ingesting each block (reorging first when +/// signalled) and invoking `on_block` after each ingest. +/// +/// A thin async convenience over the synchronous core: it pulls a block from the +/// `Send` source (the only `.await`), then synchronously +/// [`reorg_to`](EventPipeline::reorg_to) (if signalled) and +/// [`ingest_logs`](EventPipeline::ingest_logs), holding the `!Send` cache only +/// across the synchronous section. `on_block` is where a caller wires +/// [`FreshnessController::on_new_block`](crate::freshness::FreshnessController::on_new_block) +/// and freshness classification of the digest's touched slots. +pub async fn drive( + pipeline: &mut EventPipeline, + cache: &mut EvmCache, + mut source: S, + mut on_block: F, +) where + S: LogSource, + F: FnMut(&BlockDigest), +{ + while let Some((block, logs, reorg)) = source.next_block().await { + if let Some(new_head) = reorg { + pipeline.reorg_to(cache, new_head); + } + let digest = pipeline.ingest_logs(cache, block, &logs); + on_block(&digest); + } +} diff --git a/src/events/uniswap_v3.rs b/src/events/uniswap_v3.rs new file mode 100644 index 0000000..c93d667 --- /dev/null +++ b/src/events/uniswap_v3.rs @@ -0,0 +1,399 @@ +//! UniswapV3 / PancakeSwap V3 event adapter (`protocols` feature). +//! +//! [`UniswapV3Decoder`] turns a pool's `Swap` / `Mint` / `Burn` logs into the +//! Phase 3 [`StateUpdate`] vocabulary, maintaining the slots a +//! swap simulation reads: +//! +//! - **`Swap`** (stateless) → a masked `slot0` write (new `sqrtPriceX96` + `tick`, +//! **preserving** the observation index and the `unlocked` flag) plus an +//! absolute `liquidity` write (the event carries post-swap liquidity). +//! - **`Mint`/`Burn`** (stateful, reads the [`StateView`]) → per-tick +//! `liquidityGross` / `liquidityNet`, the `initialized` flag, the `tickBitmap` +//! word bit, and the global `liquidity` (conditional on the current tick). +//! +//! The decoder dispatches by emitting address: a log from a pool not registered +//! via [`with_pool`](UniswapV3Decoder::with_pool) decodes to nothing. It matches +//! events by topic0 (`Swap`/`Mint`/`Burn` signature hashes) and decodes with +//! [`SolEvent`]. +//! +//! # `slot0` bit layout (Uniswap / Pancake) +//! +//! `sqrtPriceX96` = bits [0,160), `tick` (int24) = bits [160,184), and the +//! observation index / cardinality / fee-protocol / **`unlocked`** flag occupy +//! bits [184,256). The `Swap` handler masks the low 184 bits, so the high bits — +//! crucially `unlocked` — survive. Clobbering `unlocked` to 0 would make a +//! subsequent quote/swap revert `LOK`; that is the headline reason `Swap` uses a +//! [`SlotMasked`](crate::StateUpdate::SlotMasked) rather than an absolute write. +//! +//! # Tick word packing +//! +//! Tick slot **+0** packs `liquidityGross` (uint128) = bits [0,128) and +//! `liquidityNet` (int128, two's-complement) = bits [128,256). `Mint` adds +//! `amount` to gross at both ticks and to net at the lower / from net at the +//! upper; `Burn` is the inverse. These are recomputed against the **current** +//! cached word read through the [`StateView`] and emitted as absolute `Slot` +//! writes. The `initialized` flag lives at tick slot **+3**, bit 248 (matching +//! `inject_v3_ticks`); the `tickBitmap` is keyed by the compressed tick +//! `tick / tick_spacing`. +//! +//! # Cold-aware +//! +//! When a needed word is cold ([`StateView::storage`] → `None`), the update is +//! **not** computed against an assumed value — it is skipped and surfaced. Masked +//! sub-word updates (bitmap / initialized) surface as their natural +//! [`SkippedMask`](crate::SkippedMask); the absolute tick-word / global-liquidity +//! writes that cannot be computed surface as a `SkippedMask` with +//! `mask == U256::MAX, value == U256::ZERO` (the "could-not-compute" cold marker — +//! see [`SkippedMask`](crate::SkippedMask)). A pool installed with +//! `StorageCleared` storage reads an unseeded slot as `Some(ZERO)` (hot zero), so +//! tick maintenance proceeds from zero; only a pool with no local account reads +//! cold. +//! +//! # Known limitation (§6.4) +//! +//! Event-derived tick maintenance does **not** reconstruct `feeGrowthOutside0/1X128` +//! (tick slots +1/+2), `secondsOutside`, or oracle observations — these are not +//! derivable from `Mint`/`Burn`/`Swap`. **Swap price/liquidity quoting is +//! unaffected** (the swap-amount math does not depend on `feeGrowthOutside`); fee +//! accounting and `collect` are not maintained. Sampled +//! [`reconcile`](crate::events::EventPipeline::reconcile) and reorg +//! [`reorg_to`](crate::events::EventPipeline::reorg_to) are the backstop. See +//! `KNOWN_ISSUES.md`. + +use std::collections::HashMap; + +use alloy_primitives::{Address, Log, U256}; +use alloy_sol_types::{SolEvent, sol}; + +use crate::cache::{ + PANCAKE_V3_LIQUIDITY_SLOT, PANCAKE_V3_TICK_BITMAP_BASE_SLOT, PANCAKE_V3_TICKS_BASE_SLOT, + V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, V3_TICK_BITMAP_BASE_SLOT, V3_TICKS_BASE_SLOT, + v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys_with_base, +}; +use crate::events::{EventDecoder, StateView}; +use crate::state_update::StateUpdate; + +sol! { + event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); + event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); + event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); +} + +/// Bit position where the `tick` field starts in a packed `slot0` word. +const SLOT0_TICK_SHIFT: usize = 160; +/// Number of low bits of `slot0` owned by `sqrtPriceX96` ‖ `tick` +/// (`[0,160)` + `[160,184)`); the bits above are preserved by the swap mask. +const SLOT0_PRICE_TICK_BITS: usize = 184; +/// Bit position of the `initialized` flag in tick slot +3. +const TICK_INITIALIZED_BIT: usize = 248; + +/// Per-pool V3 storage layout (slot bases + tick spacing). +/// +/// Uniswap V3 and PancakeSwap V3 share the `slot0` bit layout (only the base slot +/// numbers differ — Pancake's `uint32 feeProtocol` shifts subsequent slots by +1). +/// `tick_spacing` is required for `tickBitmap` word/bit math (the bitmap is keyed +/// by the compressed tick `tick / tick_spacing`). +#[derive(Clone, Debug)] +pub struct UniswapV3Layout { + /// Storage slot of `slot0` (packed price / tick / observation / unlocked). + pub slot0_slot: U256, + /// Storage slot of the global `liquidity`. + pub liquidity_slot: U256, + /// Base slot of the `ticks` mapping (`mapping(int24 => Tick.Info)`). + pub ticks_base_slot: U256, + /// Base slot of the `tickBitmap` mapping (`mapping(int16 => uint256)`). + pub tick_bitmap_base_slot: U256, + /// The pool's `tickSpacing` (for compressed-tick bitmap word/bit math). + pub tick_spacing: i32, +} + +impl UniswapV3Layout { + /// The canonical Uniswap V3 layout for a pool with the given `tick_spacing`. + pub fn uniswap(tick_spacing: i32) -> Self { + Self { + slot0_slot: V3_SLOT0_SLOT, + liquidity_slot: V3_LIQUIDITY_SLOT, + ticks_base_slot: V3_TICKS_BASE_SLOT, + tick_bitmap_base_slot: V3_TICK_BITMAP_BASE_SLOT, + tick_spacing, + } + } + + /// The PancakeSwap V3 layout for a pool with the given `tick_spacing` (slots + /// shifted +1 relative to Uniswap; `slot0` stays at slot 0). + pub fn pancake(tick_spacing: i32) -> Self { + Self { + slot0_slot: V3_SLOT0_SLOT, + liquidity_slot: PANCAKE_V3_LIQUIDITY_SLOT, + ticks_base_slot: PANCAKE_V3_TICKS_BASE_SLOT, + tick_bitmap_base_slot: PANCAKE_V3_TICK_BITMAP_BASE_SLOT, + tick_spacing, + } + } +} + +/// Decodes UniswapV3 / PancakeSwap V3 `Swap` / `Mint` / `Burn` logs into targeted +/// [`StateUpdate`]s. +/// +/// Register pools with [`with_pool`](Self::with_pool); a log from an unregistered +/// pool decodes to nothing. +#[derive(Default)] +pub struct UniswapV3Decoder { + /// Per-pool layout. A log from a pool not in this map decodes to nothing. + pools: HashMap, +} + +impl UniswapV3Decoder { + /// Create an empty decoder with no pools registered. + pub fn new() -> Self { + Self::default() + } + + /// Register `pool` with its storage `layout` (builder style). + pub fn with_pool(mut self, pool: Address, layout: UniswapV3Layout) -> Self { + self.pools.insert(pool, layout); + self + } +} + +/// The cold-tick "could-not-compute" marker: a [`StateUpdate::SlotMasked`] with +/// `mask == U256::MAX, value == U256::ZERO`, which `apply_updates` skip-surfaces +/// for a cold slot (see [`SkippedMask`](crate::SkippedMask)). +fn cold_marker(pool: Address, slot: U256) -> StateUpdate { + StateUpdate::slot_masked(pool, slot, U256::MAX, U256::ZERO) +} + +/// Unpack a tick slot +0 word: `(liquidityGross, liquidityNet)`. +fn unpack_tick_word(word: U256) -> (u128, i128) { + let gross = u128::try_from(word & U256::from(u128::MAX)).unwrap_or(0); + let net = u128::try_from((word >> 128) & U256::from(u128::MAX)).unwrap_or(0) as i128; + (gross, net) +} + +/// Pack `(liquidityGross, liquidityNet)` into a tick slot +0 word. +fn pack_tick_word(gross: u128, net: i128) -> U256 { + U256::from(gross) | (U256::from(net as u128) << 128) +} + +/// Convert a `sol!`-decoded int24 tick to `i32` (a tick always fits in i24 ⊂ i32). +fn tick_to_i32(tick: alloy_primitives::aliases::I24) -> i32 { + i128::try_from(tick).unwrap_or(0) as i32 +} + +/// The pre-state context a `Mint`/`Burn` maintenance pass reads against: the pool +/// address, its layout, and the read-only [`StateView`]. +struct LiquidityCtx<'a> { + pool: Address, + layout: &'a UniswapV3Layout, + view: &'a dyn StateView, +} + +impl LiquidityCtx<'_> { + /// Maintenance for one tick endpoint of a `Mint`/`Burn`. `is_burn` selects the + /// sign (mint adds, burn subtracts); `is_lower` selects the `liquidityNet` + /// sign convention (lower += / upper -= on a mint). Appends the recomputed + /// tick-word write plus any `initialized`/bitmap flips (or a cold marker) to + /// `out`. + fn maintain_tick( + &self, + tick: i32, + amount: u128, + is_burn: bool, + is_lower: bool, + out: &mut Vec, + ) { + let keys = v3_tick_info_storage_keys_with_base(tick, self.layout.ticks_base_slot); + let base = keys[0]; + let slot3 = keys[3]; + + // The current packed tick word. Cold → cannot recompute: surface a marker. + let Some(word) = self.view.storage(self.pool, base) else { + out.push(cold_marker(self.pool, base)); + return; + }; + let (gross, net) = unpack_tick_word(word); + + // gross is always +amount on mint, -amount on burn (saturating defensively). + let new_gross = if is_burn { + gross.saturating_sub(amount) + } else { + gross.saturating_add(amount) + }; + // net: lower += amount, upper -= amount on mint; inverse on burn. + let net_delta = amount as i128; + let signed_delta = match (is_burn, is_lower) { + (false, true) => net_delta, // mint lower: + + (false, false) => -net_delta, // mint upper: - + (true, true) => -net_delta, // burn lower: - + (true, false) => net_delta, // burn upper: + + }; + let new_net = net.wrapping_add(signed_delta); + + out.push(StateUpdate::slot( + self.pool, + base, + pack_tick_word(new_gross, new_net), + )); + + // initialized flag (+3, bit 248) + bitmap bit flip on a 0↔positive cross. + let init_mask = U256::from(1) << TICK_INITIALIZED_BIT; + let newly_initialized = gross == 0 && new_gross > 0; + let now_uninitialized = gross > 0 && new_gross == 0; + + if newly_initialized { + out.push(StateUpdate::slot_masked( + self.pool, slot3, init_mask, init_mask, + )); + if let Some(flip) = self.bitmap_flip(tick, true) { + out.push(flip); + } + } else if now_uninitialized { + out.push(StateUpdate::slot_masked( + self.pool, + slot3, + init_mask, + U256::ZERO, + )); + if let Some(flip) = self.bitmap_flip(tick, false) { + out.push(flip); + } + } + } + + /// Build the `tickBitmap` word/bit flip for `tick` (`set` = newly initialized, + /// clear = newly uninitialized). The bitmap is keyed by the compressed tick + /// `tick / tick_spacing` (V3 guarantees `tick % tick_spacing == 0`, so the + /// division is exact). Returns `None` if `tick_spacing` is non-positive + /// (degenerate layout). + fn bitmap_flip(&self, tick: i32, set: bool) -> Option { + if self.layout.tick_spacing <= 0 { + return None; + } + let compressed = tick / self.layout.tick_spacing; + let word_pos = (compressed >> 8) as i16; + let bit_pos = (compressed & 0xFF) as u8; + let key = v3_tick_bitmap_storage_key_with_base(word_pos, self.layout.tick_bitmap_base_slot); + let mask = U256::from(1) << bit_pos; + let value = if set { mask } else { U256::ZERO }; + Some(StateUpdate::slot_masked(self.pool, key, mask, value)) + } + + /// Global-liquidity maintenance for a `Mint`/`Burn`: if the current `slot0` + /// tick is within `[tickLower, tickUpper)`, emit an absolute `liquidity` write + /// of `current ± amount`. Reads `slot0` and `liquidity` through the view; if + /// either is cold, surface a cold marker on the liquidity slot. + fn maintain_global_liquidity( + &self, + tick_lower: i32, + tick_upper: i32, + amount: u128, + is_burn: bool, + out: &mut Vec, + ) { + let liquidity_slot = self.layout.liquidity_slot; + let Some(slot0) = self.view.storage(self.pool, self.layout.slot0_slot) else { + out.push(cold_marker(self.pool, liquidity_slot)); + return; + }; + let current_tick = extract_tick(slot0); + if !(tick_lower <= current_tick && current_tick < tick_upper) { + return; // out of range: global liquidity unchanged. + } + let Some(current_word) = self.view.storage(self.pool, liquidity_slot) else { + out.push(cold_marker(self.pool, liquidity_slot)); + return; + }; + let current = u128::try_from(current_word & U256::from(u128::MAX)).unwrap_or(0); + let new = if is_burn { + current.saturating_sub(amount) + } else { + current.saturating_add(amount) + }; + out.push(StateUpdate::slot( + self.pool, + liquidity_slot, + U256::from(new), + )); + } + + /// Decode a `Mint` or `Burn` (shared tick + liquidity maintenance). + fn decode_liquidity_event( + &self, + tick_lower: i32, + tick_upper: i32, + amount: u128, + is_burn: bool, + ) -> Vec { + let mut out = Vec::new(); + self.maintain_tick(tick_lower, amount, is_burn, true, &mut out); + self.maintain_tick(tick_upper, amount, is_burn, false, &mut out); + self.maintain_global_liquidity(tick_lower, tick_upper, amount, is_burn, &mut out); + out + } +} + +/// Sign-extend the int24 `tick` field (bits [160,184)) out of a packed `slot0`. +fn extract_tick(slot0: U256) -> i32 { + let raw = ((slot0 >> SLOT0_TICK_SHIFT) & U256::from(0x00FF_FFFFu32)).to::(); + // Sign-extend from 24 bits. + if raw & 0x0080_0000 != 0 { + (raw | 0xFF00_0000) as i32 + } else { + raw as i32 + } +} + +impl EventDecoder for UniswapV3Decoder { + fn decode(&self, log: &Log, view: &dyn StateView) -> Vec { + let Some(layout) = self.pools.get(&log.address) else { + return Vec::new(); + }; + let pool = log.address; + let topic0 = match log.topics().first() { + Some(t) => *t, + None => return Vec::new(), + }; + + if topic0 == Swap::SIGNATURE_HASH { + let Ok(swap) = Swap::decode_log_data(&log.data) else { + return Vec::new(); + }; + // slot0: masked write of sqrtPriceX96 [0,160) + tick [160,184), + // preserving observation / feeProtocol / unlocked bits [184,256). + let mask = (U256::from(1) << SLOT0_PRICE_TICK_BITS) - U256::from(1); + let sqrt_price = U256::from_be_slice(swap.sqrtPriceX96.to_be_bytes::<20>().as_slice()); + let tick = tick_to_i32(swap.tick); + let tick24 = U256::from((tick as u32) & 0x00FF_FFFF); + let value = sqrt_price | (tick24 << SLOT0_TICK_SHIFT); + vec![ + StateUpdate::slot_masked(pool, layout.slot0_slot, mask, value), + // liquidity: absolute (the event carries post-swap liquidity). + StateUpdate::slot(pool, layout.liquidity_slot, U256::from(swap.liquidity)), + ] + } else if topic0 == Mint::SIGNATURE_HASH { + let Ok(mint) = Mint::decode_log_data(&log.data) else { + return Vec::new(); + }; + let ctx = LiquidityCtx { pool, layout, view }; + ctx.decode_liquidity_event( + tick_to_i32(mint.tickLower), + tick_to_i32(mint.tickUpper), + mint.amount, + false, + ) + } else if topic0 == Burn::SIGNATURE_HASH { + let Ok(burn) = Burn::decode_log_data(&log.data) else { + return Vec::new(); + }; + let ctx = LiquidityCtx { pool, layout, view }; + ctx.decode_liquidity_event( + tick_to_i32(burn.tickLower), + tick_to_i32(burn.tickUpper), + burn.amount, + true, + ) + } else { + Vec::new() + } + } +} diff --git a/src/lib.rs b/src/lib.rs index bc20262..15406b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,9 +46,14 @@ //! policy, mechanism) and the optimistic verify-and-rerun execution loop with //! deferred validation. //! - [`state_update`] — the generic state-mutation vocabulary (`StateUpdate` / -//! `AccountPatch` / `PurgeScope`, plus relative `SlotDelta` read-modify-write) -//! applied by `EvmCache::apply_update` / `apply_updates` / `modify_slot`, with a -//! structured `StateDiff` output (Pillar B.1). +//! `AccountPatch` / `PurgeScope`, plus relative `SlotDelta` read-modify-write and +//! masked `SlotMasked` writes) applied by `EvmCache::apply_update` / +//! `apply_updates` / `modify_slot`, with a structured `StateDiff` output +//! (Pillar B.1). +//! - [`events`] — the event → state pipeline (Pillar B.2): `EventDecoder` / +//! `StateView` / `DecoderRegistry` decode an on-chain `Log` into `StateUpdate`s, +//! and `EventPipeline` ingests, reorg-purges, and reconciles a block's logs. +//! Ships an ERC-20 `Transfer` decoder and (under `protocols`) a UniswapV3 adapter. //! - [`inspector`] — an [`Inspector`](revm::Inspector) that captures ERC20 //! `Transfer` events to reconstruct balance deltas from a simulation. //! - [`multicall`] — batched read-only calls through Multicall3. @@ -102,6 +107,7 @@ pub mod cache; pub mod create3; pub mod deploy; pub mod errors; +pub mod events; pub mod freshness; pub mod inspector; pub mod multicall; @@ -109,6 +115,13 @@ pub mod prefetch_registry; pub mod state_update; pub use access_set::StorageAccessList; +pub use events::erc20::Erc20TransferDecoder; +#[cfg(feature = "protocols")] +pub use events::uniswap_v3::{UniswapV3Decoder, UniswapV3Layout}; +pub use events::{ + BlockDigest, DecoderRegistry, EventDecoder, EventPipeline, ReconcileReport, ReorgConfig, + StateView, +}; pub use freshness::{ AlwaysVerify, BlockClock, FreshnessClock, FreshnessController, FreshnessParams, FreshnessPolicy, FreshnessRegistry, NeverVerify, ObservationDriven, SimRequest, SlotChange, diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index bef20d7..1905f30 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -609,6 +609,7 @@ mod uniswap_v3 { U256::from(sqrt_price) | (tick24 << 160) | (high << 184) } + #[allow(dead_code)] fn tick_word(gross: u128, net: i128) -> U256 { U256::from(gross) | (U256::from(net as u128) << 128) } From 0749dfbf6d96e6643cbfb5201f8526df6c68bfc2 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 16 Jun 2026 13:39:46 +0100 Subject: [PATCH 5/6] Phase 4: offline example, benchmark, docs (overseer deliverables) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Phase 4 deliverable on top of the sub-agent's src/ implementation: - examples/reactive_cache.rs (offline): register an ERC-20 + UniswapV3 decoder, ingest a block of logs (a Transfer + a Swap), show the BlockDigest, the preserved slot0 `unlocked` bit, a reconcile drift alarm+correction, and a reorg purge. Feature-gated (UniswapV3 adapter) with a fallback main. - benches/event_pipeline.rs (offline, registered in Cargo.toml): per-event decode cost (ERC-20 Transfer ~435ns, V3 Swap ~68ns, V3 Mint ~1.0us), ingest_logs decode+apply throughput (linear, ~112ns/log to 1000), reorg_to purge cost (~378us/1000 addrs). - CHANGELOG: Phase 4 `### Added` (event pipeline + adapters + SlotMasked). - ROADMAP: Phase 4 row -> Done + a "Landed on ..." section. - KNOWN_ISSUES: refreshed the Pillar B status (reader+writer halves done, live WS transport not) and recorded the §6.4 V3 fee-growth/oracle maintenance gap. - README: reactive_cache + event_pipeline rows. - tests/event_pipeline.rs: removed an unused tick_word helper (mine; the sub-agent had `#[allow(dead_code)]`-silenced it) + fmt. Independently verified green on both feature configs: fmt; clippy --all-targets (default) + --lib --no-default-features; cargo test (321 passed = 289 tests + 32 doctests) + --no-default-features (269 = 241 + 28); RUSTDOCFLAGS=-D warnings doc; cargo bench --no-run. The example runs offline. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 36 ++++++ Cargo.toml | 4 + README.md | 2 + benches/event_pipeline.rs | 240 ++++++++++++++++++++++++++++++++++++ docs/KNOWN_ISSUES.md | 26 +++- docs/ROADMAP.md | 52 +++++++- examples/reactive_cache.rs | 243 +++++++++++++++++++++++++++++++++++++ tests/event_pipeline.rs | 4 - 8 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 benches/event_pipeline.rs create mode 100644 examples/reactive_cache.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bb58ca9..0968cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,42 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). and the `SlotDelta` double-read of the old value is eliminated. The result is byte-identical to folding `apply_update` over the batch (pinned by the batched==sequential equivalence test). Generic core. +- **Event → state pipeline** (`events` module, Phase 4, Pillar B.2 — the *reader + half* of the event pipeline) — turn on-chain logs into the Phase 3 `StateUpdate` + vocabulary and drive them through the cache for reactive freshness: + - **`EventDecoder` / `StateView`** — a decoder is a pure function of + `(log, pre-state)` returning `Vec`; the narrow read-only + `StateView` (implemented by `EvmCache` via `cached_storage_value`) lets + stateful adapters read current cached state without RPC. Generic core. + - **`DecoderRegistry`** — dispatches a log to the decoders registered for its + emitting address (plus globals) and concatenates their output. Generic core. + - **`Erc20TransferDecoder`** — decodes ERC-20 `Transfer` logs into relative + balance `SlotDelta`s (skipping the zero-address mint/burn leg), with per-token + balance-slot config. The reactive-balance case from Phase 3 §15, now + log-driven. Generic core. + - **`UniswapV3Decoder` / `UniswapV3Layout`** (`protocols`) — `Swap` → a masked + `slot0` write (new `sqrtPriceX96` + `tick`, **preserving** the + observation/fee/`unlocked` bits — a clobbered `unlocked` would make a quote + revert `LOK`) plus an absolute `liquidity` write; `Mint`/`Burn` → per-tick + `liquidityGross`/`liquidityNet`, the `initialized` flag, the `tickBitmap` + word bit, and the in-range global `liquidity`, computed against the + `StateView` and cold-aware. Uniswap and PancakeSwap layouts. + - **`EventPipeline`** — `ingest_logs` decodes + applies a block's logs + **log-by-log in order** (so a later log sees earlier applies) and returns a + `BlockDigest`; `reorg_to` purges (purge-and-resync) the addresses touched + after a new head; `reconcile` re-reads sampled event-derived slots against + chain truth (correct **and** alarm) via the new `EvmCache::reconcile_slots`. A + thin async `drive`/`LogSource` convenience layers the synchronous core over a + stream. Generic core. +- **`StateUpdate::SlotMasked`** (`state_update`, Phase 4) — a cold-aware + read-modify-write *masked* slot write (`new = (old & !mask) | (value & mask)`) + with the `StateUpdate::slot_masked` constructor, so a pure decoder can update + selected bits of a **packed** storage word (e.g. V3 `slot0`) without clobbering + the rest. A masked write to a cold slot is skipped and surfaced in the new + `StateDiff.skipped_masks: Vec` (counted by `has_skipped` / + `skipped_len`, not by the changes-only `is_empty`/`len`); `serde` on + `SkippedMask`. Adding the variant and the field is permitted under the pre-1.0 + break policy (`StateUpdate`/`StateDiff` are `#[non_exhaustive]`). Generic core. - **Configurable transaction & block environment** — `TxConfig` (value, gas limit, gas price, nonce, access list) threaded through `call_raw_with`; block context setters (`set_coinbase`, `set_prevrandao`, `set_block_gas_limit`). diff --git a/Cargo.toml b/Cargo.toml index cdde9d5..4a928d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,10 @@ harness = false name = "state_update" harness = false +[[bench]] +name = "event_pipeline" +harness = false + # RPC-gated real-contract benchmarks. Skipped (not failed) when RPC_URL is unset, # so `cargo bench` stays offline by default. [[bench]] diff --git a/README.md b/README.md index 0d1b959..f106255 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ and inject all state directly: | `freshness_optimistic` | Advanced | Optimistic verify-and-rerun loop: a `Corrected` validation via a stub fetcher. | | `freshness_multi_sim` | Advanced | Many sims with selective re-run, plus classification and `ValidThrough` aging. | | `state_update_apply` | Advanced | Apply a mixed `StateUpdate` batch (`Slot`/`Account`/`Purge`) and inspect the returned `StateDiff`. | +| `reactive_cache` | Advanced | Decode logs (ERC-20 `Transfer` + UniswapV3 `Swap`) into `StateUpdate`s, ingest a block, reconcile drift, and purge on a reorg. | **RPC examples** fork real mainnet state. Set `RPC_URL` to an Ethereum RPC endpoint (they print instructions and exit if it is unset): @@ -226,6 +227,7 @@ cache sizes: | `simulation` | `create_snapshot` across cache sizes (100 → 10k accounts), overlay fan-out, `call_raw` throughput, sequential bundle execution, batched storage injection. | | `freshness` | The optimistic loop end-to-end (CPU and latency-hiding), `verify_slots` at scale (1 → 1000 slots), and multi-sim fan-out. | | `state_update` | `apply_updates` throughput across batch sizes (1 → 1000 `Slot`s) and per-variant apply cost (`Slot` vs `Account` vs `Purge`). | +| `event_pipeline` | Per-event decode cost (ERC-20 `Transfer`, V3 `Swap`/`Mint`), `ingest_logs` decode+apply throughput (1 → 1000 logs), and `reorg_to` purge cost. | | `access_list` | Touch-set merge and EIP-2930 list construction. | | `revert_decoding` | Built-in and custom revert decoding, including decoder dispatch with many registered errors. | | `storage_keys` | Mapping/array storage-key derivation. | diff --git a/benches/event_pipeline.rs b/benches/event_pipeline.rs new file mode 100644 index 0000000..9ddf859 --- /dev/null +++ b/benches/event_pipeline.rs @@ -0,0 +1,240 @@ +//! Phase 4 benchmarks: the event → state pipeline (Pillar B.2). +//! +//! Measures three things, all offline (mocked provider, in-memory logs): +//! - **decode** cost per event kind (ERC-20 `Transfer`, UniswapV3 `Swap`/`Mint`), +//! isolating the pure `EventDecoder::decode` work (no apply); +//! - **ingest** throughput — [`EventPipeline::ingest_logs`] decoding **and** +//! applying a block of logs, across batch sizes (1 → 1000); +//! - **reorg** purge cost — [`EventPipeline::reorg_to`] over a touched set of +//! 1 → 1000 addresses. +//! +//! A current-thread runtime drives only the async cache constructor; the pipeline +//! itself is synchronous and never touches the network. + +use std::collections::HashMap; +use std::hint::black_box; +use std::sync::Arc; + +use alloy_primitives::aliases::{I24, U160}; +use alloy_primitives::{Address, Bytes, I256, Log, U256, hex, keccak256}; +use alloy_provider::RootProvider; +use alloy_provider::network::AnyNetwork; +use alloy_rpc_client::RpcClient; +use alloy_sol_types::{SolEvent, sol}; +use alloy_transport::mock::Asserter; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use evm_fork_cache::cache::{EvmCache, V3_SLOT0_SLOT, v3_tick_info_storage_keys_with_base}; +use evm_fork_cache::events::{DecoderRegistry, EventDecoder, EventPipeline, StateView}; +use evm_fork_cache::{Erc20TransferDecoder, StateUpdate, UniswapV3Decoder, UniswapV3Layout}; +use revm::state::{AccountInfo, Bytecode}; +use tokio::runtime::{Builder, Runtime}; + +const MOCK_ERC20_RUNTIME_HEX: &str = include_str!("../fixtures/mock_erc20_runtime.hex"); +const TOKEN: Address = Address::repeat_byte(0xAA); +const POOL: Address = Address::repeat_byte(0xBB); + +fn current_thread_rt() -> Runtime { + Builder::new_current_thread().enable_all().build().unwrap() +} + +/// A cache with `TOKEN` and `POOL` installed as storage-cleared accounts (so +/// unseeded slots read as zero — no RPC fallthrough). +fn seeded_cache(rt: &Runtime) -> EvmCache { + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider), None)); + let runtime = Bytecode::new_raw(Bytes::from( + hex::decode(MOCK_ERC20_RUNTIME_HEX.trim()).unwrap(), + )); + let code_hash = runtime.hash_slow(); + for addr in [TOKEN, POOL] { + cache.db_mut().insert_account_info( + addr, + AccountInfo { + balance: U256::ZERO, + nonce: 0, + code: Some(runtime.clone()), + code_hash, + account_id: None, + }, + ); + cache + .db_mut() + .replace_account_storage(addr, Default::default()) + .unwrap(); + } + cache +} + +sol! { + event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); + event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1); +} + +fn transfer_log(token: Address, from: Address, to: Address, value: U256) -> Log { + let sig = keccak256(b"Transfer(address,address,uint256)"); + Log::new_unchecked( + token, + vec![sig, from.into_word(), to.into_word()], + Bytes::copy_from_slice(&value.to_be_bytes::<32>()), + ) +} + +fn swap_log(pool: Address, sqrt_price: u128, liquidity: u128, tick: i32) -> Log { + let ev = Swap { + sender: Address::repeat_byte(0x01), + recipient: Address::repeat_byte(0x02), + amount0: I256::try_from(-1i64).unwrap(), + amount1: I256::try_from(1i64).unwrap(), + sqrtPriceX96: U160::from(sqrt_price), + liquidity, + tick: I24::try_from(tick).unwrap(), + }; + Log { + address: pool, + data: ev.encode_log_data(), + } +} + +fn mint_log(pool: Address, lower: i32, upper: i32, amount: u128) -> Log { + let ev = Mint { + sender: Address::repeat_byte(0x03), + owner: Address::repeat_byte(0x04), + tickLower: I24::try_from(lower).unwrap(), + tickUpper: I24::try_from(upper).unwrap(), + amount, + amount0: U256::from(1), + amount1: U256::from(1), + }; + Log { + address: pool, + data: ev.encode_log_data(), + } +} + +/// A bench-local read-only [`StateView`] over a fixed map (for the V3 `Mint` +/// decode, which reads the current tick word). +struct MapView(HashMap<(Address, U256), U256>); +impl StateView for MapView { + fn storage(&self, address: Address, slot: U256) -> Option { + self.0.get(&(address, slot)).copied() + } +} + +/// A bench-local decoder that emits one absolute `Slot` write per log, keyed by +/// the log's address — so repeated ingest is idempotent (stable across iters). +struct AbsDecoder; +impl EventDecoder for AbsDecoder { + fn decode(&self, log: &Log, _view: &dyn StateView) -> Vec { + vec![StateUpdate::slot(log.address, U256::from(0), U256::from(1))] + } +} + +/// Pure `decode` cost per event kind (no apply). +fn bench_decode(c: &mut Criterion) { + let mut group = c.benchmark_group("decode"); + + let erc20 = Erc20TransferDecoder::new(U256::from(3)); + let tlog = transfer_log( + TOKEN, + Address::repeat_byte(0x21), + Address::repeat_byte(0x22), + U256::from(100), + ); + let empty = MapView(HashMap::new()); + group.bench_function("erc20_transfer", |b| { + b.iter(|| black_box(erc20.decode(black_box(&tlog), &empty))) + }); + + let v3 = UniswapV3Decoder::new().with_pool(POOL, UniswapV3Layout::uniswap(60)); + let slog = swap_log(POOL, 2_000_000, 7_500, 120); + let mut slot0_view = HashMap::new(); + slot0_view.insert( + (POOL, V3_SLOT0_SLOT), + (U256::from(1u64) << 240) | U256::from(1_000_000u64), + ); + // Seed the tick words the Mint reads (lower/upper) so it computes (not skips). + let lo = v3_tick_info_storage_keys_with_base(60, evm_fork_cache::cache::V3_TICKS_BASE_SLOT)[0]; + let hi = v3_tick_info_storage_keys_with_base(120, evm_fork_cache::cache::V3_TICKS_BASE_SLOT)[0]; + slot0_view.insert((POOL, lo), U256::ZERO); + slot0_view.insert((POOL, hi), U256::ZERO); + let view = MapView(slot0_view); + group.bench_function("v3_swap", |b| { + b.iter(|| black_box(v3.decode(black_box(&slog), &view))) + }); + let mlog = mint_log(POOL, 60, 120, 1_000); + group.bench_function("v3_mint", |b| { + b.iter(|| black_box(v3.decode(black_box(&mlog), &view))) + }); + + group.finish(); +} + +/// `ingest_logs` decode+apply throughput as the per-block log batch grows. +fn bench_ingest_batch(c: &mut Criterion) { + let rt = current_thread_rt(); + let mut cache = seeded_cache(&rt); + + let mut group = c.benchmark_group("ingest_logs"); + for &n in &[1usize, 10, 100, 1_000] { + let logs: Vec = (0..n) + .map(|i| { + Log::new_unchecked( + Address::repeat_byte((i % 251 + 1) as u8), + vec![], + Bytes::new(), + ) + }) + .collect(); + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(AbsDecoder)); + let mut pipeline = EventPipeline::new(registry); + + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::from_parameter(n), &logs, |b, logs| { + let mut block = 0u64; + b.iter(|| { + block += 1; + black_box(pipeline.ingest_logs(&mut cache, block, black_box(logs))) + }) + }); + } + group.finish(); +} + +/// `reorg_to` purge cost over a touched set of N distinct addresses. +fn bench_reorg(c: &mut Criterion) { + let rt = current_thread_rt(); + + let mut group = c.benchmark_group("reorg_to"); + for &n in &[10usize, 100, 1_000] { + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + b.iter_batched( + || { + // Setup: a cache + pipeline with N addresses touched at block 1. + let mut cache = seeded_cache(&rt); + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(AbsDecoder)); + let mut pipeline = EventPipeline::new(registry); + let logs: Vec = (0..n) + .map(|i| { + let mut bytes = [0u8; 20]; + bytes[0..8].copy_from_slice(&(i as u64).to_be_bytes()); + Log::new_unchecked(Address::from(bytes), vec![], Bytes::new()) + }) + .collect(); + pipeline.ingest_logs(&mut cache, 1, &logs); + (pipeline, cache) + }, + |(mut pipeline, mut cache)| { + black_box(pipeline.reorg_to(&mut cache, 0)); + }, + criterion::BatchSize::SmallInput, + ) + }); + } + group.finish(); +} + +criterion_group!(benches, bench_decode, bench_ingest_batch, bench_reorg); +criterion_main!(benches); diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 06a755d..ce24b7c 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -139,11 +139,27 @@ Confidence legend: **[V]** verified against the source during review; unit tests assume the default feature. Extraction into `evm-amm-state` is planned (roadmap), blocked partly by `ImmutableDataCache` coupling generic token-decimals with V2/V3/Balancer pool metadata. -- **Event-driven sync (roadmap Pillar B) is not implemented.** Targeted - inject/purge exist; the Phase 3 **writer half** (the `StateUpdate` vocabulary + - `apply_update`/`apply_updates`) lands the apply mechanism, but decoding logs - into state updates and the WS ingestion loop with reorg handling are future - phases (Pillar B.2). +- **Event-driven sync (roadmap Pillar B) — reader/writer halves done; live WS + transport is not.** The Phase 3 **writer half** (`StateUpdate` + + `apply_update`/`apply_updates`) and the Phase 4 **reader half** (the `events` + module: `EventDecoder`/`DecoderRegistry`, the ERC-20 + UniswapV3 adapters, and + the `EventPipeline` with `ingest_logs`/`reorg_to`/`reconcile`) are implemented. + What is **not** shipped is a concrete production WS transport: the async + `events::drive`/`LogSource` convenience is generic over a log source and is + exercised only by the offline example feeding an in-memory source; wiring it to + a live `subscribe_logs`/WS provider (and detecting reorgs from block-hash + mismatches) is left to the consumer. +- **[V] V3 event-derived tick maintenance does not reconstruct fee-growth / + oracle state (Phase 4 §6.4).** `UniswapV3Decoder`'s `Mint`/`Burn` handling + maintains `liquidityGross`/`liquidityNet` (tick slot +0), the `initialized` flag + (+3), the `tickBitmap`, and the in-range global `liquidity`, but **not** + `feeGrowthOutside0/1X128` (slots +1/+2), `secondsOutside`, or oracle + observations — these are not derivable from the `Mint`/`Burn`/`Swap` events. + **Swap price/liquidity quoting is unaffected** (the swap-amount math does not + read `feeGrowthOutside`), but fee accounting and `collect`-style reads against + event-maintained ticks are not kept current. Sampled + `EventPipeline::reconcile` (RPC re-read) and reorg `reorg_to` (purge-and-resync) + are the backstop; seed a full tick via `inject_v3_ticks` when fee state matters. - **`inject_v2/v3_*` layer behavior changed in Phase 3 (Decision 2).** The `protocols`-gated `inject_v2_pool_metadata` / `inject_v3_tick_bitmap*` / `inject_v3_ticks*` helpers were refolded onto the write-through diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e192c15..8eaa262 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -73,7 +73,7 @@ RPC node Event-driven sync ← WS logs · new block | **1** | Engine seam: typed errors, configurable tx/block env, hot-path benches, builder, `protocols` feature. | **Done** (`phase-1-engine-seam`) | | **2** | Freshness core (Pillar C): `Validity` + `FreshnessRegistry`; observation tracker; policies; optimistic verify-and-rerun loop. | **Done** (`phase-2-freshness`) | | **3** | State-update primitives (Pillar B.1): `StateUpdate` + targeted writers; refold `inject_*`; surface state-diff output. | **Done** (`phase-3-state-updates`) | -| **4** | Event pipeline + adapters (Pillar B.2): `EventDecoder` trait, V3 adapter, WS ingestion loop, reorg handling. | Planned | +| **4** | Event pipeline + adapters (Pillar B.2): `EventDecoder` trait, ERC-20 + V3 adapters, ingest/reorg/reconcile pipeline. | **Done** (`phase-4-event-pipeline`) | | **5** | COW snapshots (Pillar A): structural sharing; overlay buffer reuse. | Planned | Cross-cutting (land opportunistically): call tracer Inspector, full offline @@ -356,6 +356,56 @@ equivalence test). --- +## Phase 4 — event pipeline + adapters (detailed, decisions locked) + +Builds **Pillar B.2 — the reader half** of the event → state pipeline: decode an +on-chain `Log` into the Phase 3 `StateUpdate` vocabulary, apply it, and run the +reactive maintenance (reconcile, reorg) that keeps event-derived state honest. +Decoders are pure functions of `(log, pre-state)`; the `!Send` cache discipline is +preserved by keeping the tested core synchronous (the async ingestion driver is a +thin convenience). The full build contract is in +[`phase-4-spec.md`](phase-4-spec.md). + +### Locked decisions + +1. **Packed-slot updates → `StateUpdate::SlotMasked`** (a cold-aware RMW masked + write), so a pure decoder can express a partial update to a packed word (V3 + `slot0`) without clobbering the bits it does not own (notably `unlocked`). +2. **V3 adapter coverage → `Swap` **and** `Mint`/`Burn` (full ticks).** `slot0` + + `liquidity` from `Swap`; per-tick `liquidityGross`/`liquidityNet` + + `initialized` + `tickBitmap` + in-range global `liquidity` from `Mint`/`Burn`, + computed against the `StateView`. Fee-growth/oracle state is out of scope (a + documented limitation; reconcile/purge are the backstop). +3. **Reorg → purge-and-resync.** A depth-bounded ring tracks addresses touched per + block; `reorg_to(n)` purges everything touched after `n` so reads re-fetch. + `ValidThrough` is the freshness lever. +4. **Reconciliation → sampled re-read, correct **and** alarm.** `reconcile` samples + event-derived slots and re-reads via `EvmCache::reconcile_slots` (a honest + wrapper over `verify_slots` that errors on a total fetch failure rather than + reporting a false all-clear); the fresh chain value wins and the drift is + surfaced. + +### Acceptance — met + +`cargo fmt --check`, `clippy --all-targets -- -D warnings` (default + +`--lib --no-default-features`), `cargo test` (both feature configs), +`RUSTDOCFLAGS=-D warnings cargo doc`, `cargo bench --no-run`. + +Landed on `phase-4-event-pipeline`: `src/events/` (the generic core — +`EventDecoder`/`StateView`, `DecoderRegistry`, `EventPipeline` with +`ingest_logs`/`reorg_to`/`reconcile` + `BlockDigest`/`ReconcileReport`/ +`ReorgConfig`, and the async `drive`/`LogSource`), the generic +`Erc20TransferDecoder` (`events::erc20`), and the `protocols`-gated +`UniswapV3Decoder`/`UniswapV3Layout` (`events::uniswap_v3`); the cold-aware +`StateUpdate::SlotMasked` vocabulary + `StateDiff.skipped_masks`/`SkippedMask` +(`state_update`) and its dual-layer apply arm; `EvmCache::reconcile_slots` and the +`StateView` impl; the offline `examples/reactive_cache.rs`; +`benches/event_pipeline.rs`; and `tests/event_pipeline.rs` (+ the `SlotMasked` +tests in `tests/state_update.rs`). The §6.4 V3 fee-growth/oracle maintenance gap +is recorded in `KNOWN_ISSUES.md`. + +--- + ## Key abstractions for later phases (sketches) ```rust diff --git a/examples/reactive_cache.rs b/examples/reactive_cache.rs new file mode 100644 index 0000000..7901f83 --- /dev/null +++ b/examples/reactive_cache.rs @@ -0,0 +1,243 @@ +//! Reactive cache updates from the event stream (Pillar B.2). +//! +//! Decodes on-chain logs into the Phase 3 [`StateUpdate`] vocabulary and applies +//! them to a fork cache — keeping hot state fresh **without** an RPC round-trip +//! per change. It wires up the three pieces Phase 4 adds: +//! +//! 1. A [`DecoderRegistry`] with an [`Erc20TransferDecoder`] (balances) and a +//! [`UniswapV3Decoder`] (a pool's `slot0` price/tick + `liquidity`). +//! 2. An [`EventPipeline`] whose `ingest_logs` decodes + applies a block's logs +//! (log-by-log), surfacing a [`BlockDigest`]. +//! 3. The reactive maintenance: the freshness wiring (pin event-derived slots so +//! the optimistic validator does not re-verify them, then advance the block +//! clock), a sampled **reconcile** drift alarm against a stub fetcher, and a +//! **reorg** purge-and-resync. +//! +//! Runs fully offline against a mocked provider and in-memory logs — no network. +//! Requires the `protocols` feature (the UniswapV3 adapter), which is on by +//! default. +//! +//! Run with: +//! +//! ```sh +//! cargo run --example reactive_cache +//! ``` + +#[cfg(feature = "protocols")] +#[tokio::main(flavor = "multi_thread")] +async fn main() -> anyhow::Result<()> { + imp::run().await +} + +#[cfg(not(feature = "protocols"))] +fn main() { + eprintln!( + "the `reactive_cache` example requires the `protocols` feature (the \ + UniswapV3 adapter). Run it with default features: \ + `cargo run --example reactive_cache`." + ); +} + +#[cfg(feature = "protocols")] +#[path = "support/mock.rs"] +mod mock; + +#[cfg(feature = "protocols")] +mod imp { + use std::collections::HashMap; + use std::sync::Arc; + + use alloy_eips::BlockId; + use alloy_primitives::aliases::{I24, U160}; + use alloy_primitives::{Address, Bytes, I256, Log, U256, keccak256}; + use alloy_sol_types::{SolEvent, SolValue, sol}; + use anyhow::Result; + use evm_fork_cache::cache::{StorageBatchFetchFn, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT}; + use evm_fork_cache::events::{DecoderRegistry, EventPipeline}; + use evm_fork_cache::freshness::{ + AlwaysVerify, FreshnessController, FreshnessRegistry, Validity, + }; + use evm_fork_cache::{Erc20TransferDecoder, UniswapV3Decoder, UniswapV3Layout}; + + use super::mock; + + sol! { + event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); + } + + /// Hashed `balanceOf[owner]` slot for the MockERC20 fixture (mapping at slot 3). + fn balance_slot(owner: Address) -> U256 { + let key = keccak256((owner, U256::from(mock::MOCK_ERC20_BALANCE_SLOT)).abi_encode()); + U256::from_be_bytes(key.0) + } + + /// Build an ERC-20 `Transfer(from, to, value)` log. + fn transfer_log(token: Address, from: Address, to: Address, value: U256) -> Log { + let sig = keccak256(b"Transfer(address,address,uint256)"); + Log::new_unchecked( + token, + vec![sig, from.into_word(), to.into_word()], + Bytes::copy_from_slice(&value.to_be_bytes::<32>()), + ) + } + + /// Build a UniswapV3 `Swap` log carrying the post-swap price/liquidity/tick. + fn swap_log(pool: Address, sqrt_price: u128, liquidity: u128, tick: i32) -> Log { + let ev = Swap { + sender: Address::repeat_byte(0x5e), + recipient: Address::repeat_byte(0x5f), + amount0: I256::try_from(-1_000i64).unwrap(), + amount1: I256::try_from(1_000i64).unwrap(), + sqrtPriceX96: U160::from(sqrt_price), + liquidity, + tick: I24::try_from(tick).unwrap(), + }; + Log { + address: pool, + data: ev.encode_log_data(), + } + } + + /// Pack a slot0 word: sqrtPriceX96 [0,160), tick [160,184), `unlocked` at bit 240. + fn pack_slot0(sqrt_price: u128, tick: i32) -> U256 { + let tick24 = U256::from((tick as u32) & 0x00FF_FFFF); + let unlocked = U256::from(1) << 240; + U256::from(sqrt_price) | (tick24 << 160) | unlocked + } + + pub async fn run() -> Result<()> { + let mut cache = mock::offline_cache().await?; + + let token = Address::repeat_byte(0x11); + let pool = Address::repeat_byte(0x99); + let alice = Address::repeat_byte(0x22); + let bob = Address::repeat_byte(0x33); + mock::install_default_account(&mut cache, Address::ZERO); + mock::install_default_account(&mut cache, alice); + mock::install_default_account(&mut cache, bob); + mock::install_mock_erc20(&mut cache, token); + mock::install_mock_erc20(&mut cache, pool); // reuse as a storage-cleared pool + + // Seed the holders' balances (EVM-visible) and the pool's slot0 + liquidity. + cache + .db_mut() + .insert_account_storage(token, balance_slot(alice), U256::from(1_000))?; + cache + .db_mut() + .insert_account_storage(token, balance_slot(bob), U256::from(0))?; + cache + .db_mut() + .insert_account_storage(pool, V3_SLOT0_SLOT, pack_slot0(1_000_000, 100))?; + cache + .db_mut() + .insert_account_storage(pool, V3_LIQUIDITY_SLOT, U256::from(5_000))?; + + // 1. Build the decoder registry: ERC-20 balances + the V3 pool. + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from( + mock::MOCK_ERC20_BALANCE_SLOT, + )))); + registry.register(Arc::new( + UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(60)), + )); + let mut pipeline = EventPipeline::new(registry); + + // The freshness side: a controller whose registry we pin event-derived + // slots into so the optimistic validator never re-verifies them by RPC. + let mut controller = FreshnessController::new(FreshnessRegistry::new(), AlwaysVerify); + + // 2. Ingest block 100: Alice sends Bob 250 tokens, and the pool swaps (new + // price/tick + liquidity). Decoded + applied log-by-log. + let block = 100u64; + let digest = pipeline.ingest_logs( + &mut cache, + block, + &[ + transfer_log(token, alice, bob, U256::from(250)), + swap_log(pool, 2_000_000, 7_500, 120), + ], + ); + + println!("=== ingested block {} ===", digest.block); + println!( + " decoded {} log(s) -> {} slot change(s), {} skipped", + digest.decoded_logs, + digest.applied.slots.len(), + digest.applied.skipped_len(), + ); + println!( + " alice balance: {} bob balance: {}", + mock::balance_of(&mut cache, token, alice)?, + mock::balance_of(&mut cache, token, bob)?, + ); + println!( + " pool liquidity slot: {}", + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT).unwrap() + ); + // slot0: the new price/tick landed, and the `unlocked` bit (240) is + // preserved by the masked write — a clobbered `unlocked` would make a + // quote revert LOK. + let slot0 = cache.cached_storage_value(pool, V3_SLOT0_SLOT).unwrap(); + println!( + " pool slot0 sqrtPriceX96 (low 160b): {}", + slot0 & ((U256::from(1) << 160) - U256::from(1)) + ); + println!( + " pool slot0 unlocked bit preserved: {}", + (slot0 >> 240) & U256::from(1) == U256::from(1) + ); + + // 3a. Freshness wiring: pin the touched slots (kept fresh out-of-band by + // the pipeline) and advance the block clock. + for (addr, slot) in &digest.touched_slots { + controller + .registry_mut() + .set_slot(*addr, *slot, Validity::Pinned); + } + controller.on_new_block(block); + println!( + "\npinned {} event-derived slot(s) into the freshness registry", + digest.touched_slots.len() + ); + + // 3b. Sampled reconcile against chain truth. Stub the fetcher so the + // pool's liquidity reads 7_600 on-chain (a small drift from our + // event-derived 7_500): reconcile corrects the cache AND alarms. + let fresh: HashMap<(Address, U256), U256> = + HashMap::from([((pool, V3_LIQUIDITY_SLOT), U256::from(7_600))]); + let fetcher: StorageBatchFetchFn = Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + requests + .into_iter() + .map(|(a, s)| (a, s, Ok(fresh.get(&(a, s)).copied().unwrap_or(U256::ZERO)))) + .collect() + }, + ); + cache.set_storage_batch_fetcher(fetcher); + + let report = pipeline.reconcile(&mut cache, &[(pool, V3_LIQUIDITY_SLOT)])?; + println!("\n=== reconcile (sampled {} slot) ===", report.checked); + if report.mismatched.is_empty() { + println!(" no drift — event-derived state matches chain"); + } else { + for c in &report.mismatched { + println!( + " DRIFT: {} slot {} : {} -> {} (corrected)", + c.address, c.slot, c.old, c.new + ); + } + } + + // 4. A reorg to block 99 purges everything block 100 touched, so the next + // read re-fetches from RPC (the caller re-ingests the canonical logs). + let purge = pipeline.reorg_to(&mut cache, 99); + println!("\n=== reorg to block 99 ==="); + println!( + " purged {} address(es); pool liquidity now re-reads cold/zero: {:?}", + purge.purged.len(), + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT), + ); + + Ok(()) + } +} diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index 1905f30..d18d224 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -609,10 +609,6 @@ mod uniswap_v3 { U256::from(sqrt_price) | (tick24 << 160) | (high << 184) } - #[allow(dead_code)] - fn tick_word(gross: u128, net: i128) -> U256 { - U256::from(gross) | (U256::from(net as u128) << 128) - } fn unpack_tick_word(w: U256) -> (u128, i128) { let gross = u128::try_from(w & U256::from(u128::MAX)).unwrap(); let net = u128::try_from((w >> 128) & U256::from(u128::MAX)).unwrap() as i128; From d7adfd22ad5b29159db43ca5d9882ec4b8e1e596 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 16 Jun 2026 15:33:39 +0100 Subject: [PATCH 6/6] Phase 4: differential ground-truth test for the event processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the event → state pipeline against real EVM execution: run a swap in a ground-truth revm instance and replay ONLY its emitted logs into a twin cache, then assert the token balances and the packed pool slot0 (price/tick) match bit-for-bit. - fixtures/EventGroundTruthPool.sol + test_v3_pool_creation.hex: a faithful UniswapV3-pool stand-in whose slot0 is a Solidity struct with the identical field widths to UniswapV3Pool.Slot0 — so the *compiler* does the real bit-packing and our StateUpdate::SlotMasked is the thing under test. Its swap does real ERC-20 transfers (canonical Transfer logs) + a compiler-masked slot0 update + the canonical Swap event. - tests/event_ground_truth.rs (protocols-gated): deploy two MockERC20 tokens + the pool into a ground-truth cache (deterministic CREATE addresses), seed liquidity, execute a real swap (capture its 2 Transfer + 1 Swap logs), build the identical pre-swap state in a twin cache, feed only the logs through EventPipeline, and assert balances + slot0 + liquidity equal the ground truth. Explicitly checks the slot0 unlocked + observation-index bits survive the masked update. Result: the event-derived state reproduces the ground-truth EVM execution exactly (swapper/pool balances of both tokens, packed slot0, liquidity). Full suite green both feature configs (322 default incl. the new test, 269 --no-default-features); fmt, clippy --all-targets + --lib --no-default-features, doc, bench --no-run. Co-Authored-By: Claude Opus 4.8 (1M context) --- fixtures/EventGroundTruthPool.sol | 126 ++++++++++++ fixtures/README.md | 25 +++ fixtures/test_v3_pool_creation.hex | 1 + tests/event_ground_truth.rs | 317 +++++++++++++++++++++++++++++ 4 files changed, 469 insertions(+) create mode 100644 fixtures/EventGroundTruthPool.sol create mode 100644 fixtures/test_v3_pool_creation.hex create mode 100644 tests/event_ground_truth.rs diff --git a/fixtures/EventGroundTruthPool.sol b/fixtures/EventGroundTruthPool.sol new file mode 100644 index 0000000..510e9e8 --- /dev/null +++ b/fixtures/EventGroundTruthPool.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +/// @title TestV3Pool +/// @notice A faithful **stand-in** for a UniswapV3 pool used by the event → +/// state differential test (`tests/event_ground_truth.rs`). It is NOT +/// verbatim Uniswap bytecode; instead it reproduces the two things our +/// event decoder actually depends on, and lets the Solidity compiler — +/// not the test author — generate them: +/// 1. The real UniswapV3 `slot0` **storage packing**: `slot0` is a +/// struct with the identical field order/widths as +/// `IUniswapV3PoolState.slot0`, so the compiler packs +/// `sqrtPriceX96` (bits [0,160)), `tick` (int24, [160,184)), +/// `observationIndex`/cardinality/`feeProtocol`/`unlocked` ([184,256)) +/// into one word at storage slot 0 exactly as the real pool does. +/// A swap assigns only `.sqrtPriceX96`/`.tick`, so the compiler emits +/// the masked update that preserves the observation/`unlocked` bits — +/// the exact behavior our `StateUpdate::SlotMasked` must reproduce. +/// 2. The canonical `Swap(...)` event signature, emitted with the same +/// `sqrtPriceX96`/`liquidity`/`tick` values written to storage. +/// +/// @dev Storage layout (mirrors UniswapV3Pool so the slots match +/// `UniswapV3Layout::uniswap`): +/// slot 0: slot0 (packed) +/// slot 1: feeGrowthGlobal0X128 (unused, for layout parity) +/// slot 2: feeGrowthGlobal1X128 (unused) +/// slot 3: protocolFees (unused) +/// slot 4: liquidity (uint128) +/// `token0`/`token1` are immutable (baked into code, not stored), so they do +/// not perturb the slot numbering. +/// +/// The swap *outcome* (amounts, new price/tick/liquidity) is supplied by the +/// caller so the test is deterministic; the pool still performs real ERC-20 +/// transfers (emitting canonical `Transfer` logs from the token contracts) and a +/// real compiler-packed `slot0` update. The price math itself is irrelevant to +/// what the event processor reconstructs — it reads `sqrtPriceX96`/`tick` from the +/// emitted event, never from the pool's internals. +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +contract TestV3Pool { + /// Identical field order/widths to UniswapV3Pool.Slot0 (one packed word). + struct Slot0 { + uint160 sqrtPriceX96; + int24 tick; + uint16 observationIndex; + uint16 observationCardinality; + uint16 observationCardinalityNext; + uint8 feeProtocol; + bool unlocked; + } + + event Swap( + address indexed sender, + address indexed recipient, + int256 amount0, + int256 amount1, + uint160 sqrtPriceX96, + uint128 liquidity, + int24 tick + ); + + Slot0 public slot0; // slot 0 + uint256 private feeGrowthGlobal0X128; // slot 1 + uint256 private feeGrowthGlobal1X128; // slot 2 + uint256 private protocolFees; // slot 3 + uint128 public liquidity; // slot 4 + + address public immutable token0; + address public immutable token1; + + constructor(address _token0, address _token1) { + token0 = _token0; + token1 = _token1; + } + + /// Set the initial packed `slot0` (with `unlocked = true` and a non-zero + /// observation index, so the differential test can prove those bits survive + /// a swap) and the initial `liquidity`. + function initialize(uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint128 _liquidity) + external + { + slot0 = Slot0({ + sqrtPriceX96: sqrtPriceX96, + tick: tick, + observationIndex: observationIndex, + observationCardinality: 1, + observationCardinalityNext: 1, + feeProtocol: 0, + unlocked: true + }); + liquidity = _liquidity; + } + + /// Execute a swap with a caller-specified outcome: pull `amountIn` of the + /// input token (real `transferFrom` → `Transfer` log), send `amountOut` of the + /// output token (real `transfer` → `Transfer` log), update the packed `slot0` + /// price/tick (compiler-masked, preserving the observation/`unlocked` bits) + /// and `liquidity`, then emit the canonical `Swap` event with those values. + function swap( + bool zeroForOne, + uint256 amountIn, + uint256 amountOut, + uint160 newSqrtPriceX96, + int24 newTick, + uint128 newLiquidity + ) external { + address tokenIn = zeroForOne ? token0 : token1; + address tokenOut = zeroForOne ? token1 : token0; + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + IERC20(tokenOut).transfer(msg.sender, amountOut); + + // Real Uniswap assigns the struct fields individually; the compiler emits + // the masked SSTORE that preserves observation/unlocked. This is exactly + // what `StateUpdate::SlotMasked` must reproduce off the event. + slot0.sqrtPriceX96 = newSqrtPriceX96; + slot0.tick = newTick; + liquidity = newLiquidity; + + int256 amount0 = zeroForOne ? int256(amountIn) : -int256(amountOut); + int256 amount1 = zeroForOne ? -int256(amountOut) : int256(amountIn); + emit Swap(msg.sender, msg.sender, amount0, amount1, newSqrtPriceX96, newLiquidity, newTick); + } +} diff --git a/fixtures/README.md b/fixtures/README.md index ce6f067..b52807f 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -43,3 +43,28 @@ jq -r '.deployedBytecode.object' out/MockERC20.sol/MockERC20.json \ jq -r '.bytecode.object' out/MockERC20.sol/MockERC20.json \ | sed 's/^0x//' > fixtures/mock_erc20_creation.hex ``` + +## `TestV3Pool` + +A faithful UniswapV3-pool **stand-in** (see +[`EventGroundTruthPool.sol`](EventGroundTruthPool.sol)) used by the Phase 4 +differential ground-truth test +([`../tests/event_ground_truth.rs`](../tests/event_ground_truth.rs)). It is *not* +verbatim Uniswap bytecode; it reproduces the two things the event decoder depends +on and lets the compiler generate them: the real `slot0` **struct packing** +(`sqrtPriceX96`/`tick`/observation/`unlocked`, matching `UniswapV3Pool.Slot0`) at +storage slot 0, and the canonical `Swap(...)` event. A `swap` performs real ERC-20 +transfers (canonical `Transfer` logs) and a compiler-masked `slot0` update, so the +test can replay only the emitted logs into a twin cache and assert the +event-derived state matches the ground-truth EVM execution bit-for-bit. + +- `test_v3_pool_creation.hex` — creation bytecode, for `deploy_contract`. The + constructor takes `(address token0, address token1)`; storage mirrors Uniswap + (slot 0 = `slot0`, slot 4 = `liquidity`). + +Regenerate with `solc` (the source is `^0.8.20`-compatible): + +```sh +solc --bin --optimize --optimize-runs 200 --overwrite -o out fixtures/EventGroundTruthPool.sol +cp out/TestV3Pool.bin fixtures/test_v3_pool_creation.hex +``` diff --git a/fixtures/test_v3_pool_creation.hex b/fixtures/test_v3_pool_creation.hex new file mode 100644 index 0000000..9f7230e --- /dev/null +++ b/fixtures/test_v3_pool_creation.hex @@ -0,0 +1 @@ +60c060405234801561000f575f80fd5b5060405161075338038061075383398101604081905261002e91610060565b6001600160a01b039182166080521660a052610091565b80516001600160a01b038116811461005b575f80fd5b919050565b5f8060408385031215610071575f80fd5b61007a83610045565b915061008860208401610045565b90509250929050565b60805160a0516106866100cd5f395f818161026d01528181610297015261030d01525f81816069015281816102bd01526102e701526106865ff3fe608060405234801561000f575f80fd5b5060043610610060575f3560e01c80630dfe1681146100645780631a686502146100a85780631ff1a703146100d35780633850c7bd146101b15780635c02d26614610255578063d21220a714610268575b5f80fd5b61008b7f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6004546100bb906001600160801b031681565b6040516001600160801b03909116815260200161009f565b6101af6100e136600461053b565b6040805160e0810182526001600160a01b0395909516808652600285900b602087015261ffff93909316908501819052600160608601819052608086018190525f60a0870181905260c0909601528454600160c81b6001600160b81b0319909116909317600160a01b62ffffff909516949094029390931763ffffffff60b81b1916600160b81b90930261ffff60c81b1916929092171763ffffffff60d81b1916630100000160d81b17909155600480546001600160801b0319166001600160801b03909216919091179055565b005b5f54610204906001600160a01b03811690600160a01b810460020b9061ffff600160b81b8204811691600160c81b8104821691600160d81b8204169060ff600160e81b8204811691600160f01b90041687565b604080516001600160a01b03909816885260029690960b602088015261ffff94851695870195909552918316606086015291909116608084015260ff1660a0830152151560c082015260e00161009f565b6101af6102633660046105a4565b61028f565b61008b7f000000000000000000000000000000000000000000000000000000000000000081565b5f866102bb577f00000000000000000000000000000000000000000000000000000000000000006102dd565b7f00000000000000000000000000000000000000000000000000000000000000005b90505f8761030b577f000000000000000000000000000000000000000000000000000000000000000061032d565b7f00000000000000000000000000000000000000000000000000000000000000005b6040516323b872dd60e01b8152336004820152306024820152604481018990529091506001600160a01b038316906323b872dd906064016020604051808303815f875af1158015610380573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906103a49190610608565b5060405163a9059cbb60e01b8152336004820152602481018790526001600160a01b0382169063a9059cbb906044016020604051808303815f875af11580156103ef573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104139190610608565b505f80546001600160a01b0387166001600160b81b031990911617600160a01b62ffffff871602178155600480546001600160801b0319166001600160801b0386161790558861046b576104668761062a565b61046d565b875b90505f8961047b5788610484565b6104848861062a565b60408051848152602081018390526001600160a01b038a16818301526001600160801b0388166060820152600289900b60808201529051919250339182917fc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67919081900360a00190a350505050505050505050565b80356001600160a01b038116811461050f575f80fd5b919050565b8035600281900b811461050f575f80fd5b80356001600160801b038116811461050f575f80fd5b5f805f806080858703121561054e575f80fd5b610557856104f9565b935061056560208601610514565b9250604085013561ffff8116811461057b575f80fd5b915061058960608601610525565b905092959194509250565b80151581146105a1575f80fd5b50565b5f805f805f8060c087890312156105b9575f80fd5b86356105c481610594565b955060208701359450604087013593506105e0606088016104f9565b92506105ee60808801610514565b91506105fc60a08801610525565b90509295509295509295565b5f60208284031215610618575f80fd5b815161062381610594565b9392505050565b5f600160ff1b820161064a57634e487b7160e01b5f52601160045260245ffd5b505f039056fea2646970667358221220492fd2050a35f65b8045bdcc2057caf77c1aed86d203b17fd05c21e978a89f0d64736f6c63430008170033 \ No newline at end of file diff --git a/tests/event_ground_truth.rs b/tests/event_ground_truth.rs new file mode 100644 index 0000000..e382d32 --- /dev/null +++ b/tests/event_ground_truth.rs @@ -0,0 +1,317 @@ +//! **Differential ground-truth test** for the event → state pipeline (Phase 4). +//! +//! The decisive correctness check: does feeding the *real emitted logs* of a swap +//! into our event processor reproduce the *exact* state a real EVM execution +//! produced? We run a swap in a ground-truth revm instance and replay only its +//! logs into a twin cache, then assert the token balances and the packed pool +//! `slot0` (price/tick) match bit-for-bit. +//! +//! Setup (an offline stand-in for RPC-fetched state): +//! 1. Deploy two ERC-20 tokens (the `MockERC20` fixture, balances at slot 3) and a +//! `TestV3Pool` (`fixtures/EventGroundTruthPool.sol`) whose `slot0` is a Solidity +//! **struct** with the identical field widths to `UniswapV3Pool.Slot0` — so the +//! *compiler* (not this test) does the real bit-packing, and our +//! `StateUpdate::SlotMasked` is the thing under test. Seed pool liquidity and a +//! swapper balance. +//! 2. Build the identical pre-swap state in a second ("event-driven") cache. The +//! deploy sequence is deterministic, so the token/pool addresses match. +//! 3. Execute a real `swap` against the ground-truth cache (committing) and +//! capture the emitted logs (two ERC-20 `Transfer`s + the canonical `Swap`). +//! 4. Feed only those logs into the event-driven cache via `EventPipeline`, then +//! assert its balances and `slot0` equal the ground-truth cache's. +//! +//! Runs fully offline. Requires the `protocols` feature (the UniswapV3 adapter). +#![cfg(feature = "protocols")] + +mod common; + +use std::sync::Arc; + +use alloy_primitives::aliases::{I24, U160}; +use alloy_primitives::{Address, Bytes, Log, U256, hex, keccak256}; +use alloy_sol_types::{SolCall, SolValue, sol}; +use anyhow::{Result, anyhow}; +use common::{MOCK_ERC20_CREATION_HEX, install_default_account, setup_cache}; +use evm_fork_cache::cache::{EvmCache, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT}; +use evm_fork_cache::deploy::{build_init_code, encode_constructor_args}; +use evm_fork_cache::events::{DecoderRegistry, EventPipeline}; +use evm_fork_cache::{Erc20TransferDecoder, UniswapV3Decoder, UniswapV3Layout}; +use revm::context::result::ExecutionResult; + +sol! { + interface Token { + function _mint(address to, uint256 amount) external; + function approve(address spender, uint256 amount) external returns (bool); + } + interface Pool { + function initialize(uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint128 liquidity) external; + function swap(bool zeroForOne, uint256 amountIn, uint256 amountOut, uint160 newSqrtPriceX96, int24 newTick, uint128 newLiquidity) external; + } +} + +const POOL_CREATION_HEX: &str = include_str!("../fixtures/test_v3_pool_creation.hex"); + +/// The MockERC20 balance mapping slot (`mapping(address => uint256)` at slot 3). +const BALANCE_SLOT: u64 = 3; + +/// Pre-swap parameters, shared by both caches. +const INIT_SQRT_PRICE: u128 = 1u128 << 96; // 2^96 +const INIT_TICK: i32 = 100; +const INIT_OBS_INDEX: u16 = 7; // non-zero, to prove it survives the swap +const INIT_LIQUIDITY: u128 = 1_000_000; +const POOL_RESERVE: u128 = 1_000_000; +const SWAPPER_TOKEN0: u128 = 500_000; + +/// Swap outcome (the test plays the role of the router specifying it). token0 in, +/// token1 out; a *negative* post-swap tick and a full-width `sqrtPriceX96` stress +/// the slot0 packing/sign handling. +const AMOUNT_IN: u128 = 120_000; +const AMOUNT_OUT: u128 = 80_000; +const NEW_TICK: i32 = -50; +const NEW_LIQUIDITY: u128 = 1_050_000; + +fn deployer() -> Address { + Address::repeat_byte(0xd0) +} +fn swapper() -> Address { + Address::repeat_byte(0x5a) +} + +/// Hashed `balanceOf[owner]` storage key. +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes(keccak256((owner, U256::from(BALANCE_SLOT)).abi_encode()).0) +} + +fn call(cache: &mut EvmCache, from: Address, to: Address, data: Vec) -> Result<()> { + match cache.call_raw(from, to, Bytes::from(data), true)? { + ExecutionResult::Success { .. } => Ok(()), + other => Err(anyhow!("call to {to} failed: {other:?}")), + } +} + +/// Build the identical pre-swap state in `cache`, returning `(token0, token1, pool)`. +/// +/// The deploy order is fixed, so the deterministic `CREATE` addresses are the same +/// across caches (essential — the captured logs reference these addresses). +fn build_state(cache: &mut EvmCache) -> Result<(Address, Address, Address)> { + install_default_account(cache, Address::ZERO); // coinbase + install_default_account(cache, deployer()); + install_default_account(cache, swapper()); + + // CREATE addresses are deterministic from (deployer, nonce). Pre-install them + // as empty accounts so revm's CREATE collision-check reads the local overlay + // instead of falling through to a (mocked, empty) RPC fetch. + for nonce in 0..3 { + install_default_account(cache, deployer().create(nonce)); + } + + let erc20_creation = hex::decode(MOCK_ERC20_CREATION_HEX.trim())?; + let token0 = cache.deploy_contract( + deployer(), + build_init_code( + &erc20_creation, + // uint8 encodes as a right-aligned 32-byte word, identical to U256. + encode_constructor_args(("Token0".to_string(), "T0".to_string(), U256::from(18))), + ), + )?; + let token1 = cache.deploy_contract( + deployer(), + build_init_code( + &erc20_creation, + encode_constructor_args(("Token1".to_string(), "T1".to_string(), U256::from(18))), + ), + )?; + let pool_creation = hex::decode(POOL_CREATION_HEX.trim())?; + let pool = cache.deploy_contract( + deployer(), + build_init_code(&pool_creation, encode_constructor_args((token0, token1))), + )?; + + // Initialize the pool's packed slot0 + liquidity. + call( + cache, + deployer(), + pool, + Pool::initializeCall { + sqrtPriceX96: U160::from(INIT_SQRT_PRICE), + tick: I24::try_from(INIT_TICK).unwrap(), + observationIndex: INIT_OBS_INDEX, + liquidity: INIT_LIQUIDITY, + } + .abi_encode(), + )?; + + // Seed reserves into the pool and the input balance into the swapper. + for token in [token0, token1] { + call( + cache, + deployer(), + token, + Token::_mintCall { + to: pool, + amount: U256::from(POOL_RESERVE), + } + .abi_encode(), + )?; + } + call( + cache, + deployer(), + token0, + Token::_mintCall { + to: swapper(), + amount: U256::from(SWAPPER_TOKEN0), + } + .abi_encode(), + )?; + // Swapper approves the pool to pull the input. + call( + cache, + swapper(), + token0, + Token::approveCall { + spender: pool, + amount: U256::from(AMOUNT_IN), + } + .abi_encode(), + )?; + + Ok((token0, token1, pool)) +} + +/// Snapshot the four balances + the packed slot0 + liquidity we compare on. +fn observe( + cache: &EvmCache, + t0: Address, + t1: Address, + pool: Address, +) -> Vec<(String, Option)> { + vec![ + ( + "swapper.t0".into(), + cache.cached_storage_value(t0, balance_slot(swapper())), + ), + ( + "swapper.t1".into(), + cache.cached_storage_value(t1, balance_slot(swapper())), + ), + ( + "pool.t0".into(), + cache.cached_storage_value(t0, balance_slot(pool)), + ), + ( + "pool.t1".into(), + cache.cached_storage_value(t1, balance_slot(pool)), + ), + ( + "pool.slot0".into(), + cache.cached_storage_value(pool, V3_SLOT0_SLOT), + ), + ( + "pool.liquidity".into(), + cache.cached_storage_value(pool, V3_LIQUIDITY_SLOT), + ), + ] +} + +#[tokio::test(flavor = "multi_thread")] +async fn event_processor_reproduces_ground_truth_swap() -> Result<()> { + // 1. Ground-truth cache: build state, snapshot the pre-swap state, execute the + // real swap, capture logs + the post-swap state. + let mut truth = setup_cache().await?; + let (token0, token1, pool) = build_state(&mut truth)?; + let pre_swap = observe(&truth, token0, token1, pool); + + let swap_data = Pool::swapCall { + zeroForOne: true, + amountIn: U256::from(AMOUNT_IN), + amountOut: U256::from(AMOUNT_OUT), + newSqrtPriceX96: U160::MAX, // full-width: stresses the [0,160) boundary + newTick: I24::try_from(NEW_TICK).unwrap(), + newLiquidity: NEW_LIQUIDITY, + } + .abi_encode(); + + let logs: Vec = match truth.call_raw(swapper(), pool, Bytes::from(swap_data), true)? { + ExecutionResult::Success { logs, .. } => logs, + other => return Err(anyhow!("ground-truth swap failed: {other:?}")), + }; + // Two ERC-20 Transfers (token0 in, token1 out) + the canonical Swap. + assert_eq!(logs.len(), 3, "expected 2 Transfer logs + 1 Swap log"); + + // 2. Event-driven cache: identical pre-swap state, addresses match. + let mut driven = setup_cache().await?; + let (token0_d, token1_d, pool_d) = build_state(&mut driven)?; + assert_eq!( + (token0, token1, pool), + (token0_d, token1_d, pool_d), + "deterministic deploy addresses must match across caches" + ); + + // Pre-swap, the freshly-built driven cache equals truth's pre-swap snapshot + // (sanity: the deterministic setup really is identical). + assert_eq!(observe(&driven, token0, token1, pool), pre_swap); + + // 3. Feed ONLY the swap's logs into the event pipeline. + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from( + BALANCE_SLOT, + )))); + registry.register(Arc::new( + UniswapV3Decoder::new().with_pool(pool, UniswapV3Layout::uniswap(60)), + )); + let mut pipeline = EventPipeline::new(registry); + let digest = pipeline.ingest_logs(&mut driven, 1, &logs); + + // All three logs decoded to applied changes; nothing skipped (hot state). + assert_eq!(digest.decoded_logs, 3, "all 3 logs should decode"); + assert!( + !digest.applied.has_skipped(), + "no cold skips: {:?}", + digest.applied.skipped_masks + ); + + // 4. The decisive check: event-driven state == ground-truth state, field by field. + let truth_state = observe(&truth, token0, token1, pool); + let driven_state = observe(&driven, token0, token1, pool); + assert_eq!( + driven_state, truth_state, + "event-driven state must match the ground-truth EVM execution" + ); + + // Spell out the headline invariants explicitly (defensive, human-readable). + let slot0_truth = truth.cached_storage_value(pool, V3_SLOT0_SLOT).unwrap(); + let slot0_driven = driven.cached_storage_value(pool, V3_SLOT0_SLOT).unwrap(); + assert_eq!( + slot0_driven, slot0_truth, + "packed slot0 (price/tick) must match bit-for-bit" + ); + // The price actually moved, and the observation/unlocked bits survived. + assert_ne!(slot0_driven, U256::from(INIT_SQRT_PRICE), "slot0 changed"); + assert_eq!( + (slot0_driven >> 240) & U256::from(1), + U256::from(1), + "unlocked bit preserved" + ); + assert_eq!( + (slot0_driven >> 184) & U256::from(0xFFFF), + U256::from(INIT_OBS_INDEX), + "obs index preserved" + ); + + // Balances match the ground truth (token0 in, token1 out). + assert_eq!( + driven + .cached_storage_value(token0, balance_slot(swapper())) + .unwrap(), + U256::from(SWAPPER_TOKEN0 - AMOUNT_IN), + ); + assert_eq!( + driven + .cached_storage_value(token1, balance_slot(swapper())) + .unwrap(), + U256::from(AMOUNT_OUT), + ); + + Ok(()) +}