From e928c1f96450a70b64c3766775ac76cfc714a4aa Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Thu, 18 Jun 2026 15:01:21 +0100 Subject: [PATCH] Remove protocols feature from core crate Move the public surface back to a generic EVM simulation engine by removing the protocols feature, AMM metadata/injectors, V3 event adapter, protocol fixtures/tests/benches, and protocol-only examples. Make immutable metadata token-decimals-only with a v2 cache format, rename storage purge helpers to contract terminology, and update release docs/CI/benchmarks around the new crate boundary. --- .github/workflows/ci.yml | 8 - CHANGELOG.md | 65 +-- CONTRIBUTING.md | 17 +- Cargo.toml | 22 +- README.md | 46 +- benches/event_pipeline.rs | 89 +-- benches/rpc_mainnet.rs | 57 +- benches/storage_keys.rs | 34 -- docs/KNOWN_ISSUES.md | 61 +- docs/ROADMAP.md | 101 ++-- docs/phase-2-spec.md | 16 +- docs/phase-3-spec.md | 36 +- docs/phase-4-spec.md | 10 +- docs/phase-5-spec.md | 14 +- examples/custom_revert_errors.rs | 8 +- examples/multi_hop_swap.rs | 87 --- examples/prefetch_registry.rs | 16 +- examples/reactive_cache.rs | 344 +++++------- examples/state_update_apply.rs | 27 +- examples/storage_access_list.rs | 16 +- fixtures/EventGroundTruthPool.sol | 126 ----- fixtures/README.md | 25 - fixtures/test_v3_pool_creation.hex | 1 - src/cache/journal_access_list.rs | 44 ++ src/cache/metadata.rs | 115 +--- src/cache/mod.rs | 862 ++--------------------------- src/cache/storage_keys.rs | 222 -------- src/cache/tick_snapshot.rs | 221 -------- src/events/mod.rs | 15 +- src/events/uniswap_v3.rs | 399 ------------- src/lib.rs | 8 +- src/multicall.rs | 2 +- src/state_update.rs | 40 +- tests/cache_state.rs | 20 +- tests/event_ground_truth.rs | 317 ----------- tests/event_pipeline.rs | 377 +------------ tests/freshness.rs | 4 +- tests/public_release_surface.rs | 192 +++++++ tests/serialization_roundtrip.rs | 196 +------ tests/state_update.rs | 108 +--- tests/storage_keys.rs | 52 -- 41 files changed, 730 insertions(+), 3690 deletions(-) delete mode 100644 benches/storage_keys.rs delete mode 100644 examples/multi_hop_swap.rs delete mode 100644 fixtures/EventGroundTruthPool.sol delete mode 100644 fixtures/test_v3_pool_creation.hex create mode 100644 src/cache/journal_access_list.rs delete mode 100644 src/cache/storage_keys.rs delete mode 100644 src/cache/tick_snapshot.rs delete mode 100644 src/events/uniswap_v3.rs delete mode 100644 tests/event_ground_truth.rs create mode 100644 tests/public_release_surface.rs delete mode 100644 tests/storage_keys.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c06e89..01fc583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,17 +29,9 @@ jobs: - name: Clippy run: cargo clippy --all-targets --no-deps -- -D warnings - # The generic engine must build and lint cleanly without the `protocols` - # feature (which gates the DeFi-specific surface). - - name: Clippy (no default features) - run: cargo clippy --lib --no-default-features --no-deps -- -D warnings - - name: Tests (all targets) run: cargo test --all-targets - - name: Tests (no default features) - run: cargo test --no-default-features - - name: Doc tests run: cargo test --doc diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb26c9..1310bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,27 @@ surface freezes at 1.0. This is the first release line. It captures the work done across the pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). +### Changed + +- **Breaking:** extracted the old in-crate AMM adapter surface before public + release. Protocol-specific storage layouts, protocol metadata, injector + helpers, tick snapshots, and protocol log decoders belong in `evm-amm-state`; + this crate now exposes only the generic fork cache, simulation, freshness, + state-update, ERC-20 decoding, and event-pipeline primitives. +- **Breaking:** `ImmutableDataCache` is now token-decimals-only and its on-disk + format version is bumped to `2`, so older metadata files with protocol payloads + are treated as stale cache misses. +- **Breaking:** renamed the remaining generic storage-purge helpers from + pool-oriented names to contract-oriented names: + `has_contract_storage`, `contract_storage_slot_count`, + `purge_contract_storage`, and `purge_contract_slots`. The old pool-named + aliases were removed rather than deprecated before the first public release. + ### Added - **Forked EVM cache** (`cache::EvmCache`) backed by `foundry-fork-db` with lazy - RPC loading and on-disk persistence for accounts, storage, bytecode, immutable - metadata, and Uniswap V3-style tick snapshots. + RPC loading and on-disk persistence for accounts, storage, bytecode, and + immutable metadata. - **`EvmCacheBuilder`** — a fluent constructor (`EvmCache::builder(provider)`) subsuming the positional `with_cache` / `from_backend` constructors, with block pin, EVM spec, cache-config, and shared-memory-capacity configuration. @@ -41,9 +57,8 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). overlay-if-present, no new overlay account materialized), returning a structured `StateDiff` (`SlotChange`s, `AccountChange`s, `PurgeRecord`s) that records only actual changes. The existing `inject_storage_batch_fresh` / `purge_account` / - `purge_pool_storage` / `purge_pool_slots` writers and the freshness - correction-drain are refolded onto it (signatures unchanged); generic, builds - with `--no-default-features`. + `purge_contract_storage` / `purge_contract_slots` writers and the freshness + correction-drain are refolded onto it (signatures unchanged). - **Relative / read-modify-write state updates** (`state_update`, Phase 3 §15) — a saturating `SlotDelta` (`Add`/`Sub`, clamping at `U256::MAX`/`U256::ZERO`), a `StateUpdate::SlotDelta { address, slot, delta }` variant (with the @@ -55,7 +70,7 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). field for the caller to fetch+seed and retry. `skipped` is informational metadata and does not affect `StateDiff::is_empty` / `len` (changes-only). Adding the `StateDiff.skipped` field is a struct change permitted under the - pre-1.0 break policy. Generic core (builds `--no-default-features`). + pre-1.0 break policy. Generic core. - **Post-audit state-update remediation** (`state_update`, Phase 3 §16): - **`serde`** — `Serialize`/`Deserialize` derived (unconditionally) on the whole vocabulary (`SlotDelta`, `StateUpdate`, `AccountPatch`, `PurgeScope`) and the @@ -101,13 +116,6 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). 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 @@ -118,8 +126,8 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - **`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 + selected bits of a **packed** storage word 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 @@ -143,9 +151,6 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). during decoder setup instead of relying on the warning-only ergonomic path. - **Two-stage prefetch registry** (`prefetch_registry`) for cross-cycle storage-slot pre-warming. -- **`protocols` feature** (default-on) gating the Uniswap V2/V3 storage layouts, - V3 tick snapshots, and `inject_v3_*` / `inject_v2_pool_metadata` helpers, so - the generic engine builds with `--no-default-features`. - **Copy-on-write snapshots** (Phase 5, Pillar A) — `create_snapshot` is now a two-tier copy-on-write view instead of an O(total state) deep clone. The cold `BlockchainDb` index (layer 2) is flattened once into an internal, immutable, @@ -186,13 +191,12 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - **Exact access-list RLP data-gas helper** — `access_list::access_list_rlp_data_gas(&AccessList)` returns the EIP-2930 RLP calldata gas for an access list and backs the L2 profitability calculation. -- **Versioned on-disk cache envelope** — binary EVM state, bytecode, - `ImmutableDataCache`, and V3 tick snapshot cache files now start with - crate-specific magic bytes plus a `u32` version before the bincode payload. +- **Versioned on-disk cache envelope** — binary EVM state, bytecode, and + `ImmutableDataCache` files now start with crate-specific magic bytes plus a + `u32` version before the bincode payload. - **Public-release CI gates** — the GitHub Actions workflow now enforces format, - clippy on all targets, no-default-feature library linting, all-target tests, - no-default-feature tests, doctests, warning-free docs, bench compilation, - package verification, and the MSRV library check. + clippy on all targets, all-target tests, doctests, warning-free docs, bench + compilation, package verification, and the MSRV library check. ### Changed @@ -227,20 +231,11 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). header. - **Legacy raw-bincode cache files are treated as misses** — the versioned cache envelope intentionally rejects unversioned `evm_state.bin`, `bytecodes.bin`, - `immutable_data.bin`, and `v3_tick_snapshots.bin` payloads rather than trying - to deserialize ambiguous layouts. + and `immutable_data.bin` payloads rather than trying to deserialize ambiguous + layouts. - Simulation entry points that distinguish failure modes return `SimulationResult` (`Result`), separating decoded reverts, EVM halts, and host errors. `SimulationErrorKind` remains as a deprecated alias. -- **`inject_v2_pool_metadata` / `inject_v3_tick_bitmap*` / `inject_v3_ticks*` - (`protocols`) now write through both cache layers** (Phase 3, Decision 2). - Previously these wrote only the CacheDB overlay (layer 1); they are now folded - onto the write-through `StateUpdate::Slot` primitive, so the injected slots also - land in the BlockchainDb backend (layer 2). Signatures and return values are - unchanged and the visible `token0()`/`tickBitmap()`/`ticks()` reads are the - same; only the slot *placement* across layers changed. See - [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md). (The cold-backfill - `inject_storage_batch` keeps its layer-2-only intent and is unchanged.) ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 926a005..3da41b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,6 @@ pass **all** of them. Run them locally before pushing: ```sh cargo fmt --all --check cargo clippy --all-targets --no-deps -- -D warnings -# The generic engine must also build and lint cleanly without the protocols feature: -cargo clippy --lib --no-default-features --no-deps -- -D warnings cargo test RUSTDOCFLAGS="-D warnings" cargo doc --no-deps ``` @@ -36,7 +34,6 @@ A convenience one-liner: ```sh 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 ``` @@ -48,15 +45,13 @@ dedicated CI job (`cargo check --lib --locked` on 1.88). Do not use std APIs newer than 1.88 in the library. Dev-only code (examples, benches, tests) is not MSRV-constrained. -### Feature configurations +### Crate boundary -The `protocols` feature (default on) gates DeFi protocol knowledge. The generic -simulation engine must compile and lint with `--no-default-features`. Any new -DeFi-specific surface (protocol storage layouts, pool injection) must be gated -behind `protocols`; generic machinery stays always-on. When you add a public -item behind `#[cfg(feature = "protocols")]`, also add -`#[cfg_attr(docsrs, doc(cfg(feature = "protocols")))]` so docs.rs renders the -feature badge. +Keep this crate focused on the generic EVM simulation engine: cache mechanics, +snapshots/overlays, freshness, access lists, revert decoding, ERC-20 helpers, +multicall, deployment, and event-pipeline primitives. Protocol-specific storage +layouts, AMM state, and DeFi adapters belong in `evm-amm-state` or downstream +applications. ## Tests, benchmarks, and examples diff --git a/Cargo.toml b/Cargo.toml index 5f7cf9b..845b370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,33 +4,17 @@ version = "0.1.0" edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" -description = "Forked EVM state cache, snapshots, overlays, and simulation utilities for DeFi search" +description = "Forked EVM state cache, snapshots, overlays, and simulation utilities for EVM search" keywords = ["evm", "revm", "defi", "simulation", "ethereum"] categories = ["cryptography::cryptocurrencies", "simulation", "caching"] readme = "README.md" repository = "https://github.com/KaiCode2/evm-fork-cache" documentation = "https://docs.rs/evm-fork-cache" -# Build docs.rs with every feature enabled so the `protocols` surface is -# documented, and pass `--cfg docsrs` so feature-gated items render an -# "available on crate feature X" badge (see `#![cfg_attr(docsrs, feature(doc_cfg))]` -# in lib.rs). `docsrs` is only set on docs.rs and never affects local/CI builds. -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] - # Standalone workspace root: keeps this crate from being absorbed by any # ancestor-directory workspace and gives it its own Cargo.lock. [workspace] -[features] -# `protocols` gates DeFi protocol knowledge (Uniswap V2/V3-style storage layouts, -# V3 tick snapshots, and the `inject_v3_*` / `inject_v2_pool_metadata` helpers). -# On by default; build with `--no-default-features` for the generic engine alone. -# This surface is slated to move into the `evm-amm-state` crate. -default = ["protocols"] -protocols = [] - [dependencies] alloy-consensus = "1.1.2" alloy-contract = "1.0.38" @@ -67,10 +51,6 @@ tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } name = "revert_decoding" harness = false -[[bench]] -name = "storage_keys" -harness = false - [[bench]] name = "create3" harness = false diff --git a/README.md b/README.md index df64313..0c6d13b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![docs.rs](https://img.shields.io/docsrs/evm-fork-cache)](https://docs.rs/evm-fork-cache) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) -A forked-EVM **simulation engine** for DeFi search, MEV, and backtesting — built +A forked-EVM **simulation engine** for EVM search, MEV, and backtesting — built on [`revm`], [`alloy`], and [`foundry-fork-db`]. It exists to answer one question fast and repeatedly: *"if I sent this @@ -19,7 +19,7 @@ re-deriving state on every call. ## Why it exists -A DeFi search loop evaluates many hypothetical transactions against the *same* +A search loop evaluates many hypothetical transactions against the *same* recent chain state. Doing that with a naive fork means re-fetching state, paying RPC latency on the hot path, and either sharing mutable EVM state across tasks (unsafe) or deep-cloning a fork per candidate (slow). `evm-fork-cache` is built @@ -29,7 +29,7 @@ around three capabilities that target exactly this workload: hand a cheap `Arc` clone to each task, and run many isolated simulations in parallel. No task can observe another's writes. 2. **Targeted state sync** — refresh or purge *specific* accounts and storage - slots in place (no RPC on the hot path), so hot pool state stays correct + slots in place (no RPC on the hot path), so hot contract state stays correct without re-forking. 3. **Freshness as a first-class concept** — the engine tracks what it can trust, for how long, and verifies the rest. The optimistic verify-and-rerun loop @@ -46,8 +46,7 @@ around three capabilities that target exactly this workload: ## What it provides today - **Forked EVM cache** backed by `foundry-fork-db` with lazy RPC loading and - on-disk persistence for accounts, storage, bytecode, immutable metadata, and - Uniswap V3-style tick snapshots. + on-disk persistence for accounts, storage, bytecode, and immutable metadata. - **Snapshots and overlays** — `create_snapshot()` produces an immutable, `Send + Sync` point-in-time view; each `EvmOverlay` is a cheap clone that simulates in isolation, ideal for parallel candidate evaluation. @@ -55,12 +54,12 @@ around three capabilities that target exactly this workload: policy, mechanism) plus an optimistic verify-and-rerun execution loop with deferred validation. See the [`freshness`](src/freshness.rs) module. - **Targeted state manipulation** — direct storage injection, account/slot - purge, and balance overrides for pool-state refresh workflows. -- **Event-to-state pipeline** — decode ERC-20 and Uniswap V3 logs into - `StateUpdate`s, apply them in order, purge touched state on reorg, and - reconcile sampled event-derived slots against RPC. The crate ships the generic - driver and in-memory examples; production WebSocket subscription/reorg wiring - stays with the consumer. + purge, and balance overrides for hot-state refresh workflows. +- **Event-to-state pipeline** — decode logs into `StateUpdate`s, apply them in + order, purge touched state on reorg, and reconcile sampled event-derived slots + against RPC. The crate ships the generic driver, the ERC-20 `Transfer` decoder, + and in-memory examples; production WebSocket subscription/reorg wiring and + protocol-specific decoders stay with the consumer or companion crates. - **ERC20 helpers** — balances, allowances, decimals, and controlled balance mutation (including automatic balance-slot discovery) for simulations. - **Transfer-inspector simulation** that reports per-token balance deltas @@ -176,7 +175,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. | +| `reactive_cache` | Advanced | Decode ERC-20 `Transfer` logs 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): @@ -187,7 +186,6 @@ endpoint (they print instructions and exit if it is unset): | `multicall_batch` | Intermediate | Batch many view calls through Multicall3 in one pass. | | `multicall_with_error_handling` | Intermediate | Batch with `allowFailure`; read partial results when a call reverts. | | `fork_override_balance` | Intermediate | Discover a real token's balance slot and override it. | -| `multi_hop_swap` | Advanced | Quote a 2-hop Uniswap V2 swap (WETH→USDC→DAI) against live reserves. | ```sh cargo run --example revert_decoding @@ -234,10 +232,9 @@ useful for A/B comparison: | `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. | +| `event_pipeline` | Per-decoder cost (ERC-20 `Transfer`, generic slot marker), `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. | | `create3` | CREATE3 address derivation. | ```sh @@ -246,7 +243,7 @@ cargo bench --bench simulation # one suite ``` The `rpc_mainnet` bench runs against **live mainnet state** to validate -real-contract performance (USDC `balanceOf`, a Uniswap V2 `getReserves`). It is +real-contract performance (USDC `balanceOf`, `totalSupply`, and `allowance`). It is gated behind the `RPC_URL` environment variable and is skipped (not failed) when it is unset, so `cargo bench` stays offline and CI-reproducible by default: @@ -254,18 +251,13 @@ it is unset, so `cargo bench` stays offline and CI-reproducible by default: RPC_URL=https://eth.llamarpc.com cargo bench --bench rpc_mainnet ``` -## Cargo features +## Crate boundary -| Feature | Default | Gates | -| --- | --- | --- | -| `protocols` | ✅ | DeFi protocol knowledge: Uniswap V2/V3-style storage layouts, V3 tick snapshots, and the `inject_v3_*` / `inject_v2_pool_metadata` helpers. | - -Build with `--no-default-features` for the **generic simulation engine** alone: -the cache core, snapshots/overlays, freshness control plane, access lists, the -revert decoder, ERC20 helpers, multicall, deploy, and CREATE3. The `protocols` -surface is slated to move into a separate `evm-amm-state` crate (see the -[roadmap](docs/ROADMAP.md)); keeping it behind a default feature today lets the -generic core build and lint cleanly without it (CI enforces both configurations). +`evm-fork-cache` is the generic simulation engine: cache, snapshots/overlays, +freshness control, access lists, revert decoding, ERC-20 helpers, multicall, +deployment, CREATE3, and event-pipeline primitives. AMM state tracking, +protocol-specific storage layouts, and DeFi adapters belong in the companion +`evm-amm-state` crate or downstream applications. ## Stability diff --git a/benches/event_pipeline.rs b/benches/event_pipeline.rs index 3fbb248..e3727be 100644 --- a/benches/event_pipeline.rs +++ b/benches/event_pipeline.rs @@ -1,7 +1,7 @@ //! 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`), +//! - **decode** cost per decoder kind (ERC-20 `Transfer`, generic slot marker), //! 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); @@ -11,33 +11,30 @@ //! 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_primitives::{Address, Bytes, 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::cache::EvmCache; use evm_fork_cache::events::{DecoderRegistry, EventDecoder, EventPipeline, StateView}; -use evm_fork_cache::{Erc20TransferDecoder, StateUpdate, UniswapV3Decoder, UniswapV3Layout}; +use evm_fork_cache::{Erc20TransferDecoder, StateUpdate}; 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); +const MARKER: 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 +/// A cache with `TOKEN` and `MARKER` 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())); @@ -46,7 +43,7 @@ fn seeded_cache(rt: &Runtime) -> EvmCache { hex::decode(MOCK_ERC20_RUNTIME_HEX.trim()).unwrap(), )); let code_hash = runtime.hash_slow(); - for addr in [TOKEN, POOL] { + for addr in [TOKEN, MARKER] { cache.db_mut().insert_account_info( addr, AccountInfo { @@ -65,11 +62,6 @@ fn seeded_cache(rt: &Runtime) -> EvmCache { 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( @@ -79,44 +71,10 @@ fn transfer_log(token: Address, from: Address, to: Address, value: U256) -> Log ) } -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() +struct EmptyView; +impl StateView for EmptyView { + fn storage(&self, _address: Address, _slot: U256) -> Option { + None } } @@ -140,30 +98,15 @@ fn bench_decode(c: &mut Criterion) { Address::repeat_byte(0x22), U256::from(100), ); - let empty = MapView(HashMap::new()); + let empty = EmptyView; 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))) + let marker = AbsDecoder; + let marker_log = Log::new_unchecked(MARKER, vec![], Bytes::new()); + group.bench_function("absolute_slot_marker", |b| { + b.iter(|| black_box(marker.decode(black_box(&marker_log), &empty))) }); group.finish(); diff --git a/benches/rpc_mainnet.rs b/benches/rpc_mainnet.rs index 9f2a9d7..a6c3c12 100644 --- a/benches/rpc_mainnet.rs +++ b/benches/rpc_mainnet.rs @@ -9,11 +9,11 @@ //! RPC_URL=https://eth.llamarpc.com cargo bench --bench rpc_mainnet //! ``` //! -//! They measure warm-cache throughput of view calls against well-known mainnet -//! contracts (USDC `balanceOf`, a Uniswap V2 pair `getReserves`). The cache is -//! warmed once before timing so each measured iteration reads from the local -//! cache rather than re-fetching over RPC — that warm-reuse path is exactly what -//! a search loop hammers between block updates. +//! They measure warm-cache throughput of view calls against a well-known mainnet +//! ERC-20 contract (USDC `balanceOf`, `totalSupply`, and `allowance`). The cache +//! is warmed once before timing so each measured iteration reads from the local +//! cache rather than re-fetching over RPC — that warm-reuse path is exactly what a +//! search loop hammers between block updates. //! //! RPC-touching calls run inside `rt.block_on(..)` because `EvmCache` fetches //! missing state via `tokio::task::block_in_place`, which requires a @@ -37,15 +37,15 @@ const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); /// A consistently USDC-holding address (an exchange hot wallet). The exact /// balance is irrelevant to a perf benchmark; `balanceOf` succeeds regardless. const HOLDER: Address = address!("28C6c06298d514Db089934071355E5743bf21d60"); -/// The Uniswap V2 USDC/WETH pair. -const UNIV2_USDC_WETH: Address = address!("B4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"); +/// Arbitrary spender for an `allowance` view call. A zero allowance is fine for +/// the benchmark; the call path still exercises contract storage reads. +const SPENDER: Address = address!("000000000022d473030f116ddee9f6b43ac78ba3"); sol! { interface IErc20 { function balanceOf(address account) external view returns (uint256); - } - interface IUniswapV2Pair { - function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); + function totalSupply() external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); } } @@ -74,13 +74,21 @@ fn bench_rpc_mainnet(c: &mut Criterion) { ); let balance_of = Bytes::from(IErc20::balanceOfCall { account: HOLDER }.abi_encode()); - let get_reserves = Bytes::from(IUniswapV2Pair::getReservesCall {}.abi_encode()); + let total_supply = Bytes::from(IErc20::totalSupplyCall {}.abi_encode()); + let allowance = Bytes::from( + IErc20::allowanceCall { + owner: HOLDER, + spender: SPENDER, + } + .abi_encode(), + ); - // Warm the cache once per target so the timed iterations are warm reads. + // Warm the cache once per call shape so the timed iterations are warm reads. let warm = rt.block_on(async { let a = cache.call_raw(HOLDER, USDC, balance_of.clone(), false); - let b = cache.call_raw(Address::ZERO, UNIV2_USDC_WETH, get_reserves.clone(), false); - (a, b) + let b = cache.call_raw(HOLDER, USDC, total_supply.clone(), false); + let c = cache.call_raw(HOLDER, USDC, allowance.clone(), false); + (a, b, c) }); assert!( matches!(warm.0, Ok(ExecutionResult::Success { .. })), @@ -89,9 +97,14 @@ fn bench_rpc_mainnet(c: &mut Criterion) { ); assert!( matches!(warm.1, Ok(ExecutionResult::Success { .. })), - "Uniswap V2 getReserves warm-up should succeed: {:?}", + "USDC totalSupply warm-up should succeed: {:?}", warm.1 ); + assert!( + matches!(warm.2, Ok(ExecutionResult::Success { .. })), + "USDC allowance warm-up should succeed: {:?}", + warm.2 + ); let mut group = c.benchmark_group("rpc_mainnet_warm"); group.bench_function("usdc_balanceOf", |b| { @@ -102,12 +115,18 @@ fn bench_rpc_mainnet(c: &mut Criterion) { black_box(r); }) }); - group.bench_function("univ2_getReserves", |b| { + group.bench_function("usdc_totalSupply", |b| { + b.iter(|| { + let r = rt + .block_on(async { cache.call_raw(HOLDER, USDC, total_supply.clone(), false) }) + .unwrap(); + black_box(r); + }) + }); + group.bench_function("usdc_allowance", |b| { b.iter(|| { let r = rt - .block_on(async { - cache.call_raw(Address::ZERO, UNIV2_USDC_WETH, get_reserves.clone(), false) - }) + .block_on(async { cache.call_raw(HOLDER, USDC, allowance.clone(), false) }) .unwrap(); black_box(r); }) diff --git a/benches/storage_keys.rs b/benches/storage_keys.rs deleted file mode 100644 index 4703694..0000000 --- a/benches/storage_keys.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Microbenchmarks for Uniswap V3-style storage-key derivation. - -use std::hint::black_box; - -use criterion::{Criterion, criterion_group, criterion_main}; -use evm_fork_cache::cache::{v3_tick_bitmap_storage_key, v3_tick_info_storage_keys}; - -fn bench_storage_keys(c: &mut Criterion) { - let mut group = c.benchmark_group("storage_keys"); - - group.bench_function("tick_bitmap_key", |b| { - b.iter(|| v3_tick_bitmap_storage_key(black_box(-128))) - }); - - group.bench_function("tick_info_keys", |b| { - b.iter(|| v3_tick_info_storage_keys(black_box(-887_220))) - }); - - // Deriving keys for a sweep of words, as a tick prefetch would. - group.bench_function("tick_bitmap_keys_x256", |b| { - b.iter(|| { - let mut acc = alloy_primitives::U256::ZERO; - for word in -128i16..128 { - acc ^= v3_tick_bitmap_storage_key(black_box(word)); - } - acc - }) - }); - - group.finish(); -} - -criterion_group!(benches, bench_storage_keys); -criterion_main!(benches); diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index d2e2bc5..da9bfeb 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -51,10 +51,10 @@ Confidence legend: **[V]** verified against the source during review; call, and post-reads. 7. **[FIXED] On-disk cache files carry magic bytes and a version number.** - `binary_state`, `bytecode`, `ImmutableDataCache`, `PrefetchRegistry`, - `SlotObservationTracker`, and V3 tick snapshots now write a crate-specific - magic header plus version `1` before the bincode payload. Unknown - magic/version values and legacy raw-bincode files are treated as cache misses. + `binary_state`, `bytecode`, `ImmutableDataCache`, `PrefetchRegistry`, and + `SlotObservationTracker` now write a crate-specific magic header plus an + explicit version before the bincode payload. Unknown magic/version values and + legacy raw-bincode files are treated as cache misses. 8. **[FIXED] `call_raw_with_access_list` did not revert its checkpoint on a transact error.** Both `EvmCache::call_raw_with_access_list` and @@ -81,19 +81,8 @@ remaining items below are accepted limitations or code-quality/API nits. ## Code-quality nits -1. **[V] Dead branch in `i128_to_u256`** (`cache/storage_keys.rs`): both the - `value >= 0` and `else` arms evaluate the identical `U256::from(value as u128)`. - The two's-complement cast is correct for both signs, so the `if`/`else` can - collapse to one line (keep the explanatory comment). - -2. **[R] V3 tick-snapshot keys serialize as strings.** `V3PoolTickSnapshot` - stringifies `i16`/`i32` tick/word keys for bincode, then `parse()`s them back - in `to_tick_bitmap`/`to_ticks`, silently dropping any key that fails to parse. - A native integer-keyed encoding would be faster and would not fail silently. - -3. **[R] Balancer pool id keyed by `Debug` formatting.** `ImmutableDataCache` - keys `balancer_pools` by `format!("{:?}", pool_id)`. `Debug` output is not a - stable encoding contract; a hex encoding would be safer for a persisted key. +No current code-quality nits are tracked here after the protocol-specific cache +surface was moved out of this crate. ## API ergonomics @@ -146,7 +135,7 @@ remaining items below are accepted limitations or code-quality/API nits. - **Layer-2 unchecked accessors remain an explicit contract boundary (Phase 5).** The snapshot base's growth scan is count/absence-based, which is sufficient for the supported writers: the crate's own mutators (`apply_update`, `inject_storage_batch`, - the `inject_*` helpers, purges, code overrides) explicitly mark the base dirty, + purges, code overrides) explicitly mark the base dirty, and the `foundry-fork-db` `SharedBackend` lazy fetch is append-only at a fixed block (it only inserts on a cache miss, never overwrites in place — a load-bearing invariant noted in `refresh_base`). Direct out-of-band writes through the @@ -167,42 +156,20 @@ remaining items below are accepted limitations or code-quality/API nits. `with_blockchain_db_mut_rehonest_after_storage_overwrite`, `with_blockchain_db_mut_rehonest_after_account_overwrite`) pins it. -- **`protocols` not yet extracted.** The DeFi surface is feature-gated but still - in-crate. The generic core builds and tests with `--no-default-features`, but - extraction into `evm-amm-state` is still planned (roadmap), blocked partly by - `ImmutableDataCache` coupling generic token-decimals with V2/V3/Balancer pool - metadata. +- **Protocol adapters are intentionally out of scope.** AMM state tracking, + protocol-specific storage layouts, and DeFi event adapters now belong in + `evm-amm-state` or downstream crates. This crate provides the generic + `StateUpdate` writer vocabulary, `EventDecoder`/`DecoderRegistry`, the ERC-20 + decoder, and `EventPipeline` orchestration. - **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. + module: `EventDecoder`/`DecoderRegistry`, the ERC-20 adapter, 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 - `StateUpdate::Slot` primitive, so they now write **both** cache layers (backend - + overlay-if-present) instead of the previous overlay-only write. This is a - deliberate normalization (one consistent write path), not a bug: signatures and - return values are unchanged and the visible reads are identical; only the slot - *placement* across layers moved. `tests/state_update.rs` - (`inject_v3_tick_bitmap_writes_through_to_backend`) pins the new behavior, and - it is recorded in `CHANGELOG.md` (`### Changed`). The cold-backfill - `inject_storage_batch` deliberately remains layer-2-only. - **Recent toolchain.** MSRV 1.88 and edition 2024 are intentional and CI-enforced; consumers on older toolchains are not supported. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3209266..c0c8384 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,9 +59,9 @@ RPC node Event-driven sync ← WS logs · new block ## Design principles -1. **Generic core, pluggable protocols.** The simulation engine knows nothing - about Uniswap. DeFi knowledge (slot layouts, event ABIs) lives behind the - `protocols` feature and will eventually move to the `evm-amm-state` crate. +1. **Generic core, external protocol adapters.** The simulation engine knows + nothing about AMM layouts. DeFi knowledge (slot layouts, event ABIs, and AMM + state tracking) lives in `evm-amm-state` or downstream crates. 2. **Honest freshness.** Reuse aggressively where safe; purge loudly where not. Never silently serve stale state. 3. **Correctness is verifiable.** Event-derived state must be reconcilable @@ -73,14 +73,14 @@ RPC node Event-driven sync ← WS logs · new block | Phase | Scope | Status | | --- | --- | --- | | **0** | API hygiene + correctness: drop `amms`, fix `set_block` divergence + `block_in_place` panic, commit the tree. | **Done** (`p0-oss-prep`) | -| **1** | Engine seam: typed errors, configurable tx/block env, hot-path benches, builder, `protocols` feature. | **Done** (`phase-1-engine-seam`) | +| **1** | Engine seam: typed errors, configurable tx/block env, hot-path benches, builder, protocol-adapter extraction path. | **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, ERC-20 + V3 adapters, ingest/reorg/reconcile pipeline. | **Done** (`phase-4-event-pipeline`) | | **5** | COW snapshots (Pillar A): structural sharing; overlay buffer reuse. | **Done** (`phase-5-cow-snapshots`) | Cross-cutting remaining work: call tracer Inspector, full no-provider build split, -protocol/metadata extraction, and production event-transport integrations. +and production event-transport integrations. --- @@ -88,8 +88,9 @@ protocol/metadata extraction, and production event-transport integrations. Goal: lift the crate out of read-only-swap simulation into value-bearing simulation, give it a typed error contract and a real constructor, isolate -protocol knowledge behind a feature, and add the benchmarks that will quantify -the Pillar A rewrite. These are the breaking changes that must precede a 1.0. +protocol knowledge from the generic engine, and add the benchmarks that will +quantify the Pillar A rewrite. These are the breaking changes that must precede +a 1.0. ### 1a — Typed error model @@ -142,31 +143,21 @@ the Pillar A rewrite. These are the breaking changes that must precede a 1.0. speed-mode setter remains as accepted API ergonomics debt (tracked in `docs/KNOWN_ISSUES.md`). -### 1e — `protocols` feature - -- **Change:** add a `[features]` table with `default = ["protocols"]`. Gate the - DeFi-specific surface behind `protocols`: the V3 tick-snapshot module - (`tick_snapshot`), the `inject_v3_ticks*` / `inject_v2_pool_metadata` methods, - and the protocol slot constants in `storage_keys` (V2/V3/Pancake/Slipstream). - Generic machinery (errors, create3, access sets, multicall, ERC20 helpers, - the cache core, `CacheConfig`, token-decimals cache) stays always-on. -- **Files:** `Cargo.toml`, `src/lib.rs`, `src/cache/mod.rs`, `src/cache/storage_keys.rs`. -- **Done:** `mod storage_keys` / `mod tick_snapshot`, their re-exports, the - `tick_snapshot_cache` field + its construction/save, the `inject_v2_pool_metadata` - / `inject_v3_*` methods, and `CacheConfig::tick_snapshot_cache_path` are all - gated behind `protocols` (default on). The library builds and lints cleanly - with `--no-default-features` (CI enforces `cargo clippy --lib - --no-default-features -- -D warnings`). -- **Deferred (next, with the `evm-amm-state` move):** pool *metadata* structs - (`V2/V3/BalancerPoolMetadata`, entangled with `ImmutableDataCache`) stay - always-on for now, as does the full no-provider build (making - revm/foundry-fork-db/alloy-provider optional behind an `rpc` feature). The - generic no-default library and tests are release gates. +### 1e — Protocol adapter extraction + +- **Change:** keep the generic engine focused on cache mechanics, snapshots, + freshness, state updates, access lists, ERC-20 helpers, multicall, deploy, and + CREATE3. Protocol-specific storage layout helpers, AMM metadata, and + concentrated-liquidity adapter state move out to `evm-amm-state`. +- **Files:** `Cargo.toml`, `src/lib.rs`, `src/cache/mod.rs`, `src/events/mod.rs`, + tests, benches, examples, and release docs. +- **Done:** removed the old in-crate AMM adapter surface, made + `ImmutableDataCache` token-decimals-only, bumped its on-disk version, and + updated CI/docs/examples/benches to present this crate as the generic engine. ### Phase 1 acceptance — met -`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` (default), -`cargo clippy --lib --no-default-features -- -D warnings`, `cargo test`, +`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `RUSTDOCFLAGS=-D warnings cargo doc`, and all examples/benches build. --- @@ -235,7 +226,7 @@ pub struct FreshnessController { /* regis tracker — only the background validator observes checked slots.) - `purge_account(&mut self, addr)` — remove `addr` from the CacheDB overlay, the BlockchainDb accounts map, and its storage, so the next access re-fetches a clean - `AccountInfo`. Distinct from storage-only `purge_pool_storage`. + `AccountInfo`. Distinct from storage-only `purge_contract_storage`. ### Optimistic execution loop with deferred validation (`FreshnessController::run`) @@ -280,8 +271,7 @@ list only buys the overlap. This `FreshnessController` is the seed of the eventu `src/cache/freshness.rs` (child of `cache` → reads private layers for enumeration); `slot_observations.rs` revived + made clock-agnostic; `verify_slots`/`purge_account` -on `EvmCache`. The whole freshness surface lives under the always-on (non-`protocols`) -core. +on `EvmCache`. The whole freshness surface lives under the always-on generic core. ### Tests (offline) @@ -294,8 +284,8 @@ storage on both layers; `ValidThrough` boundary; `WallClock` vs `BlockClock`. ### Acceptance — met -`cargo fmt --check`, `clippy --all-targets -- -D warnings` (default + -`--lib --no-default-features`), `cargo test`, `RUSTDOCFLAGS=-D warnings cargo doc`. +`cargo fmt --check`, `clippy --all-targets -- -D warnings`, `cargo test`, +`RUSTDOCFLAGS=-D warnings cargo doc`. Landed on `phase-2-freshness`: `src/freshness.rs` (the generic core — `Validity` / `FreshnessRegistry`, `FreshnessClock` + `BlockClock`/`WallClock`, @@ -321,25 +311,24 @@ ingestion loop, reorg handling, and overlay-side apply. 1. **`Account` variant is a partial `AccountPatch`** (`balance`/`nonce`/`code`, each `Option`), not a full `AccountInfo`: best fit for event-derived writes (one field at a time) and keeps revm's type out of the public vocabulary. -2. **`inject_v2/v3_*` (`protocols`) normalized to write-through.** Refolded onto - the write-through `StateUpdate::Slot` primitive (backend + overlay-if-present) - instead of the old overlay-only write — a deliberate behavior change recorded - in `CHANGELOG.md` (`### Changed`) and `KNOWN_ISSUES.md`, with a test pinning - the new placement. The cold-backfill `inject_storage_batch` stays layer-2-only. +2. **Legacy protocol writers normalized before extraction.** The old protocol + writers were refolded onto the write-through `StateUpdate::Slot` primitive + before being moved out, keeping the generic write path as the single contract. + The cold-backfill `inject_storage_batch` stays layer-2-only. ### Acceptance — met -`cargo fmt --check`, `clippy --all-targets -- -D warnings` (default + -`--lib --no-default-features`), `cargo test`, `RUSTDOCFLAGS=-D warnings cargo doc`. +`cargo fmt --check`, `clippy --all-targets -- -D warnings`, `cargo test`, +`RUSTDOCFLAGS=-D warnings cargo doc`. Landed on `phase-3-state-updates`: `src/state_update.rs` (the generic vocabulary — `StateUpdate` / `AccountPatch` / `PurgeScope`, the `StateDiff` / `AccountChange` / `PurgeRecord` output, reusing `freshness::SlotChange`); `EvmCache::apply_update` / `apply_updates` with the dual-layer write-through `Slot`/`Account` and dispatch `Purge` semantics; the refold of `inject_storage_batch_fresh` / `purge_account` / -`purge_pool_storage` / `purge_pool_slots` / `inject_v2_pool_metadata` / -`inject_v3_*` onto the primitive and the freshness correction-drain routed -through `apply_updates`; the offline `examples/state_update_apply.rs`; +`purge_contract_storage` / `purge_contract_slots` onto the primitive and the freshness +correction-drain routed through `apply_updates`; the offline +`examples/state_update_apply.rs`; `benches/state_update.rs`; and `tests/state_update.rs`. The §15 addendum adds the relative / read-modify-write surface — a saturating `SlotDelta`, the `StateUpdate::SlotDelta` variant, `EvmCache::modify_slot`, and the cold-aware @@ -391,19 +380,17 @@ thin convenience). The full build contract is in ### Acceptance — met -`cargo fmt --check`, `clippy --all-targets -- -D warnings` (default + -`--lib --no-default-features`), `cargo test` (both feature configs), +`cargo fmt --check`, `clippy --all-targets -- -D warnings`, `cargo test`, `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`; +`Erc20TransferDecoder` (`events::erc20`); 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`. @@ -438,8 +425,7 @@ the deep clone. The full build contract is in ### Acceptance — met -`cargo fmt --check`, `clippy --all-targets -- -D warnings` (default + -`--lib --no-default-features`), `cargo test` (both feature configs), +`cargo fmt --check`, `clippy --all-targets -- -D warnings`, `cargo test`, `RUSTDOCFLAGS=-D warnings cargo doc`, `cargo bench --no-run`; the `tests/cow_snapshot.rs` differential-equivalence gate and the existing snapshot/overlay/freshness tests pass unchanged. @@ -470,9 +456,6 @@ fold cost model is recorded in `KNOWN_ISSUES.md`. 2. **Snapshot consistency point in continuous ingestion.** Applications that run a live event loop should snapshot at block boundaries or behind their own generation guard so simulations do not observe a partially applied block. -3. **Protocol/metadata extraction.** `ImmutableDataCache` couples generic - token-decimals with V2/V3/Balancer pool metadata. Fully separating them is the - precondition for moving protocol knowledge into `evm-amm-state`. -4. **Full no-provider build split.** `--no-default-features` covers the generic - engine and is CI-gated, but the dependency graph still includes provider/RPC - crates. A later `rpc` feature can make those optional for pure offline users. +3. **Full no-provider build split.** The dependency graph still includes + provider/RPC crates. A later `rpc` feature can make those optional for pure + offline users. diff --git a/docs/phase-2-spec.md b/docs/phase-2-spec.md index baff6b2..741812e 100644 --- a/docs/phase-2-spec.md +++ b/docs/phase-2-spec.md @@ -1,5 +1,12 @@ # Phase 2 implementation spec — freshness core + optimistic execution +> **Archival pre-release implementation note:** this file records an internal +> build contract from before the public crate boundary was finalized. It is not +> current release documentation or a current acceptance checklist. The old +> protocol adapter surface, feature-gated protocol APIs, and related +> no-default-feature validation flow were removed/extracted before public release; +> protocol-specific state tracking now belongs in `evm-amm-state`. + Implementation contract for the freshness control plane and the optimistic verify-and-rerun loop with deferred validation. Read this **with** [`ROADMAP.md`](ROADMAP.md) (the "Phase 2 — freshness core" section is the design @@ -15,10 +22,9 @@ prefer this. `Co-Authored-By: Claude Opus 4.8 (1M context) ` - **The whole freshness surface is generic core** — it must compile and lint with `--no-default-features` (it must NOT depend on the `protocols` feature). -- **Green bar at every commit, both feature configs:** +- **Historical green bar at every commit:** - `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` - MSRV is 1.88 — no newer-than-1.88 std APIs. Edition 2024. @@ -52,8 +58,8 @@ evaluation sims only. - `cache::EvmCache` (`src/cache/mod.rs`): `create_snapshot() -> Arc`, `storage_batch_fetcher() -> Option<&StorageBatchFetchFn>`, - `inject_storage_batch(&[(Address,U256,U256)])`, `purge_pool_storage`, - `purge_pool_slots`, `call_raw_with`/`TxConfig`, `CallSimulationResult`, + `inject_storage_batch(&[(Address,U256,U256)])`, `purge_contract_storage`, + `purge_contract_slots`, `call_raw_with`/`TxConfig`, `CallSimulationResult`, `unchecked_blockchain_db()`, `db_mut()`. - `cache::EvmOverlay` / `cache::EvmSnapshot` (`overlay.rs`/`snapshot.rs`): `EvmOverlay::new(Arc, Option)`, `call_raw`, @@ -197,7 +203,7 @@ pub struct SimRequest { - `purge_account(&mut self, addr: Address)`: remove `addr` from the CacheDB overlay accounts (`self.db.cache.accounts`), the BlockchainDb accounts map, and the BlockchainDb storage map — so the next access re-fetches a clean `AccountInfo`. - Distinct from storage-only `purge_pool_storage`. Add a doc comment + a test. + Distinct from storage-only `purge_contract_storage`. Add a doc comment + a test. - `set_storage_batch_fetcher(&mut self, f: StorageBatchFetchFn)`: test/extensibility seam so a stub fetcher can be injected without a provider. diff --git a/docs/phase-3-spec.md b/docs/phase-3-spec.md index e51886c..539b537 100644 --- a/docs/phase-3-spec.md +++ b/docs/phase-3-spec.md @@ -1,5 +1,12 @@ # Phase 3 implementation spec — state-update primitives (Pillar B.1) +> **Archival pre-release implementation note:** this file records an internal +> build contract from before the public crate boundary was finalized. It is not +> current release documentation or a current acceptance checklist. The old +> protocol adapter surface, feature-gated protocol APIs, and related +> no-default-feature validation flow were removed/extracted before public release; +> protocol-specific state tracking now belongs in `evm-amm-state`. + Implementation contract for the **targeted state-mutation vocabulary** and the single apply primitive that writes it correctly across both cache layers, returning a structured state diff. Read this **with** @@ -24,10 +31,9 @@ mechanism that *applies* it, with no protocol or event knowledge in the core. `StateDiff` / `apply_update` / `apply_updates` must NOT depend on the `protocols` feature. (The *refold* of the `protocols`-gated `inject_v2/v3_*` helpers stays behind `protocols`, but it consumes the generic primitive.) -- **Green bar at every commit, both feature configs:** +- **Historical green bar at every commit:** - `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` - MSRV is 1.88 — no newer-than-1.88 std APIs. Edition 2024. @@ -49,8 +55,8 @@ Today the crate writes cached state through a scatter of ad-hoc methods with | `inject_storage_batch_fresh` | write-through *if present* | write | no | | `inject_v2_pool_metadata` / `inject_v3_*` | write (via `insert_account_storage`) | — | **yes** | | `purge_account` | remove acct | remove acct + storage | n/a | -| `purge_pool_storage` | clear storage | remove storage | n/a | -| `purge_pool_slots` | remove slots | remove slots | n/a | +| `purge_contract_storage` | clear storage | remove storage | n/a | +| `purge_contract_slots` | remove slots | remove slots | n/a | | `override_account_code*` | insert info | insert info | n/a | Three different slot-write semantics, no machine-readable record of *what @@ -90,7 +96,7 @@ offline tests, an example, a benchmark, and docs. (`accounts()` / `storage()` `RwLock`s, layer 2); the established write-through pattern in `inject_storage_batch_fresh` (the F1 fix — **the** reference for correct slot-write layering); `cached_storage_value`; `purge_account` / - `purge_pool_storage` / `purge_pool_slots` (the purge layer logic to fold in); + `purge_contract_storage` / `purge_contract_slots` (the purge layer logic to fold in); `self.db.insert_account_info` / `insert_account_storage` (CacheDB writers). - `freshness::SlotChange { address, slot, old, new }` (`src/freshness.rs`, re-exported at crate root) — **reuse it** as the slot-diff type; do not define a @@ -109,7 +115,7 @@ offline tests, an example, a benchmark, and docs. in-module unit tests. No `EvmCache` dependency (pure data + logic on itself). - **`src/cache/mod.rs`**: `EvmCache::apply_update`, `EvmCache::apply_updates`, and the internal per-variant helpers. Refold `inject_storage_batch_fresh`, - `purge_account`, `purge_pool_storage`, `purge_pool_slots`, + `purge_account`, `purge_contract_storage`, `purge_contract_slots`, `override_account_code*`, and (Decision 2) `inject_v2/v3_*` onto them. - **`src/freshness.rs`**: route the `FreshnessController::run` `pending` drain through `apply_updates` (§9) — behavior-preserving. @@ -172,9 +178,9 @@ pub enum PurgeScope { /// Full account: `AccountInfo` (balance/nonce/code) **and** all storage. /// Equivalent to today's `purge_account`. Account, - /// All storage slots; account info preserved. Equivalent to `purge_pool_storage`. + /// All storage slots; account info preserved. Equivalent to `purge_contract_storage`. AllStorage, - /// Only the listed storage slots. Equivalent to `purge_pool_slots`. + /// Only the listed storage slots. Equivalent to `purge_contract_slots`. Slots(Vec), } ``` @@ -271,9 +277,9 @@ home), returning a `PurgeRecord`: - `Account` → `purge_account` logic: remove from overlay accounts, backend accounts, backend storage. `account_removed` = removed from any account layer; `slots_removed` = backend storage slots removed. -- `AllStorage` → `purge_pool_storage` logic (clear overlay storage, remove +- `AllStorage` → `purge_contract_storage` logic (clear overlay storage, remove backend storage); `slots_removed` = backend slots removed. -- `Slots(slots)` → `purge_pool_slots` logic; `slots_removed` = backend slots +- `Slots(slots)` → `purge_contract_slots` logic; `slots_removed` = backend slots removed. ## 6. Refold map (existing → primitive) @@ -285,8 +291,8 @@ becomes a wrapper. Existing tests must pass unchanged. | --- | --- | --- | | `inject_storage_batch_fresh(&[(a,s,v)])` | `apply_updates` of `Slot`s (discard diff) | unchanged (`-> ()`) | | `purge_account(a)` | `apply_update(Purge{a, Account})` | unchanged (`-> ()`) | -| `purge_pool_storage(a) -> usize` | `apply_update(Purge{a, AllStorage})`; return `rec.slots_removed` | unchanged | -| `purge_pool_slots(a, slots) -> usize` | `apply_update(Purge{a, Slots(..)})`; return `rec.slots_removed` | unchanged | +| `purge_contract_storage(a) -> usize` | `apply_update(Purge{a, AllStorage})`; return `rec.slots_removed` | unchanged | +| `purge_contract_slots(a, slots) -> usize` | `apply_update(Purge{a, Slots(..)})`; return `rec.slots_removed` | unchanged | | `override_account_code*` | **best-effort**: route its final write through `apply_update(Account{ patch: code })` **only if** behavior-equivalent; it has bespoke target-creation (`MissingTargetBehavior`) + source→target code-copy semantics, so if the refold is not cleanly equivalent, leave the method as-is and only cross-reference the primitive in its doc | unchanged | | `inject_v2_pool_metadata`, `inject_v3_*` (`protocols`) | build `Vec`, `apply_updates` | **Decision 2 (§12)** | @@ -365,7 +371,7 @@ in a new `tests/state_update.rs` (reuse `tests/common`). counts (`slots_removed`, `account_removed`) correct on both layers. 9. **`apply_updates` fold + merge:** a mixed batch (Slot, Account, Purge) → merged `StateDiff`; later-overrides-earlier ordering for same-key slots. -10. **Refold equivalence:** `purge_pool_storage` wrapper returns the same `usize` +10. **Refold equivalence:** `purge_contract_storage` wrapper returns the same `usize` as the pre-refold behavior on a seeded cache; `inject_storage_batch_fresh` wrapper leaves the cache in the same state as the equivalent `apply_updates`. 11. **(Decision 2, if "normalize"):** `inject_v3_*` now writes through to the @@ -420,8 +426,8 @@ The `protocols` pool tests do not pin layer placement, so they stay green. tests; `lib.rs` re-exports. 2. `EvmCache::apply_update` / `apply_updates` (Slot, Account, Purge) + the `tests/state_update.rs` integration tests. -3. Refold `inject_storage_batch_fresh`, `purge_account`, `purge_pool_storage`, - `purge_pool_slots`, `override_account_code*`, and (per Decision 2) +3. Refold `inject_storage_batch_fresh`, `purge_account`, `purge_contract_storage`, + `purge_contract_slots`, `override_account_code*`, and (per Decision 2) `inject_v2/v3_*`; route the freshness drain through `apply_updates`. 4. Example + benchmark + README rows. 5. Docs (module `//!`, item rustdoc, doctest), CHANGELOG, ROADMAP → Done, diff --git a/docs/phase-4-spec.md b/docs/phase-4-spec.md index 78d2d6d..d5eef14 100644 --- a/docs/phase-4-spec.md +++ b/docs/phase-4-spec.md @@ -1,5 +1,12 @@ # Phase 4 implementation spec — event pipeline + adapters (Pillar B.2) +> **Archival pre-release implementation note:** this file records an internal +> build contract from before the public crate boundary was finalized. It is not +> current release documentation or a current acceptance checklist. The old +> protocol adapter surface, feature-gated protocol APIs, and related +> no-default-feature validation flow were removed/extracted before public release; +> protocol-specific state tracking now belongs in `evm-amm-state`. + 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 — @@ -26,10 +33,9 @@ the decoder, the protocol adapters, and the orchestration that drives them. 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:** +- **Historical green bar at every commit:** - `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) diff --git a/docs/phase-5-spec.md b/docs/phase-5-spec.md index 384ba14..ea62082 100644 --- a/docs/phase-5-spec.md +++ b/docs/phase-5-spec.md @@ -1,5 +1,12 @@ # Phase 5 — copy-on-write snapshots (Pillar A) +> **Archival pre-release implementation note:** this file records an internal +> build contract from before the public crate boundary was finalized. It is not +> current release documentation or a current acceptance checklist. The old +> protocol adapter surface, feature-gated protocol APIs, and related +> no-default-feature validation flow were removed/extracted before public release; +> protocol-specific state tracking now belongs in `evm-amm-state`. + > Status: **build contract**. Authored by the overseer before implementation; the > red acceptance tests in [`../tests/cow_snapshot.rs`](../tests/cow_snapshot.rs) > and the extended overlay tests pin this contract and gate the deliverable. @@ -22,10 +29,9 @@ the per-account storage maps, not a third-party persistent-map crate (D1). 4. **Keep the deep-clone reachable** for A/B benchmarking and as the equivalence reference (D3). It is retained as `create_snapshot_deep_clone()`. -5. **Standard bars.** `cargo fmt --check`; `cargo clippy --all-targets -- -D - warnings` (default) **and** `cargo clippy --lib --no-default-features -- -D - warnings`; `cargo test` (both feature configs); `RUSTDOCFLAGS=-D warnings cargo - doc`; `cargo bench --no-run`. +5. **Historical standard bars.** `cargo fmt --check`; + `cargo clippy --all-targets -- -D warnings`; `cargo test`; + `RUSTDOCFLAGS=-D warnings cargo doc`; `cargo bench --no-run`. ## 1. Goal diff --git a/examples/custom_revert_errors.rs b/examples/custom_revert_errors.rs index ecd2854..d16d058 100644 --- a/examples/custom_revert_errors.rs +++ b/examples/custom_revert_errors.rs @@ -23,7 +23,7 @@ sol! { #[derive(Debug)] error SwapFailed(address router, bytes data); #[derive(Debug)] - error InvalidUniswapV3Pool(); + error InvalidSimulationTarget(); #[derive(Debug)] error NotCalm(); // The IERC6093 standard error decodes through the very same mechanism. @@ -36,7 +36,7 @@ fn main() { // clone and is `Send + Sync`). let decoder = RevertDecoder::new() .with_error::() - .with_error::() + .with_error::() .with_error::() .with_error::(); println!("decoder knows {} custom errors\n", decoder.len()); @@ -46,8 +46,8 @@ fn main() { decode( &decoder, - "InvalidUniswapV3Pool", - Bytes::from(InvalidUniswapV3Pool::SELECTOR.to_vec()), + "InvalidSimulationTarget", + Bytes::from(InvalidSimulationTarget::SELECTOR.to_vec()), ); // A custom error carrying parameters — they are decoded and Debug-formatted. diff --git a/examples/multi_hop_swap.rs b/examples/multi_hop_swap.rs deleted file mode 100644 index f6e8249..0000000 --- a/examples/multi_hop_swap.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Simulate a multi-hop Uniswap V2 swap quote against live mainnet state. -//! -//! This calls the real Uniswap V2 router's `getAmountsOut(amountIn, path)` for a -//! two-hop path (WETH → USDC → DAI) inside the fork. The router reads each pair's -//! reserves from chain state — fetched lazily through the cache on first access — -//! and returns the output amount after both hops. It is a pure view call, so no -//! funding or approvals are needed, yet it exercises the real multi-contract -//! state a swap simulation depends on. -//! -//! To go further (a state-changing swap), you would override the caller's input -//! token balance (see `fork_override_balance`) and call the router's -//! `swapExactTokensForTokens`, then read the balance deltas with -//! `simulate_with_transfer_tracking`. -//! -//! Requires an Ethereum mainnet RPC endpoint. Run with: -//! -//! ```sh -//! RPC_URL=https://eth.llamarpc.com cargo run --example multi_hop_swap -//! ``` - -use std::sync::Arc; - -use alloy_primitives::{Address, Bytes, U256, address}; -use alloy_provider::ProviderBuilder; -use alloy_provider::network::AnyNetwork; -use alloy_sol_types::{SolCall, sol}; -use anyhow::{Result, anyhow}; -use evm_fork_cache::cache::EvmCache; -use revm::context::result::ExecutionResult; - -const ROUTER: Address = address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"); -const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); -const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); -const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); - -sol! { - interface IUniswapV2Router { - function getAmountsOut(uint256 amountIn, address[] path) external view returns (uint256[] amounts); - } -} - -#[tokio::main(flavor = "multi_thread")] -async fn main() -> Result<()> { - let Ok(rpc_url) = std::env::var("RPC_URL") else { - eprintln!("This example needs an Ethereum mainnet RPC endpoint. Run with:"); - eprintln!(" RPC_URL=https://eth.llamarpc.com cargo run --example multi_hop_swap"); - return Ok(()); - }; - - let provider = ProviderBuilder::new() - .network::() - .connect_http(rpc_url.parse()?); - let mut cache = EvmCache::new(Arc::new(provider)).await; - - // Quote 1 WETH swapped along WETH -> USDC -> DAI. - let amount_in = U256::from(10u64).pow(U256::from(18u64)); // 1 WETH (1e18) - let path = vec![WETH, USDC, DAI]; - let calldata = Bytes::from( - IUniswapV2Router::getAmountsOutCall { - amountIn: amount_in, - path: path.clone(), - } - .abi_encode(), - ); - - let result = cache.call_raw(Address::ZERO, ROUTER, calldata, false)?; - let output = match result { - ExecutionResult::Success { output, .. } => output.into_data(), - other => return Err(anyhow!("getAmountsOut did not succeed: {other:?}")), - }; - - let amounts = IUniswapV2Router::getAmountsOutCall::abi_decode_returns(&output)?; - if amounts.len() != path.len() { - return Err(anyhow!("unexpected amounts length: {}", amounts.len())); - } - - // USDC has 6 decimals, DAI has 18; print human-readable figures. - let usdc_mid = amounts[1] / U256::from(10u64).pow(U256::from(6u64)); - let dai_out = amounts[2] / U256::from(10u64).pow(U256::from(18u64)); - - println!("two-hop quote (Uniswap V2, live reserves):"); - println!(" in: 1 WETH"); - println!(" hop1 -> ~{usdc_mid} USDC ({} raw)", amounts[1]); - println!(" hop2 -> ~{dai_out} DAI ({} raw)", amounts[2]); - - Ok(()) -} diff --git a/examples/prefetch_registry.rs b/examples/prefetch_registry.rs index 690092b..9669ef6 100644 --- a/examples/prefetch_registry.rs +++ b/examples/prefetch_registry.rs @@ -20,18 +20,18 @@ use evm_fork_cache::StorageAccessList; use evm_fork_cache::prefetch_registry::PrefetchRegistry; fn main() -> anyhow::Result<()> { - let pool = Address::repeat_byte(0xAA); + let settlement = Address::repeat_byte(0xAA); let vault_a = Address::repeat_byte(0x01); let vault_b = Address::repeat_byte(0x02); let mut registry = PrefetchRegistry::default(); // An aggregated phase: one access list covering a batch of view calls. - let mut pool_refresh = StorageAccessList::default(); - pool_refresh.accounts.insert(pool); - pool_refresh.slots.insert((pool, U256::from(0))); - pool_refresh.slots.insert((pool, U256::from(4))); - registry.record("pool_refresh", pool_refresh); + let mut settlement_refresh = StorageAccessList::default(); + settlement_refresh.accounts.insert(settlement); + settlement_refresh.slots.insert((settlement, U256::from(0))); + settlement_refresh.slots.insert((settlement, U256::from(4))); + registry.record("settlement_refresh", settlement_refresh); // A keyed phase: per-address lists, so the next cycle can prefetch only the // addresses it is about to simulate. @@ -48,8 +48,8 @@ fn main() -> anyhow::Result<()> { registry.save(&path)?; let loaded = PrefetchRegistry::load(&path); - let aggregated = loaded.phase_slots("pool_refresh"); - println!("pool_refresh phase has {} slots", aggregated.len()); + let aggregated = loaded.phase_slots("settlement_refresh"); + println!("settlement_refresh phase has {} slots", aggregated.len()); for (addr, slot) in &aggregated { println!(" {addr} slot {slot}"); } diff --git a/examples/reactive_cache.rs b/examples/reactive_cache.rs index 7901f83..e498ad1 100644 --- a/examples/reactive_cache.rs +++ b/examples/reactive_cache.rs @@ -1,21 +1,14 @@ //! 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: +//! Decodes on-chain logs into the [`StateUpdate`](evm_fork_cache::StateUpdate) +//! vocabulary and applies them to a fork cache, keeping hot state fresh without +//! an RPC round-trip per change. This example wires up: //! -//! 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. +//! 1. A [`DecoderRegistry`] with the built-in [`Erc20TransferDecoder`]. +//! 2. An [`EventPipeline`] whose `ingest_logs` decodes and applies a block's logs. +//! 3. Freshness pinning, sampled reconcile against a stub fetcher, and reorg purge. //! -//! 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. +//! Runs fully offline against a mocked provider and in-memory logs. //! //! Run with: //! @@ -23,221 +16,134 @@ //! cargo run --example reactive_cache //! ``` -#[cfg(feature = "protocols")] -#[tokio::main(flavor = "multi_thread")] -async fn main() -> anyhow::Result<()> { - imp::run().await -} +#[path = "support/mock.rs"] +mod mock; -#[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`." - ); +use std::collections::HashMap; +use std::sync::Arc; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, Bytes, Log, U256, keccak256}; +use alloy_sol_types::SolValue; +use anyhow::Result; +use evm_fork_cache::Erc20TransferDecoder; +use evm_fork_cache::cache::StorageBatchFetchFn; +use evm_fork_cache::events::{DecoderRegistry, EventPipeline}; +use evm_fork_cache::freshness::{AlwaysVerify, FreshnessController, FreshnessRegistry, Validity}; + +/// 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) } -#[cfg(feature = "protocols")] -#[path = "support/mock.rs"] -mod mock; +/// 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>()), + ) +} -#[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); - } +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + let mut cache = mock::offline_cache().await?; + + let token = Address::repeat_byte(0x11); + 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); + + let alice_slot = balance_slot(alice); + let bob_slot = balance_slot(bob); + + cache + .db_mut() + .insert_account_storage(token, alice_slot, U256::from(1_000))?; + cache + .db_mut() + .insert_account_storage(token, bob_slot, U256::from(0))?; + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from( + mock::MOCK_ERC20_BALANCE_SLOT, + )))); + let mut pipeline = EventPipeline::new(registry); + + let mut controller = FreshnessController::new(FreshnessRegistry::new(), AlwaysVerify); + + let block = 100u64; + let digest = pipeline.ingest_logs( + &mut cache, + block, + &[transfer_log(token, alice, bob, U256::from(250))], + ); - /// 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) - } + 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)?, + ); - /// 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>()), - ) + 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() + ); - /// 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(), + let fresh: HashMap<(Address, U256), U256> = + HashMap::from([((token, bob_slot), U256::from(260))]); + 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, &[(token, bob_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 + ); } } + println!( + " bob balance after reconcile: {}", + mock::balance_of(&mut cache, token, bob)? + ); - /// 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 - ); - } - } + let purge = pipeline.reorg_to(&mut cache, 99); + println!("\n=== reorg to block 99 ==="); + println!( + " purged {} address(es); bob balance slot now cached as {:?}", + purge.purged.len(), + cache.cached_storage_value(token, bob_slot), + ); - // 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(()) - } + Ok(()) } diff --git a/examples/state_update_apply.rs b/examples/state_update_apply.rs index fa06944..47b589e 100644 --- a/examples/state_update_apply.rs +++ b/examples/state_update_apply.rs @@ -26,32 +26,35 @@ use mock::{install_default_account, install_mock_erc20, offline_cache}; #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { - let pool = Address::repeat_byte(0x11); + let contract = Address::repeat_byte(0x11); let holder = Address::repeat_byte(0x22); let mut cache = offline_cache().await?; // A token-like account with overlay storage (so the slot write heals both // layers) plus an EOA-style account to patch a balance onto. - install_mock_erc20(&mut cache, pool); + install_mock_erc20(&mut cache, contract); install_default_account(&mut cache, holder); - // Seed some backend storage on the pool so the purge has something to remove + // Seed some backend storage on the contract so the purge has something to remove // and the slot write has a recorded `old` value. cache.inject_storage_batch(&[ - (pool, U256::from(0), U256::from(100)), // e.g. a reserve slot - (pool, U256::from(7), U256::from(1)), // a tick/aux slot we'll purge - (pool, U256::from(8), U256::from(2)), // another slot we'll purge + (contract, U256::from(0), U256::from(100)), + (contract, U256::from(7), U256::from(1)), + (contract, U256::from(8), U256::from(2)), ]); println!("Applying a mixed batch of state updates...\n"); let diff = cache.apply_updates(&[ - // 1. Authoritative slot write (e.g. an event-derived reserve update). - StateUpdate::slot(pool, U256::from(0), U256::from(250)), + // 1. Authoritative slot write. + StateUpdate::slot(contract, U256::from(0), U256::from(250)), // 2. Partial account patch: set only the balance, leave nonce/code. StateUpdate::balance(holder, U256::from(1_000_000)), // 3. Drop two stale storage slots so the next read re-fetches them. - StateUpdate::purge(pool, PurgeScope::Slots(vec![U256::from(7), U256::from(8)])), + StateUpdate::purge( + contract, + PurgeScope::Slots(vec![U256::from(7), U256::from(8)]), + ), ]); println!("StateDiff: {} changed entr(ies)\n", diff.len()); @@ -87,7 +90,7 @@ async fn main() -> Result<()> { } // Re-applying the same slot value is a no-op — idempotence is observable. - let again = cache.apply_update(&StateUpdate::slot(pool, U256::from(0), U256::from(250))); + let again = cache.apply_update(&StateUpdate::slot(contract, U256::from(0), U256::from(250))); println!( "\nRe-applying the same slot value -> empty diff: {}", again.is_empty() @@ -103,7 +106,7 @@ async fn main() -> Result<()> { // A hot (seeded) balance slot: +750 relative to the current value. let hot_slot = U256::from(0); // we set this to 250 above let rel = cache.apply_update(&StateUpdate::slot_delta( - pool, + contract, hot_slot, SlotDelta::Add(U256::from(750)), )); @@ -119,7 +122,7 @@ async fn main() -> Result<()> { // to fetch+seed the true value and retry. let cold_slot = U256::from(4_242); let cold = cache.apply_update(&StateUpdate::slot_delta( - pool, + contract, cold_slot, SlotDelta::Add(U256::from(100)), )); diff --git a/examples/storage_access_list.rs b/examples/storage_access_list.rs index f1b52f8..24dca20 100644 --- a/examples/storage_access_list.rs +++ b/examples/storage_access_list.rs @@ -16,25 +16,25 @@ use alloy_primitives::{Address, U256}; use evm_fork_cache::StorageAccessList; fn main() { - let pool = Address::repeat_byte(0xAA); + let contract = Address::repeat_byte(0xAA); let token = Address::repeat_byte(0xBB); - // First simulation touches the pool's slot0 and liquidity slots. + // First simulation touches two slots on a hot contract. let mut first = StorageAccessList::default(); - first.accounts.insert(pool); - first.slots.insert((pool, U256::from(0))); // slot0 - first.slots.insert((pool, U256::from(4))); // liquidity + first.accounts.insert(contract); + first.slots.insert((contract, U256::from(0))); + first.slots.insert((contract, U256::from(4))); println!( "first sim: {} accounts, {} slots", first.account_count(), first.slot_count() ); - // Second simulation re-touches the pool and additionally reads a token balance. + // Second simulation re-touches the contract and additionally reads a token balance. let mut second = StorageAccessList::default(); - second.accounts.insert(pool); + second.accounts.insert(contract); second.accounts.insert(token); - second.slots.insert((pool, U256::from(0))); // slot0 again (overlaps) + second.slots.insert((contract, U256::from(0))); // overlaps with first second.slots.insert((token, U256::from(3))); // a balance slot // If `second` runs after `first` has warmed state, the overlap is cheaper diff --git a/fixtures/EventGroundTruthPool.sol b/fixtures/EventGroundTruthPool.sol deleted file mode 100644 index 510e9e8..0000000 --- a/fixtures/EventGroundTruthPool.sol +++ /dev/null @@ -1,126 +0,0 @@ -// 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 b52807f..ce6f067 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -43,28 +43,3 @@ 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 deleted file mode 100644 index 9f7230e..0000000 --- a/fixtures/test_v3_pool_creation.hex +++ /dev/null @@ -1 +0,0 @@ -60c060405234801561000f575f80fd5b5060405161075338038061075383398101604081905261002e91610060565b6001600160a01b039182166080521660a052610091565b80516001600160a01b038116811461005b575f80fd5b919050565b5f8060408385031215610071575f80fd5b61007a83610045565b915061008860208401610045565b90509250929050565b60805160a0516106866100cd5f395f818161026d01528181610297015261030d01525f81816069015281816102bd01526102e701526106865ff3fe608060405234801561000f575f80fd5b5060043610610060575f3560e01c80630dfe1681146100645780631a686502146100a85780631ff1a703146100d35780633850c7bd146101b15780635c02d26614610255578063d21220a714610268575b5f80fd5b61008b7f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6004546100bb906001600160801b031681565b6040516001600160801b03909116815260200161009f565b6101af6100e136600461053b565b6040805160e0810182526001600160a01b0395909516808652600285900b602087015261ffff93909316908501819052600160608601819052608086018190525f60a0870181905260c0909601528454600160c81b6001600160b81b0319909116909317600160a01b62ffffff909516949094029390931763ffffffff60b81b1916600160b81b90930261ffff60c81b1916929092171763ffffffff60d81b1916630100000160d81b17909155600480546001600160801b0319166001600160801b03909216919091179055565b005b5f54610204906001600160a01b03811690600160a01b810460020b9061ffff600160b81b8204811691600160c81b8104821691600160d81b8204169060ff600160e81b8204811691600160f01b90041687565b604080516001600160a01b03909816885260029690960b602088015261ffff94851695870195909552918316606086015291909116608084015260ff1660a0830152151560c082015260e00161009f565b6101af6102633660046105a4565b61028f565b61008b7f000000000000000000000000000000000000000000000000000000000000000081565b5f866102bb577f00000000000000000000000000000000000000000000000000000000000000006102dd565b7f00000000000000000000000000000000000000000000000000000000000000005b90505f8761030b577f000000000000000000000000000000000000000000000000000000000000000061032d565b7f00000000000000000000000000000000000000000000000000000000000000005b6040516323b872dd60e01b8152336004820152306024820152604481018990529091506001600160a01b038316906323b872dd906064016020604051808303815f875af1158015610380573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906103a49190610608565b5060405163a9059cbb60e01b8152336004820152602481018790526001600160a01b0382169063a9059cbb906044016020604051808303815f875af11580156103ef573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906104139190610608565b505f80546001600160a01b0387166001600160b81b031990911617600160a01b62ffffff871602178155600480546001600160801b0319166001600160801b0386161790558861046b576104668761062a565b61046d565b875b90505f8961047b5788610484565b6104848861062a565b60408051848152602081018390526001600160a01b038a16818301526001600160801b0388166060820152600289900b60808201529051919250339182917fc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67919081900360a00190a350505050505050505050565b80356001600160a01b038116811461050f575f80fd5b919050565b8035600281900b811461050f575f80fd5b80356001600160801b038116811461050f575f80fd5b5f805f806080858703121561054e575f80fd5b610557856104f9565b935061056560208601610514565b9250604085013561ffff8116811461057b575f80fd5b915061058960608601610525565b905092959194509250565b80151581146105a1575f80fd5b50565b5f805f805f8060c087890312156105b9575f80fd5b86356105c481610594565b955060208701359450604087013593506105e0606088016104f9565b92506105ee60808801610514565b91506105fc60a08801610525565b90509295509295509295565b5f60208284031215610618575f80fd5b815161062381610594565b9392505050565b5f600160ff1b820161064a57634e487b7160e01b5f52601160045260245ffd5b505f039056fea2646970667358221220492fd2050a35f65b8045bdcc2057caf77c1aed86d203b17fd05c21e978a89f0d64736f6c63430008170033 \ No newline at end of file diff --git a/src/cache/journal_access_list.rs b/src/cache/journal_access_list.rs new file mode 100644 index 0000000..7fc0ad4 --- /dev/null +++ b/src/cache/journal_access_list.rs @@ -0,0 +1,44 @@ +use alloy_eips::eip2930::{AccessList, AccessListItem}; +use alloy_primitives::B256; + +/// Extract an EIP-2930 access list from the EVM journaled state. +/// +/// After a transaction executes, `journaled_state.state` contains all accounts +/// and storage slots that were touched. This converts them into an `AccessList` +/// suitable for inclusion in a transaction, ensuring all accessed storage is warm. +pub(super) fn extract_access_list(state: &revm::state::EvmState) -> AccessList { + let items: Vec = state + .iter() + .filter(|(_, account)| account.is_touched()) + .map(|(address, account)| AccessListItem { + address: *address, + storage_keys: account + .storage + .keys() + .map(|slot| B256::from(*slot)) + .collect(), + }) + .collect(); + AccessList(items) +} + +pub(super) fn merge_access_lists(access_lists: impl IntoIterator) -> AccessList { + let mut merged: Vec = Vec::new(); + for access_list in access_lists { + for item in access_list.0 { + if let Some(existing) = merged + .iter_mut() + .find(|existing| existing.address == item.address) + { + for key in item.storage_keys { + if !existing.storage_keys.contains(&key) { + existing.storage_keys.push(key); + } + } + } else { + merged.push(item); + } + } + } + AccessList(merged) +} diff --git a/src/cache/metadata.rs b/src/cache/metadata.rs index 670de08..4a7708e 100644 --- a/src/cache/metadata.rs +++ b/src/cache/metadata.rs @@ -1,15 +1,15 @@ //! Disk-cache configuration and immutable side-data persistence. //! //! Alongside the raw EVM state, the cache tracks values that rarely or never -//! change for a given fork — token decimals, pool metadata, and similar -//! immutable data. This module defines the on-disk cache layout +//! change for a given fork, currently ERC-20 token decimals. This module defines +//! the on-disk cache layout //! ([`CacheConfig`]) and the serializable containers used to persist and reload //! that side data so subsequent runs avoid re-fetching it over RPC. use std::collections::HashMap; use std::path::{Path, PathBuf}; -use alloy_primitives::{Address, B256, U256}; +use alloy_primitives::{Address, U256}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -18,7 +18,7 @@ use std::collections::HashSet; use super::versioned; const IMMUTABLE_CACHE_MAGIC: &[u8; 8] = b"EFCMETA\0"; -const IMMUTABLE_CACHE_VERSION: u32 = 1; +const IMMUTABLE_CACHE_VERSION: u32 = 2; /// Configuration for disk-based caching of EVM state. /// @@ -79,12 +79,6 @@ impl CacheConfig { self.chain_dir().join("immutable_data.bin") } - /// Get the path for the V3 tick snapshot cache file (binary format). - #[cfg(feature = "protocols")] - pub(crate) fn tick_snapshot_cache_path(&self) -> PathBuf { - self.chain_dir().join("v3_tick_snapshots.bin") - } - /// Get the path for the EVM state cache file (bincode format). /// /// This cache stores the complete EVM state (accounts + storage) in @@ -94,56 +88,10 @@ impl CacheConfig { } } -/// Cached metadata for a UniswapV2 pool. -/// -/// Holds the immutable token pair plus a freshness marker -/// ([`last_block_timestamp`](Self::last_block_timestamp)) used to detect when -/// cached reserves have gone stale. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct V2PoolMetadata { - pub token0: Address, - pub token1: Address, - /// The blockTimestampLast from getReserves() at cache time. - /// Used to detect stale cached storage - if the on-chain value differs, - /// the reserves have changed and cached storage should be purged. - #[serde(default)] - pub last_block_timestamp: u32, -} - -/// Cached metadata for a UniswapV3 pool. -/// -/// All fields are immutable for the lifetime of the pool: the token pair, the -/// fee tier, and the tick spacing. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct V3PoolMetadata { - pub token0: Address, - pub token1: Address, - pub fee: u32, - pub tick_spacing: i32, -} - -/// Cached metadata for a Balancer pool. -/// -/// Holds the pool's tokens, weights, and swap fee plus a freshness marker -/// ([`last_change_block`](Self::last_change_block)) used to detect when cached -/// balances have gone stale. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BalancerPoolMetadata { - pub tokens: Vec
, - pub weights: Vec, - pub swap_fee: U256, - /// The lastChangeBlock from getPoolTokens() at cache time. - /// Used to detect stale cached storage - if the on-chain value differs, - /// the balances have changed and cached storage should be purged. - #[serde(default)] - pub last_change_block: U256, -} - /// Cache for immutable on-chain data that doesn't change between blocks. /// /// This includes: /// - Token decimals (ERC20 decimals are immutable) -/// - Pool metadata (token addresses, fees, tick spacing) /// /// By caching this data, we avoid redundant RPC calls across block changes /// and process restarts. @@ -151,12 +99,6 @@ pub struct BalancerPoolMetadata { pub struct ImmutableDataCache { /// Token address -> decimals pub token_decimals: HashMap, - /// UniswapV2 pool address -> metadata - pub v2_pools: HashMap, - /// UniswapV3 pool address -> metadata - pub v3_pools: HashMap, - /// Balancer pool ID (as hex string) -> metadata - pub balancer_pools: HashMap, } impl ImmutableDataCache { @@ -208,62 +150,13 @@ impl ImmutableDataCache { self.token_decimals.insert(token, decimals); } - /// Get cached V2 pool metadata. - pub fn get_v2_pool(&self, address: Address) -> Option<&V2PoolMetadata> { - self.v2_pools.get(&address) - } - - /// Cache V2 pool metadata. - pub fn set_v2_pool(&mut self, address: Address, metadata: V2PoolMetadata) { - self.v2_pools.insert(address, metadata); - } - - /// Get cached V3 pool metadata. - pub fn get_v3_pool(&self, address: Address) -> Option<&V3PoolMetadata> { - self.v3_pools.get(&address) - } - - /// Cache V3 pool metadata. - pub fn set_v3_pool(&mut self, address: Address, metadata: V3PoolMetadata) { - self.v3_pools.insert(address, metadata); - } - - /// Get cached Balancer pool metadata. - /// - /// The `pool_id` is keyed by its `Debug` formatting (matching - /// [`ImmutableDataCache::set_balancer_pool`]), so a lookup only hits if the - /// id was stored through that same setter. - pub fn get_balancer_pool(&self, pool_id: B256) -> Option<&BalancerPoolMetadata> { - self.balancer_pools.get(&format!("{:?}", pool_id)) - } - - /// Cache Balancer pool metadata. - /// - /// The `pool_id` is stored under its `Debug` formatting as the map key. - pub fn set_balancer_pool(&mut self, pool_id: B256, metadata: BalancerPoolMetadata) { - self.balancer_pools - .insert(format!("{:?}", pool_id), metadata); - } - /// Check if the cache is empty. - /// - /// Returns `true` only when every sub-map (token decimals and all pool - /// kinds) is empty. pub fn is_empty(&self) -> bool { self.token_decimals.is_empty() - && self.v2_pools.is_empty() - && self.v3_pools.is_empty() - && self.balancer_pools.is_empty() } /// Get the total number of cached entries. - /// - /// This is the sum of the entry counts across all sub-maps (token decimals - /// plus V2, V3, and Balancer pools), not a count of distinct addresses. pub fn len(&self) -> usize { self.token_decimals.len() - + self.v2_pools.len() - + self.v3_pools.len() - + self.balancer_pools.len() } } diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 0919355..19af0e4 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,35 +1,17 @@ mod binary_state; mod bytecode; +mod journal_access_list; mod metadata; pub mod overlay; pub mod slot_observations; pub mod snapshot; -#[cfg(feature = "protocols")] -mod storage_keys; -#[cfg(feature = "protocols")] -mod tick_snapshot; pub(crate) mod versioned; pub use binary_state::{load_binary_state, save_binary_state}; -pub use metadata::{ - BalancerPoolMetadata, CacheConfig, ImmutableDataCache, V2PoolMetadata, V3PoolMetadata, -}; +pub use metadata::{CacheConfig, ImmutableDataCache}; pub use overlay::EvmOverlay; pub use slot_observations::SlotObservationTracker; pub use snapshot::EvmSnapshot; -#[cfg(feature = "protocols")] -#[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] -pub use storage_keys::{ - PANCAKE_V3_LIQUIDITY_SLOT, PANCAKE_V3_TICK_BITMAP_BASE_SLOT, PANCAKE_V3_TICKS_BASE_SLOT, - SLIPSTREAM_LIQUIDITY_SLOT, SLIPSTREAM_SLOT0_SLOT, SLIPSTREAM_TICK_BITMAP_BASE_SLOT, - SLIPSTREAM_TICKS_BASE_SLOT, V2_RESERVES_SLOT, V3_LIQUIDITY_SLOT, V3_SLOT0_SLOT, - V3_TICK_BITMAP_BASE_SLOT, V3_TICKS_BASE_SLOT, v3_tick_bitmap_storage_key, - v3_tick_bitmap_storage_key_with_base, v3_tick_info_storage_keys, - v3_tick_info_storage_keys_with_base, -}; -#[cfg(feature = "protocols")] -#[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] -pub use tick_snapshot::{SerializableTickInfo, TickInfo, V3PoolTickSnapshot, V3TickSnapshotCache}; use std::{ cell::RefCell, @@ -44,7 +26,7 @@ use std::{ }; use alloy_consensus::BlockHeader; -use alloy_eips::eip2930::{AccessList, AccessListItem}; +use alloy_eips::eip2930::AccessList; use alloy_eips::{BlockId, BlockNumberOrTag}; use alloy_network::BlockResponse; use alloy_primitives::{Address, B256, Bytes, I256, Log, TxKind, U256, keccak256}; @@ -73,8 +55,7 @@ use crate::state_update::{ }; use bytecode::BytecodeCache; -#[cfg(feature = "protocols")] -use storage_keys::{i128_to_u256, i256_from_i16, i256_from_i24}; +use journal_access_list::{extract_access_list, merge_access_lists}; /// Re-export AnyNetwork for callers that need to construct providers. pub use alloy_provider::network::AnyNetwork as AnyNetworkType; @@ -90,9 +71,10 @@ pub type RpcCallFn = Arc Result + Send + Sync>; /// Callback for batch-fetching storage slots directly from RPC, bypassing SharedBackend. /// -/// Used by V3 tick prefetch to avoid 16K+ individual channel round-trips through -/// SharedBackend. Fires concurrent `eth_getStorageAt` calls directly via the provider -/// and returns results for bulk injection into BlockchainDb. +/// Used by callers that need bulk storage reads without many individual channel +/// round-trips through SharedBackend. Fires concurrent `eth_getStorageAt` calls +/// directly via the provider and returns results for bulk injection into +/// BlockchainDb. /// /// The second argument pins the fetch to a specific block: `Some(block)` fetches /// at exactly that block, while `None` uses the fetcher's configured block (the @@ -357,11 +339,10 @@ where /// Enable disk-backed caching with the given configuration. /// - /// Supplying a [`CacheConfig`] turns on persistence of EVM state, - /// bytecodes, immutable data, and (with the `protocols` feature) V3 tick - /// snapshots under the configured chain directory; the cache is loaded on - /// [`build`](Self::build) and flushed on drop. Omit it for a purely - /// in-memory cache backed solely by RPC. + /// Supplying a [`CacheConfig`] turns on persistence of EVM state, bytecodes, + /// and immutable data under the configured chain directory; the cache is + /// loaded on [`build`](Self::build) and flushed on drop. Omit it for a + /// purely in-memory cache backed solely by RPC. pub fn cache_config(mut self, cache_config: CacheConfig) -> Self { self.cache_config = Some(cache_config); self @@ -477,11 +458,8 @@ pub struct EvmCache { token_decimals: HashMap, block: BlockId, cache_config: Option, - /// Cache for immutable on-chain data (token decimals, pool metadata). + /// Cache for immutable on-chain data (token decimals). immutable_cache: ImmutableDataCache, - /// Cache for V3 pool tick snapshots (tick_bitmap, ticks, liquidity). - #[cfg(feature = "protocols")] - tick_snapshot_cache: V3TickSnapshotCache, /// Optional timestamp override for simulating future blocks. /// When set, EVM simulations use this timestamp instead of the current system time. timestamp_override: Option, @@ -678,8 +656,7 @@ impl EvmCache { /// This enables several caching features: /// 1. Unified EVM state: Accounts + storage loaded from `evm_state.bin` (bincode) /// 2. Bytecode caching: Contract bytecodes from `bytecodes.bin` - /// 3. Tick snapshots: V3 pool tick data for validation - /// 4. Immutable data: Token decimals, pool metadata + /// 3. Immutable data: Token decimals /// /// # Runtime requirement /// RPC-backed operation requires a **multi-thread** tokio runtime @@ -839,7 +816,7 @@ impl EvmCache { } } - // Load immutable data cache (token decimals, pool metadata) + // Load immutable data cache (token decimals). // This is still needed for validation and metadata lookups let immutable_cache = cache_config .as_ref() @@ -848,9 +825,6 @@ impl EvmCache { ImmutableDataCache::load(&path).inspect(|cache| { debug!( token_decimals = cache.token_decimals.len(), - v2_pools = cache.v2_pools.len(), - v3_pools = cache.v3_pools.len(), - balancer_pools = cache.balancer_pools.len(), path = ?path, "Loaded immutable data from cache" ); @@ -861,22 +835,6 @@ impl EvmCache { // Pre-populate in-memory token decimals from immutable cache let token_decimals = immutable_cache.token_decimals.clone(); - // Load V3 tick snapshot cache (for liquidity validation) - #[cfg(feature = "protocols")] - let tick_snapshot_cache = cache_config - .as_ref() - .and_then(|cfg| { - let path = cfg.tick_snapshot_cache_path(); - V3TickSnapshotCache::load(&path).inspect(|cache| { - debug!( - snapshots = cache.len(), - path = ?path, - "Loaded V3 tick snapshots from cache" - ); - }) - }) - .unwrap_or_default(); - // Create an RPC callback for direct eth_call before moving provider into backend. // This bypasses revm simulation for batch queries where lazy storage fetching is too slow. let provider_for_rpc = provider.clone(); @@ -1058,8 +1016,6 @@ impl EvmCache { block, cache_config, immutable_cache, - #[cfg(feature = "protocols")] - tick_snapshot_cache, timestamp_override: None, chain_id, block_number, @@ -1135,8 +1091,6 @@ impl EvmCache { block, cache_config: None, immutable_cache: ImmutableDataCache::default(), - #[cfg(feature = "protocols")] - tick_snapshot_cache: V3TickSnapshotCache::default(), timestamp_override: None, chain_id, block_number, @@ -1165,10 +1119,10 @@ impl EvmCache { /// This persists: /// 1. Unified EVM state (accounts + storage) to `evm_state.bin` (bincode) /// 2. Contract bytecodes to `bytecodes.bin` - /// 3. Immutable data (token decimals, pool metadata) to `immutable_data.bin` - /// 4. V3 tick snapshots to `v3_tick_snapshots.bin` + /// 3. Immutable data (token decimals) to `immutable_data.bin` /// - /// Call this after loading AMMs and running simulations to speed up subsequent runs. + /// Call this after loading hot contract state and running simulations to + /// speed up subsequent runs. /// The cache is also automatically flushed when the EvmCache is dropped. pub fn flush(&self) -> Result<()> { if let Some(cfg) = &self.cache_config { @@ -1199,28 +1153,9 @@ impl EvmCache { })?; debug!( token_decimals = self.immutable_cache.token_decimals.len(), - v2_pools = self.immutable_cache.v2_pools.len(), - v3_pools = self.immutable_cache.v3_pools.len(), - balancer_pools = self.immutable_cache.balancer_pools.len(), path = ?immutable_path, "Updated immutable data cache" ); - - // Save the V3 tick snapshot cache (needed for liquidity validation) - #[cfg(feature = "protocols")] - { - let tick_snapshot_path = cfg.tick_snapshot_cache_path(); - self.tick_snapshot_cache - .save(&tick_snapshot_path) - .with_context(|| { - format!("failed to save V3 tick snapshot cache to {tick_snapshot_path:?}") - })?; - debug!( - snapshots = self.tick_snapshot_cache.len(), - path = ?tick_snapshot_path, - "Updated V3 tick snapshot cache" - ); - } } Ok(()) } @@ -1451,8 +1386,8 @@ impl EvmCache { /// # use alloy_primitives::{Address, U256}; /// # use evm_fork_cache::StateUpdate; /// # fn example(cache: &mut evm_fork_cache::cache::EvmCache) { - /// let pool = Address::repeat_byte(0x01); - /// let diff = cache.apply_update(&StateUpdate::slot(pool, U256::from(0), U256::from(42))); + /// let contract = Address::repeat_byte(0x01); + /// let diff = cache.apply_update(&StateUpdate::slot(contract, U256::from(0), U256::from(42))); /// assert_eq!(diff.slots.len(), 1); /// # } /// ``` @@ -2000,7 +1935,7 @@ impl EvmCache { } } PurgeScope::AllStorage => { - let slots_removed = self.purge_pool_storage_inner(address); + let slots_removed = self.purge_contract_storage_inner(address); PurgeRecord { address, scope: PurgeScope::AllStorage, @@ -2009,7 +1944,7 @@ impl EvmCache { } } PurgeScope::Slots(slots) => { - let slots_removed = self.purge_pool_slots_inner(address, slots); + let slots_removed = self.purge_contract_slots_inner(address, slots); PurgeRecord { address, scope: PurgeScope::Slots(slots.clone()), @@ -2172,7 +2107,7 @@ impl EvmCache { /// Removes `addr` from the CacheDB overlay accounts map, the BlockchainDb /// accounts map, and the BlockchainDb storage map, so the next access /// re-fetches a clean account from RPC. This is the account-level - /// counterpart to the storage-only [`purge_pool_storage`](Self::purge_pool_storage): + /// counterpart to the storage-only [`purge_contract_storage`](Self::purge_contract_storage): /// use it when an address is fully volatile (no pinned slots) and even its /// balance/nonce/code can no longer be trusted. pub fn purge_account(&mut self, addr: Address) { @@ -2850,7 +2785,7 @@ impl EvmCache { /// Read a single storage slot through the SharedBackend (BlockchainDb -> RPC fallback). /// - /// After `purge_pool_slots` removes a slot from BlockchainDb, this method fetches + /// After `purge_contract_slots` removes a slot from BlockchainDb, this method fetches /// fresh data from RPC and caches it in BlockchainDb. Subsequent EVM SLOADs find /// the value there without additional RPC calls. pub fn read_storage_slot(&mut self, address: Address, slot: U256) -> Result { @@ -2982,197 +2917,6 @@ impl EvmCache { Ok(None) } - /// Inject UniswapV2 pool metadata (token0, token1) directly into the EVM storage cache. - /// - /// This allows subsequent EVM calls to `token0()` and `token1()` to hit the - /// local cache instead of fetching from RPC. - /// - /// # Storage Layout - /// In UniswapV2Pair: - /// - Slot 6: token0 (address) - /// - Slot 7: token1 (address) - /// - /// # Arguments - /// * `pool_address` - The UniswapV2 pair contract address - /// * `metadata` - The cached pool metadata containing token0 and token1 - /// - /// # Layering (Phase 3 change) - /// As of Phase 3 this writes **through** the dual-layer policy via - /// [`apply_updates`](Self::apply_updates) (backend always, overlay-if-present) - /// rather than the old overlay-only write. The slot *placement* is normalized; - /// the visible `token0()` / `token1()` reads are unchanged. The slot writes are - /// now infallible; the `Result` is retained for signature compatibility. - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn inject_v2_pool_metadata( - &mut self, - pool_address: Address, - metadata: &V2PoolMetadata, - ) -> Result<()> { - const TOKEN0_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - const TOKEN1_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); - - // Addresses are stored as 20 bytes right-aligned in a 32-byte slot - let token0_value = U256::from_be_slice(metadata.token0.as_slice()); - let token1_value = U256::from_be_slice(metadata.token1.as_slice()); - - self.apply_updates(&[ - StateUpdate::slot(pool_address, TOKEN0_SLOT, token0_value), - StateUpdate::slot(pool_address, TOKEN1_SLOT, token1_value), - ]); - - Ok(()) - } - - /// Inject UniswapV3 tickBitmap data directly into the EVM storage cache. - /// - /// This allows subsequent EVM calls to `tickBitmap(wordPosition)` to hit the - /// local cache instead of fetching from RPC. - /// - /// # Storage Layout - /// In UniswapV3Pool, `tickBitmap` is a `mapping(int16 => uint256)` at storage slot 6. - /// For a mapping at slot `p`, the value for key `k` is stored at `keccak256(abi.encode(k, p))`. - /// - /// # Arguments - /// * `pool_address` - The UniswapV3 pool contract address - /// * `tick_bitmap` - Map of word position (int16) to bitmap value (uint256) - /// - /// # Layering (Phase 3 change) - /// As of Phase 3 this writes **through** the dual-layer policy via - /// [`apply_updates`](Self::apply_updates) (backend always, overlay-if-present) - /// rather than the old overlay-only write — so the slots now land in the - /// BlockchainDb backend (layer 2) too. See `CHANGELOG.md` / `KNOWN_ISSUES.md`. - /// The slot writes are now infallible; the `Result` is retained for signature - /// compatibility. - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn inject_v3_tick_bitmap( - &mut self, - pool_address: Address, - tick_bitmap: &std::collections::HashMap, - ) -> Result { - self.inject_v3_tick_bitmap_with_base(pool_address, tick_bitmap, V3_TICK_BITMAP_BASE_SLOT) - } - - /// Inject V3-style tick bitmap data with a custom base slot. - /// - /// PancakeSwap V3 uses base slot 7 instead of Uniswap V3's slot 6. - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn inject_v3_tick_bitmap_with_base( - &mut self, - pool_address: Address, - tick_bitmap: &std::collections::HashMap, - base_slot: U256, - ) -> Result { - let mut updates = Vec::with_capacity(tick_bitmap.len()); - for (&word_position, &bitmap_value) in tick_bitmap { - let word_position_i256 = i256_from_i16(word_position); - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&word_position_i256); - slot_preimage[32..64].copy_from_slice(&base_slot.to_be_bytes::<32>()); - let storage_slot: U256 = keccak256(slot_preimage).into(); - updates.push(StateUpdate::slot(pool_address, storage_slot, bitmap_value)); - } - let injected = updates.len(); - self.apply_updates(&updates); - Ok(injected) - } - - /// Inject UniswapV3 tick info data directly into the EVM storage cache. - /// - /// This allows subsequent EVM calls to `ticks(tick)` to partially hit the - /// local cache instead of fetching all data from RPC. - /// - /// # Storage Layout - /// In UniswapV3Pool, `ticks` is a `mapping(int24 => Tick.Info)` at storage slot 5. - /// `Tick.Info` is a struct that spans 4 storage slots: - /// - /// - Slot +0: `liquidityGross` (u128, bits 0-127) | `liquidityNet` (i128, bits 128-255) - /// - Slot +1: `feeGrowthOutside0X128` (u256) - /// - Slot +2: `feeGrowthOutside1X128` (u256) - /// - Slot +3: packed (`tickCumulativeOutside`, `secondsPerLiquidityOutsideX128`, - /// `secondsOutside`, `initialized`) - /// - /// We inject slot 0 (liquidityGross + liquidityNet) and slot 3 (initialized flag). - /// Slot 0 covers the most critical data used in swap simulation. - /// Slot 3 contains the `initialized` flag which determines whether a tick - /// is processed during swap execution -- stale values from Layer 2 can cause - /// ticks to be erroneously skipped or processed. - /// - /// # Arguments - /// * `pool_address` - The UniswapV3 pool contract address - /// * `ticks` - Map of tick index (int24) to tick info - /// - /// # Layering (Phase 3 change) - /// As of Phase 3 this writes **through** the dual-layer policy via - /// [`apply_updates`](Self::apply_updates) (backend always, overlay-if-present) - /// rather than the old overlay-only write. See `CHANGELOG.md` / - /// `KNOWN_ISSUES.md`. The slot writes are now infallible; the `Result` is - /// retained for signature compatibility. - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn inject_v3_ticks( - &mut self, - pool_address: Address, - ticks: &std::collections::HashMap, - ) -> Result { - self.inject_v3_ticks_with_base(pool_address, ticks, V3_TICKS_BASE_SLOT) - } - - /// Inject V3-style tick info data with a custom ticks mapping slot. - /// - /// PancakeSwap V3 uses ticks at slot 6 instead of Uniswap V3's slot 5. - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn inject_v3_ticks_with_base( - &mut self, - pool_address: Address, - ticks: &std::collections::HashMap, - ticks_slot: U256, - ) -> Result { - let mut updates = Vec::with_capacity(ticks.len() * 2); - for (&tick, info) in ticks { - let tick_i256 = i256_from_i24(tick); - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&tick_i256); - slot_preimage[32..64].copy_from_slice(&ticks_slot.to_be_bytes::<32>()); - - let base_slot: U256 = keccak256(slot_preimage).into(); - - // Pack liquidityGross and liquidityNet into slot 0 - // Solidity packing: liquidityGross in lower 128 bits, liquidityNet in upper 128 bits - let liquidity_gross_u256 = U256::from(info.liquidity_gross); - let liquidity_net_u256 = i128_to_u256(info.liquidity_net); - let packed_slot0 = liquidity_gross_u256 | (liquidity_net_u256 << 128); - - updates.push(StateUpdate::slot(pool_address, base_slot, packed_slot0)); - - // Also inject slot 3 with the `initialized` flag. - // Slot 3 layout: packed (tickCumulativeOutside, secondsPerLiquidityOutsideX128, - // secondsOutside, initialized) - // The `initialized` flag is in the highest byte (bit 248+). - // We only set the initialized flag; the other fields in slot 3 are - // not used by swap simulation, but without this injection the EVM - // would read stale values from Layer 2 (evm_state.bin). - let slot3 = base_slot + U256::from(3); - let initialized_value = if info.initialized { - // initialized is a bool packed at byte offset 31 (rightmost byte of the - // last field in the packed struct). In the actual Solidity layout it's at - // a higher bit position, but the key thing is we need the bit set. - // Actual layout: initialized is at byte 31 of the packed word. - U256::from(1u64) << 248 - } else { - U256::ZERO - }; - updates.push(StateUpdate::slot(pool_address, slot3, initialized_value)); - } - - let injected = ticks.len(); - self.apply_updates(&updates); - Ok(injected) - } - /// Execute a call with automatic account/storage fetching. /// /// Unlike the old implementation, this does NOT prefetch via access lists. @@ -3381,53 +3125,32 @@ impl EvmCache { } } - /// Get a reference to the immutable data cache (token decimals and pool - /// metadata that never change for a given contract). + /// Get a reference to the immutable data cache (token decimals). pub fn immutable_cache(&self) -> &ImmutableDataCache { &self.immutable_cache } /// Get a mutable reference to the immutable data cache. /// - /// Use this to pre-populate token decimals or pool metadata that would - /// otherwise be discovered lazily. Entries are persisted on the next - /// [`flush`](Self::flush) (and on drop) when a [`CacheConfig`] is set. + /// Use this to pre-populate token decimals that would otherwise be discovered + /// lazily. Entries are persisted on the next [`flush`](Self::flush) (and on + /// drop) when a [`CacheConfig`] is set. pub fn immutable_cache_mut(&mut self) -> &mut ImmutableDataCache { &mut self.immutable_cache } - /// Get a reference to the V3 pool tick snapshot cache (per-pool - /// `tick_bitmap`, `ticks`, and liquidity used for liquidity validation). - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn tick_snapshot_cache(&self) -> &V3TickSnapshotCache { - &self.tick_snapshot_cache - } - - /// Get a mutable reference to the V3 pool tick snapshot cache. - /// - /// Use this to insert or update tick snapshots. Entries are persisted on - /// the next [`flush`](Self::flush) (and on drop) when a [`CacheConfig`] is - /// set. - #[cfg(feature = "protocols")] - #[cfg_attr(docsrs, doc(cfg(feature = "protocols")))] - pub fn tick_snapshot_cache_mut(&mut self) -> &mut V3TickSnapshotCache { - &mut self.tick_snapshot_cache - } - - /// Check if a pool has storage slots pre-loaded in the BlockchainDb. + /// Check if an address has storage slots pre-loaded in the BlockchainDb. /// /// This is useful to determine if we loaded the EVM state from the unified - /// `evm_state.bin` cache and the pool's tick data is already in storage. - /// If true, we can skip expensive tick injection when liquidity hasn't changed. + /// `evm_state.bin` cache and an address already has reusable storage. /// /// # Arguments - /// * `address` - The pool contract address to check + /// * `address` - The contract address to check /// /// # Returns - /// `true` if the pool has any storage slots in the underlying BlockchainDb, + /// `true` if the address has any storage slots in the underlying BlockchainDb, /// `false` otherwise - pub fn has_pool_storage(&self, address: Address) -> bool { + pub fn has_contract_storage(&self, address: Address) -> bool { let storage = self.blockchain_db.storage().read(); storage .get(&address) @@ -3435,10 +3158,10 @@ impl EvmCache { .unwrap_or(false) } - /// Get the number of storage slots loaded for a pool. + /// Get the number of storage slots loaded for a contract address. /// /// Useful for debugging and logging to understand cache state. - pub fn pool_storage_slot_count(&self, address: Address) -> usize { + pub fn contract_storage_slot_count(&self, address: Address) -> usize { let storage = self.blockchain_db.storage().read(); storage.get(&address).map(|slots| slots.len()).unwrap_or(0) } @@ -3521,7 +3244,7 @@ impl EvmCache { self.shared_memory_capacity } - /// Purge all storage slots for a specific pool from both cache layers. + /// Purge all storage slots for a specific contract from both cache layers. /// /// This clears: /// 1. **CacheDB overlay** (`self.db.cache.accounts[addr].storage`) - the in-memory @@ -3531,9 +3254,9 @@ impl EvmCache { /// 2. **BlockchainDb backend** (`self.blockchain_db.storage()`) - the persistent /// layer that caches RPC responses and is loaded from `evm_state.bin`. /// - /// After purging both layers, the next EVM read for this pool's storage will + /// After purging both layers, the next EVM read for this contract's storage will /// go all the way to the RPC for fresh data. - pub fn purge_pool_storage(&mut self, address: Address) -> usize { + pub fn purge_contract_storage(&mut self, address: Address) -> usize { // Thin wrapper over the unified purge primitive; returns the backend slot // count the `AllStorage` scope removed. self.apply_update(&StateUpdate::purge(address, PurgeScope::AllStorage)) @@ -3546,7 +3269,7 @@ impl EvmCache { /// `AllStorage`-scope purge layer logic. Clears the overlay storage for /// `address` and removes its backend storage map. Returns the number of /// backend slots removed. - fn purge_pool_storage_inner(&mut self, address: Address) -> usize { + fn purge_contract_storage_inner(&mut self, address: Address) -> usize { // Layer 1: Clear CacheDB overlay let cache_db_cleared = if let Some(db_account) = self.db.cache.accounts.get_mut(&address) { let count = db_account.storage.len(); @@ -3568,10 +3291,10 @@ impl EvmCache { if cache_db_cleared > 0 || backend_cleared > 0 { debug!( - pool = %address, + contract = %address, cache_db_slots = cache_db_cleared, backend_slots = backend_cleared, - "purged pool storage from both cache layers" + "purged contract storage from both cache layers" ); } @@ -3580,16 +3303,14 @@ impl EvmCache { backend_cleared } - /// Purge specific storage slots for a pool from both cache layers. + /// Purge specific storage slots for a contract from both cache layers. /// - /// Unlike `purge_pool_storage()` which removes ALL storage, this only removes - /// the specified slots. This is critical for performance: V3 pools may have - /// hundreds of tick data slots that are expensive to re-fetch. When we only - /// need fresh slot0/liquidity values, we can purge just those 2 slots and - /// preserve the tick data. + /// Unlike `purge_contract_storage()` which removes ALL storage, this only removes + /// the specified slots. This is useful when only a narrow subset of hot storage + /// became stale and the rest of the contract's cached storage should be kept. /// /// Returns the number of slots removed from the BlockchainDb backend. - pub fn purge_pool_slots(&mut self, address: Address, slots: &[U256]) -> usize { + pub fn purge_contract_slots(&mut self, address: Address, slots: &[U256]) -> usize { // Thin wrapper over the unified purge primitive; returns the backend slot // count the `Slots` scope removed. self.apply_update(&StateUpdate::purge( @@ -3604,7 +3325,7 @@ impl EvmCache { /// `Slots`-scope purge layer logic. Removes the listed slots from the overlay /// and the backend storage map. Returns the number of backend slots removed. - fn purge_pool_slots_inner(&mut self, address: Address, slots: &[U256]) -> usize { + fn purge_contract_slots_inner(&mut self, address: Address, slots: &[U256]) -> usize { let mut cache_db_removed = 0usize; let mut backend_removed = 0usize; @@ -3631,11 +3352,11 @@ impl EvmCache { if cache_db_removed > 0 || backend_removed > 0 { trace!( - pool = %address, + contract = %address, requested = slots.len(), cache_db_removed, backend_removed, - "selectively purged pool storage slots from both cache layers" + "selectively purged contract storage slots from both cache layers" ); } @@ -3647,7 +3368,7 @@ impl EvmCache { /// Purge storage slots for multiple contracts from both cache layers. /// - /// See `purge_pool_storage()` for details on what each layer contains. + /// See `purge_contract_storage()` for details on what each layer contains. pub fn purge_contracts_storage( &mut self, addresses: impl IntoIterator, @@ -3777,10 +3498,10 @@ impl EvmCache { addrs.into_iter().collect() } - /// Get the number of storage slots in the CacheDB overlay for a pool. + /// Get the number of storage slots in the CacheDB overlay for a contract. /// - /// This is useful for diagnostics - if a pool has slots in the CacheDB overlay, - /// they will be served on EVM reads without going to the backend. + /// This is useful for diagnostics: if a contract has slots in the CacheDB + /// overlay, they will be served on EVM reads without going to the backend. pub fn cache_db_storage_slot_count(&self, address: Address) -> usize { self.db .cache @@ -4447,48 +4168,6 @@ impl Drop for EvmCache { } } -/// Extract an EIP-2930 access list from the EVM journaled state. -/// -/// After a transaction executes, `journaled_state.state` contains all accounts -/// and storage slots that were touched. This converts them into an `AccessList` -/// suitable for inclusion in a transaction, ensuring all accessed storage is warm. -fn extract_access_list(state: &revm::state::EvmState) -> AccessList { - let items: Vec = state - .iter() - .filter(|(_, account)| account.is_touched()) - .map(|(address, account)| AccessListItem { - address: *address, - storage_keys: account - .storage - .keys() - .map(|slot| B256::from(*slot)) - .collect(), - }) - .collect(); - AccessList(items) -} - -fn merge_access_lists(access_lists: impl IntoIterator) -> AccessList { - let mut merged: Vec = Vec::new(); - for access_list in access_lists { - for item in access_list.0 { - if let Some(existing) = merged - .iter_mut() - .find(|existing| existing.address == item.address) - { - for key in item.storage_keys { - if !existing.storage_keys.contains(&key) { - existing.storage_keys.push(key); - } - } - } else { - merged.push(item); - } - } - } - AccessList(merged) -} - #[cfg(test)] mod shared_memory_capacity_tests { use super::SharedMemoryCapacity as Cap; @@ -4518,414 +4197,11 @@ mod shared_memory_capacity_tests { } } -#[cfg(all(test, feature = "protocols"))] -mod tests { - use super::*; - use storage_keys::{i128_to_u256, i256_from_i16, i256_from_i24}; - - #[test] - fn test_i256_from_i16_positive() { - let result = i256_from_i16(1); - // Should be 31 zero bytes followed by 0x0001 - assert_eq!(result[0..30], [0u8; 30]); - assert_eq!(result[30], 0x00); - assert_eq!(result[31], 0x01); - } - - #[test] - fn test_i256_from_i16_negative() { - let result = i256_from_i16(-1); - // Should be 30 0xFF bytes followed by 0xFFFF - assert_eq!(result[0..30], [0xFF; 30]); - assert_eq!(result[30], 0xFF); - assert_eq!(result[31], 0xFF); - } - - #[test] - fn test_i256_from_i16_zero() { - let result = i256_from_i16(0); - assert_eq!(result, [0u8; 32]); - } - - #[test] - fn test_i256_from_i16_max() { - let result = i256_from_i16(i16::MAX); // 32767 = 0x7FFF - assert_eq!(result[0..30], [0u8; 30]); - assert_eq!(result[30], 0x7F); - assert_eq!(result[31], 0xFF); - } - - #[test] - fn test_i256_from_i16_min() { - let result = i256_from_i16(i16::MIN); // -32768 = 0x8000 - assert_eq!(result[0..30], [0xFF; 30]); - assert_eq!(result[30], 0x80); - assert_eq!(result[31], 0x00); - } - - #[test] - fn test_tick_bitmap_storage_slot_calculation() { - // This test verifies our storage slot calculation matches Solidity's behavior. - // In Solidity: mapping(int16 => uint256) tickBitmap at slot 6 - // Storage slot = keccak256(abi.encode(wordPosition, 6)) - // - // For wordPosition = 0: - // abi.encode(int256(0), uint256(6)) = - // 0x0000...0000 (32 bytes for 0) ++ 0x0000...0006 (32 bytes for 6) - // keccak256 of that gives the storage slot - - const TICK_BITMAP_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - - // Test word position 0 - let word_pos: i16 = 0; - let word_position_i256 = i256_from_i16(word_pos); - - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&word_position_i256); - slot_preimage[32..64].copy_from_slice(&TICK_BITMAP_SLOT.to_be_bytes::<32>()); - - let storage_slot: U256 = keccak256(slot_preimage).into(); - - // The slot should be a valid keccak256 hash (non-zero, 256 bits) - assert!(storage_slot != U256::ZERO); - - // Verify the preimage is correctly formed - assert_eq!(&slot_preimage[..32], &[0u8; 32]); // word position 0 - assert_eq!(slot_preimage[63], 6); // slot 6 in last byte - assert_eq!(&slot_preimage[32..63], &[0u8; 31]); // rest of slot is zeros - } - - #[test] - fn test_tick_bitmap_storage_slot_negative_word() { - // Test with negative word position to ensure sign extension works - const TICK_BITMAP_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - - let word_pos: i16 = -1; - let word_position_i256 = i256_from_i16(word_pos); - - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&word_position_i256); - slot_preimage[32..64].copy_from_slice(&TICK_BITMAP_SLOT.to_be_bytes::<32>()); - - let storage_slot: U256 = keccak256(slot_preimage).into(); - - // Should produce a valid slot - assert!(storage_slot != U256::ZERO); - - // Verify the preimage has sign-extended -1 - assert_eq!(&slot_preimage[..32], &[0xFF; 32]); // -1 sign-extended - } - - #[test] - fn test_different_word_positions_give_different_slots() { - const TICK_BITMAP_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - - let calc_slot = |word_pos: i16| -> U256 { - let word_position_i256 = i256_from_i16(word_pos); - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&word_position_i256); - slot_preimage[32..64].copy_from_slice(&TICK_BITMAP_SLOT.to_be_bytes::<32>()); - keccak256(slot_preimage).into() - }; - - let slot_0 = calc_slot(0); - let slot_1 = calc_slot(1); - let slot_neg1 = calc_slot(-1); - let slot_100 = calc_slot(100); - - // All should be different - assert_ne!(slot_0, slot_1); - assert_ne!(slot_0, slot_neg1); - assert_ne!(slot_0, slot_100); - assert_ne!(slot_1, slot_neg1); - assert_ne!(slot_1, slot_100); - assert_ne!(slot_neg1, slot_100); - } - - // ==================== i256_from_i24 tests ==================== - - #[test] - fn test_i256_from_i24_zero() { - let result = i256_from_i24(0); - assert_eq!(result, [0u8; 32]); - } - - #[test] - fn test_i256_from_i24_positive() { - let result = i256_from_i24(1); - assert_eq!(result[0..31], [0u8; 31]); - assert_eq!(result[31], 0x01); - } - - #[test] - fn test_i256_from_i24_negative_one() { - // -1 in 24-bit two's complement is 0xFFFFFF - let result = i256_from_i24(-1); - // Should be sign-extended: 29 0xFF bytes followed by 0xFFFFFF - assert_eq!(result[0..29], [0xFF; 29]); - assert_eq!(result[29], 0xFF); - assert_eq!(result[30], 0xFF); - assert_eq!(result[31], 0xFF); - } - - #[test] - fn test_i256_from_i24_max_positive() { - // Max int24 is 8388607 = 0x7FFFFF - let max_i24: i32 = 0x7FFFFF; - let result = i256_from_i24(max_i24); - assert_eq!(result[0..29], [0u8; 29]); - assert_eq!(result[29], 0x7F); - assert_eq!(result[30], 0xFF); - assert_eq!(result[31], 0xFF); - } - - #[test] - fn test_i256_from_i24_min_negative() { - // Min int24 is -8388608 = 0x800000 (as 24-bit signed) - let min_i24: i32 = -8388608; - let result = i256_from_i24(min_i24); - // Should be sign-extended with 0xFF - assert_eq!(result[0..29], [0xFF; 29]); - assert_eq!(result[29], 0x80); - assert_eq!(result[30], 0x00); - assert_eq!(result[31], 0x00); - } - - #[test] - fn test_i256_from_i24_typical_tick_positive() { - // Test a typical positive tick value (e.g., 1000) - let tick: i32 = 1000; // 0x0003E8 - let result = i256_from_i24(tick); - assert_eq!(result[0..30], [0u8; 30]); - assert_eq!(result[30], 0x03); - assert_eq!(result[31], 0xE8); - } - - #[test] - fn test_i256_from_i24_typical_tick_negative() { - // Test a typical negative tick value (e.g., -1000) - // -1000 in 24-bit two's complement: 0xFFFC18 - let tick: i32 = -1000; - let result = i256_from_i24(tick); - assert_eq!(result[0..29], [0xFF; 29]); - assert_eq!(result[29], 0xFF); - assert_eq!(result[30], 0xFC); - assert_eq!(result[31], 0x18); - } - - // ==================== i128_to_u256 tests ==================== - - #[test] - fn test_i128_to_u256_positive() { - let result = i128_to_u256(12345); - assert_eq!(result, U256::from(12345u128)); - } - - #[test] - fn test_i128_to_u256_zero() { - let result = i128_to_u256(0); - assert_eq!(result, U256::ZERO); - } - - #[test] - fn test_i128_to_u256_negative_one() { - // -1 in two's complement u128 is all 1s - let result = i128_to_u256(-1); - // Lower 128 bits should be all 1s - let expected = U256::from(u128::MAX); - assert_eq!(result, expected); - } - - #[test] - fn test_i128_to_u256_negative() { - // -100 should give us the two's complement representation - let result = i128_to_u256(-100); - let expected = U256::from((-100i128) as u128); - assert_eq!(result, expected); - } - - #[test] - fn test_i128_to_u256_max() { - let result = i128_to_u256(i128::MAX); - assert_eq!(result, U256::from(i128::MAX as u128)); - } - - #[test] - fn test_i128_to_u256_min() { - let result = i128_to_u256(i128::MIN); - // i128::MIN as u128 = 0x8000...0000 - assert_eq!(result, U256::from(i128::MIN as u128)); - } - - // ==================== Tick storage slot tests ==================== - - #[test] - fn test_tick_info_packing() { - // Test that liquidityGross and liquidityNet are packed correctly - let liquidity_gross: u128 = 1_000_000_000; - let liquidity_net: i128 = -500_000_000; - - let liquidity_gross_u256 = U256::from(liquidity_gross); - let liquidity_net_u256 = i128_to_u256(liquidity_net); - let packed = liquidity_gross_u256 | (liquidity_net_u256 << 128); - - // Extract and verify - let extracted_gross = packed & U256::from(u128::MAX); - let extracted_net_u256 = packed >> 128; - - assert_eq!(extracted_gross, U256::from(liquidity_gross)); - assert_eq!(extracted_net_u256, liquidity_net_u256); - } - - #[test] - fn test_tick_storage_slot_calculation() { - const TICKS_SLOT: U256 = U256::from_limbs([5, 0, 0, 0]); - - // Test tick 0 - let tick: i32 = 0; - let tick_i256 = i256_from_i24(tick); - - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&tick_i256); - slot_preimage[32..64].copy_from_slice(&TICKS_SLOT.to_be_bytes::<32>()); - - let storage_slot: U256 = keccak256(slot_preimage).into(); - - // Should produce a valid slot - assert!(storage_slot != U256::ZERO); - - // Verify preimage - assert_eq!(&slot_preimage[..32], &[0u8; 32]); // tick 0 - assert_eq!(slot_preimage[63], 5); // slot 5 - } - - #[test] - fn test_different_ticks_give_different_slots() { - const TICKS_SLOT: U256 = U256::from_limbs([5, 0, 0, 0]); - - let calc_slot = |tick: i32| -> U256 { - let tick_i256 = i256_from_i24(tick); - let mut slot_preimage = [0u8; 64]; - slot_preimage[..32].copy_from_slice(&tick_i256); - slot_preimage[32..64].copy_from_slice(&TICKS_SLOT.to_be_bytes::<32>()); - keccak256(slot_preimage).into() - }; - - let slot_0 = calc_slot(0); - let slot_60 = calc_slot(60); - let slot_neg60 = calc_slot(-60); - let slot_887272 = calc_slot(887272); // MAX_TICK - - // All should be different - assert_ne!(slot_0, slot_60); - assert_ne!(slot_0, slot_neg60); - assert_ne!(slot_0, slot_887272); - assert_ne!(slot_60, slot_neg60); - } - - // -- PancakeSwap V3 storage slot tests -- - - #[test] - fn test_pancake_v3_constants_correct_values() { - // PancakeSwap V3 slots are shifted +1 from Uniswap V3 - assert_eq!(V3_LIQUIDITY_SLOT, U256::from(4)); - assert_eq!(PANCAKE_V3_LIQUIDITY_SLOT, U256::from(5)); - - assert_eq!(V3_TICKS_BASE_SLOT, U256::from(5)); - assert_eq!(PANCAKE_V3_TICKS_BASE_SLOT, U256::from(6)); - - assert_eq!(V3_TICK_BITMAP_BASE_SLOT, U256::from(6)); - assert_eq!(PANCAKE_V3_TICK_BITMAP_BASE_SLOT, U256::from(7)); - } - - #[test] - fn test_tick_bitmap_with_base_matches_original_for_uniswap() { - // v3_tick_bitmap_storage_key_with_base using Uniswap base slot should - // produce identical results to v3_tick_bitmap_storage_key - for word_pos in [-100i16, -1, 0, 1, 42, 100] { - let original = v3_tick_bitmap_storage_key(word_pos); - let with_base = - v3_tick_bitmap_storage_key_with_base(word_pos, V3_TICK_BITMAP_BASE_SLOT); - assert_eq!( - original, with_base, - "with_base should match original for word_pos={word_pos}" - ); - } - } - - #[test] - fn test_tick_bitmap_pancake_differs_from_uniswap() { - // PancakeSwap base slot 7 must produce different keys than Uniswap slot 6 - for word_pos in [-1i16, 0, 1, 42] { - let uniswap = v3_tick_bitmap_storage_key(word_pos); - let pancake = - v3_tick_bitmap_storage_key_with_base(word_pos, PANCAKE_V3_TICK_BITMAP_BASE_SLOT); - assert_ne!( - uniswap, pancake, - "PancakeSwap bitmap key should differ from Uniswap for word_pos={word_pos}" - ); - } - } - - #[test] - fn test_tick_info_with_base_matches_original_for_uniswap() { - // v3_tick_info_storage_keys_with_base using Uniswap base slot should - // produce identical results to v3_tick_info_storage_keys - for tick in [-887_272i32, -1000, 0, 1000, 887_272] { - let original = v3_tick_info_storage_keys(tick); - let with_base = v3_tick_info_storage_keys_with_base(tick, V3_TICKS_BASE_SLOT); - assert_eq!( - original, with_base, - "with_base should match original for tick={tick}" - ); - } - } - - #[test] - fn test_tick_info_pancake_differs_from_uniswap() { - // PancakeSwap ticks base slot 6 must produce different keys than Uniswap slot 5 - for tick in [-1000i32, 0, 1000] { - let uniswap = v3_tick_info_storage_keys(tick); - let pancake = v3_tick_info_storage_keys_with_base(tick, PANCAKE_V3_TICKS_BASE_SLOT); - for i in 0..4 { - assert_ne!( - uniswap[i], pancake[i], - "PancakeSwap tick info key[{i}] should differ from Uniswap for tick={tick}" - ); - } - } - } - - #[test] - fn test_tick_info_with_base_keys_are_sequential() { - // The 4 keys returned should be consecutive (base, base+1, base+2, base+3) - let keys = v3_tick_info_storage_keys_with_base(500, PANCAKE_V3_TICKS_BASE_SLOT); - assert_eq!(keys[1], keys[0] + U256::from(1)); - assert_eq!(keys[2], keys[0] + U256::from(2)); - assert_eq!(keys[3], keys[0] + U256::from(3)); - } -} - -/// Tests that exercise only the generic (protocol-independent) engine, so they -/// run under `--no-default-features` too. The protocol-gated unit tests live in -/// the `tests` module above, which is `#[cfg(feature = "protocols")]`. +/// Tests that exercise the generic cache engine. #[cfg(test)] mod core_tests { use super::*; - // ==================== V2 pool metadata injection tests ==================== - - #[test] - fn test_v2_pool_metadata_storage_slots() { - // Verify the storage slot constants match UniswapV2Pair layout - const TOKEN0_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - const TOKEN1_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); - - // Slots should be sequential starting at 6 - assert_eq!(TOKEN0_SLOT, U256::from(6)); - assert_eq!(TOKEN1_SLOT, U256::from(7)); - } - #[test] fn test_address_to_u256_conversion() { // Test that address conversion preserves the address bytes correctly @@ -4942,36 +4218,6 @@ mod core_tests { assert_eq!(&bytes[12..], addr.as_slice()); } - #[test] - fn test_v2_metadata_address_values() { - // Test specific address encoding - let token0 = Address::repeat_byte(0x11); - let token1 = Address::repeat_byte(0x22); - - let metadata = V2PoolMetadata { - token0, - token1, - last_block_timestamp: 0, - }; - - let token0_value = U256::from_be_slice(metadata.token0.as_slice()); - let token1_value = U256::from_be_slice(metadata.token1.as_slice()); - - // Values should be different - assert_ne!(token0_value, token1_value); - - // Each should be non-zero - assert_ne!(token0_value, U256::ZERO); - assert_ne!(token1_value, U256::ZERO); - - // Verify round-trip: extract address bytes back - let token0_bytes = token0_value.to_be_bytes::<32>(); - let token1_bytes = token1_value.to_be_bytes::<32>(); - - assert_eq!(&token0_bytes[12..], token0.as_slice()); - assert_eq!(&token1_bytes[12..], token1.as_slice()); - } - // ==================== block context tests ==================== #[test] diff --git a/src/cache/storage_keys.rs b/src/cache/storage_keys.rs deleted file mode 100644 index 01117f6..0000000 --- a/src/cache/storage_keys.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! AMM storage-slot key math. -//! -//! DEX pool contracts (UniswapV3 and similar) store their state at well-known -//! slot numbers and in Solidity mappings keyed by tick or bitmap word. This -//! module pins those base slot constants and computes the concrete storage keys -//! (`keccak256`-derived mapping slots) for individual ticks and bitmap words, -//! so the cache can selectively purge or refresh just the slots that matter -//! instead of an entire account's storage. - -use alloy_primitives::{U256, keccak256}; - -// ============================================================================ -// UniswapV3 Storage Layout Constants -// ============================================================================ -// -// These constants map to the storage slot numbers in the UniswapV3Pool contract. -// They are used for selective cache purging (purging only specific slots instead -// of all storage) and for computing mapping storage keys. - -/// Storage slot for UniswapV3Pool.slot0 (packed: sqrtPriceX96, tick, etc.) -pub const V3_SLOT0_SLOT: U256 = U256::ZERO; - -/// Storage slot for UniswapV3Pool.liquidity -pub const V3_LIQUIDITY_SLOT: U256 = U256::from_limbs([4, 0, 0, 0]); - -/// Base storage slot for UniswapV3Pool.ticks mapping (used by Uniswap V3) -pub const V3_TICKS_BASE_SLOT: U256 = U256::from_limbs([5, 0, 0, 0]); - -/// Base storage slot for UniswapV3Pool.tickBitmap mapping (int16 => uint256) -pub const V3_TICK_BITMAP_BASE_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - -/// PancakeSwap V3 has a storage layout shift: slot0 uses uint32 feeProtocol -/// instead of uint8, pushing subsequent slots by +1. -/// -/// Storage slot for PancakeSwapV3Pool.liquidity (slot 5 vs Uniswap's slot 4) -pub const PANCAKE_V3_LIQUIDITY_SLOT: U256 = U256::from_limbs([5, 0, 0, 0]); - -/// Base storage slot for PancakeSwapV3Pool.ticks mapping (slot 6 vs Uniswap's slot 5) -pub const PANCAKE_V3_TICKS_BASE_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - -/// Base storage slot for PancakeSwapV3Pool.tickBitmap mapping (slot 7 vs Uniswap's slot 6) -pub const PANCAKE_V3_TICK_BITMAP_BASE_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); - -/// Aerodrome/Velodrome Slipstream CL pools have extra reward-related state variables -/// (gauge, nft, factoryRegistry, rewardGrowthGlobalX128, etc.) that shift storage slots. -/// slot0 is at slot 6, liquidity at 17, tickBitmap at 18, ticks at 19. -/// -/// Storage slot for Slipstream CLPool.slot0 -pub const SLIPSTREAM_SLOT0_SLOT: U256 = U256::from_limbs([6, 0, 0, 0]); - -/// Storage slot for Slipstream CLPool.liquidity (slot 17) -pub const SLIPSTREAM_LIQUIDITY_SLOT: U256 = U256::from_limbs([17, 0, 0, 0]); - -/// Base storage slot for Slipstream CLPool.tickBitmap mapping (slot 18) -pub const SLIPSTREAM_TICK_BITMAP_BASE_SLOT: U256 = U256::from_limbs([18, 0, 0, 0]); - -/// Base storage slot for Slipstream CLPool.ticks mapping (slot 19) -pub const SLIPSTREAM_TICKS_BASE_SLOT: U256 = U256::from_limbs([19, 0, 0, 0]); - -/// Storage slot for UniswapV2Pair packed reserves (reserve0 | reserve1 | blockTimestampLast) -pub const V2_RESERVES_SLOT: U256 = U256::from_limbs([8, 0, 0, 0]); - -/// Compute the storage key for a UniswapV3 tickBitmap entry. -/// -/// tickBitmap is a `mapping(int16 => uint256)` at base slot 6. -/// For a mapping at slot `p`, the value for key `k` is at `keccak256(abi.encode(k, p))`. -/// -/// This is the convenience wrapper over -/// [`v3_tick_bitmap_storage_key_with_base`] pinned to -/// [`V3_TICK_BITMAP_BASE_SLOT`]. -/// -/// # Examples -/// -/// ``` -/// use evm_fork_cache::cache::{ -/// v3_tick_bitmap_storage_key, v3_tick_bitmap_storage_key_with_base, -/// V3_TICK_BITMAP_BASE_SLOT, -/// }; -/// -/// // Equivalent to calling the `_with_base` form with the default base slot. -/// assert_eq!( -/// v3_tick_bitmap_storage_key(3), -/// v3_tick_bitmap_storage_key_with_base(3, V3_TICK_BITMAP_BASE_SLOT), -/// ); -/// // The key is deterministic and distinct per word position. -/// assert_ne!(v3_tick_bitmap_storage_key(3), v3_tick_bitmap_storage_key(-3)); -/// ``` -pub fn v3_tick_bitmap_storage_key(word_position: i16) -> U256 { - v3_tick_bitmap_storage_key_with_base(word_position, V3_TICK_BITMAP_BASE_SLOT) -} - -/// Compute the storage key for a V3-style tickBitmap entry with a custom base slot. -/// -/// PancakeSwap V3 uses base slot 7 instead of Uniswap V3's slot 6. -/// -/// The key is `keccak256(abi.encode(int256(word_position), base_slot))`, so a -/// different `base_slot` yields a different key for the same word position. -/// -/// # Examples -/// -/// ``` -/// use evm_fork_cache::cache::{ -/// v3_tick_bitmap_storage_key_with_base, V3_TICK_BITMAP_BASE_SLOT, -/// PANCAKE_V3_TICK_BITMAP_BASE_SLOT, -/// }; -/// -/// let uniswap = v3_tick_bitmap_storage_key_with_base(10, V3_TICK_BITMAP_BASE_SLOT); -/// let pancake = v3_tick_bitmap_storage_key_with_base(10, PANCAKE_V3_TICK_BITMAP_BASE_SLOT); -/// assert_ne!(uniswap, pancake); -/// ``` -pub fn v3_tick_bitmap_storage_key_with_base(word_position: i16, base_slot: U256) -> U256 { - let word_i256 = i256_from_i16(word_position); - let mut preimage = [0u8; 64]; - preimage[..32].copy_from_slice(&word_i256); - preimage[32..64].copy_from_slice(&base_slot.to_be_bytes::<32>()); - keccak256(preimage).into() -} - -/// Compute the storage slot keys for a UniswapV3 tick's Info struct. -/// -/// The ticks mapping is at slot 5: `mapping(int24 => Tick.Info)` -/// Storage key: `keccak256(abi.encode(int256(tick), uint256(5)))` -/// The Tick.Info struct occupies 4 consecutive slots starting from the base. -/// -/// This is the convenience wrapper over [`v3_tick_info_storage_keys_with_base`] -/// pinned to [`V3_TICKS_BASE_SLOT`]. -/// -/// # Examples -/// -/// ``` -/// use evm_fork_cache::cache::v3_tick_info_storage_keys; -/// use alloy_primitives::U256; -/// -/// let keys = v3_tick_info_storage_keys(0); -/// // The four slots are consecutive, starting from the hashed base. -/// assert_eq!(keys[1], keys[0] + U256::from(1)); -/// assert_eq!(keys[2], keys[0] + U256::from(2)); -/// assert_eq!(keys[3], keys[0] + U256::from(3)); -/// ``` -pub fn v3_tick_info_storage_keys(tick: i32) -> [U256; 4] { - v3_tick_info_storage_keys_with_base(tick, V3_TICKS_BASE_SLOT) -} - -/// Compute the storage slot keys for a V3-style tick's Info struct with a custom ticks mapping slot. -/// -/// PancakeSwap V3 uses ticks at slot 6 instead of Uniswap V3's slot 5. The four -/// returned keys are consecutive, starting from -/// `keccak256(abi.encode(int256(tick), ticks_slot))`. -/// -/// # Examples -/// -/// ``` -/// use evm_fork_cache::cache::{v3_tick_info_storage_keys_with_base, V3_TICKS_BASE_SLOT}; -/// use alloy_primitives::U256; -/// -/// let keys = v3_tick_info_storage_keys_with_base(-100, V3_TICKS_BASE_SLOT); -/// assert_eq!(keys[3], keys[0] + U256::from(3)); -/// ``` -pub fn v3_tick_info_storage_keys_with_base(tick: i32, ticks_slot: U256) -> [U256; 4] { - let tick_i256 = i256_from_i24(tick); - let mut preimage = [0u8; 64]; - preimage[..32].copy_from_slice(&tick_i256); - preimage[32..64].copy_from_slice(&ticks_slot.to_be_bytes::<32>()); - let base: U256 = keccak256(preimage).into(); - [ - base, - base + U256::from(1), - base + U256::from(2), - base + U256::from(3), - ] -} - -/// Sign-extend an i16 to a 32-byte big-endian representation (i256). -/// -/// This is needed for Solidity ABI encoding of signed integers in mapping keys. -/// Positive values are zero-extended, negative values are sign-extended with 0xFF bytes. -pub(crate) fn i256_from_i16(value: i16) -> [u8; 32] { - let mut result = if value < 0 { - [0xFF; 32] // Sign-extend with 1s for negative - } else { - [0x00; 32] // Zero-extend for positive - }; - // Place the i16 value in the last 2 bytes (big-endian) - let bytes = value.to_be_bytes(); - result[30] = bytes[0]; - result[31] = bytes[1]; - result -} - -/// Sign-extend an i24 (stored as i32) to a 32-byte big-endian representation (i256). -/// -/// UniswapV3 uses int24 for tick indices. We store them as i32 but only the lower -/// 24 bits are meaningful. This function sign-extends based on the 24-bit value. -pub(crate) fn i256_from_i24(value: i32) -> [u8; 32] { - // Mask to 24 bits and check sign bit (bit 23) - let masked = value & 0x00FF_FFFF; - let is_negative = (masked & 0x0080_0000) != 0; - - let mut result = if is_negative { - [0xFF; 32] // Sign-extend with 1s for negative - } else { - [0x00; 32] // Zero-extend for positive - }; - // Place the i24 value in the last 3 bytes (big-endian) - result[29] = ((masked >> 16) & 0xFF) as u8; - result[30] = ((masked >> 8) & 0xFF) as u8; - result[31] = (masked & 0xFF) as u8; - result -} - -/// Convert an i128 to U256, handling negative values via two's complement. -/// -/// This is needed for packing signed integers into storage slots. -pub(crate) fn i128_to_u256(value: i128) -> U256 { - if value >= 0 { - U256::from(value as u128) - } else { - // Two's complement: for negative values, we need the bit pattern - // as an unsigned value. In Rust, casting i128 to u128 gives us this. - U256::from(value as u128) - } -} diff --git a/src/cache/tick_snapshot.rs b/src/cache/tick_snapshot.rs deleted file mode 100644 index 145f7b2..0000000 --- a/src/cache/tick_snapshot.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Persisted snapshots of UniswapV3-style tick state. -//! -//! Loading every initialized tick of a concentrated-liquidity pool over RPC is -//! expensive, so this module defines the public per-tick state ([`TickInfo`]), -//! its serializable on-disk counterpart ([`SerializableTickInfo`]), and the -//! snapshot containers used to persist a pool's tick data to disk and reload it -//! on a later run, avoiding repeated tick scans. - -use std::collections::HashMap; -use std::path::Path; - -use alloy_primitives::{Address, U256}; -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -use super::versioned; - -const TICK_SNAPSHOT_CACHE_MAGIC: &[u8; 8] = b"EFCTICK\0"; -const TICK_SNAPSHOT_CACHE_VERSION: u32 = 1; - -/// Per-tick liquidity state for a UniswapV3-style concentrated-liquidity pool. -/// -/// This is the public, dependency-free representation of a single tick's -/// `Tick.Info` used by [`crate::cache::EvmCache::inject_v3_ticks`] and returned by -/// [`V3PoolTickSnapshot::to_ticks`]. It mirrors the three fields of the -/// on-chain struct that matter for swap simulation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct TickInfo { - /// Total liquidity that references this tick (`liquidityGross`). - pub liquidity_gross: u128, - /// Net liquidity added/removed when the tick is crossed (`liquidityNet`). - pub liquidity_net: i128, - /// Whether the tick is initialized; controls whether it is processed - /// during swap execution. - pub initialized: bool, -} - -/// Serializable tick info for V3 pools. -/// -/// On-disk counterpart of [`TickInfo`] with the same three fields. It exists as -/// a distinct type so the persisted snapshot format can evolve independently of -/// the public [`TickInfo`] used by the simulation API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializableTickInfo { - pub liquidity_gross: u128, - pub liquidity_net: i128, - pub initialized: bool, -} - -/// Cached tick data snapshot for a UniswapV3 pool. -/// -/// This captures the tick_bitmap and tick Info at a point in time, -/// allowing us to skip expensive tick re-scanning on restart if the -/// pool state hasn't changed significantly. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct V3PoolTickSnapshot { - /// Tick bitmap: word position -> bitmap value - /// Using String keys for JSON serialization (i16 keys not directly supported) - pub tick_bitmap: HashMap, - /// Tick info: tick index -> (liquidity_gross, liquidity_net, initialized) - /// Using String keys for JSON serialization (i32 keys not directly supported) - pub ticks: HashMap, - /// Global liquidity at snapshot time (used for cache validation) - pub last_liquidity: u128, - /// Tick at snapshot time - pub last_tick: i32, -} - -impl V3PoolTickSnapshot { - /// Create a new tick snapshot from pool data. - /// - /// Captures the in-memory `tick_bitmap` and `ticks` maps along with the - /// pool's current `liquidity` and `tick`, converting the integer map keys to - /// their `String` form for serialization. The conversion is total (no entry - /// is dropped); the inverse [`V3PoolTickSnapshot::to_tick_bitmap`] / - /// [`V3PoolTickSnapshot::to_ticks`] may drop entries whose string keys fail - /// to parse. - pub fn from_pool_data( - tick_bitmap: &std::collections::HashMap, - ticks: &std::collections::HashMap, - liquidity: u128, - tick: i32, - ) -> Self { - Self { - tick_bitmap: tick_bitmap - .iter() - .map(|(k, v)| (k.to_string(), *v)) - .collect(), - ticks: ticks - .iter() - .map(|(k, v)| { - ( - k.to_string(), - SerializableTickInfo { - liquidity_gross: v.liquidity_gross, - liquidity_net: v.liquidity_net, - initialized: v.initialized, - }, - ) - }) - .collect(), - last_liquidity: liquidity, - last_tick: tick, - } - } - - /// Convert tick_bitmap back to HashMap. - /// - /// Reverses the `i16 -> String` keying done by - /// [`V3PoolTickSnapshot::from_pool_data`]. Any entry whose string key does - /// not parse back to an `i16` is silently dropped, so a corrupted or - /// out-of-range key produces a smaller map rather than an error. - pub fn to_tick_bitmap(&self) -> std::collections::HashMap { - self.tick_bitmap - .iter() - .filter_map(|(k, v)| k.parse::().ok().map(|key| (key, *v))) - .collect() - } - - /// Convert ticks back to `HashMap`. - /// - /// Reverses the `i32 -> String` keying done by - /// [`V3PoolTickSnapshot::from_pool_data`]. Any entry whose string key does - /// not parse back to an `i32` is silently dropped, so a corrupted or - /// out-of-range key produces a smaller map rather than an error. - pub fn to_ticks(&self) -> std::collections::HashMap { - self.ticks - .iter() - .filter_map(|(k, v)| { - k.parse::().ok().map(|key| { - ( - key, - TickInfo { - liquidity_gross: v.liquidity_gross, - liquidity_net: v.liquidity_net, - initialized: v.initialized, - }, - ) - }) - }) - .collect() - } -} - -/// Cache for V3 pool tick snapshots. -/// -/// Stored in a separate file from immutable data since tick data -/// can change (though infrequently) and may be large. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct V3TickSnapshotCache { - /// Pool address -> tick snapshot - pub snapshots: HashMap, -} - -impl V3TickSnapshotCache { - /// Load tick snapshot cache from disk (binary format). - /// - /// Returns `None` if `path` cannot be read, fails the magic/version check, or - /// fails to decode as bincode for this type. - pub fn load(path: &Path) -> Option { - let data = std::fs::read(path).ok()?; - versioned::decode( - &data, - TICK_SNAPSHOT_CACHE_MAGIC, - TICK_SNAPSHOT_CACHE_VERSION, - "V3 tick snapshot cache", - ) - } - - /// Save tick snapshot cache to disk (binary format). - /// - /// Creates the parent directory if needed, then writes the - /// bincode-serialized cache to `path`. - /// - /// # Errors - /// - /// Returns an error if the parent directory cannot be created, if bincode - /// serialization fails, or if writing the file fails. - pub fn save(&self, path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let data = versioned::encode( - TICK_SNAPSHOT_CACHE_MAGIC, - TICK_SNAPSHOT_CACHE_VERSION, - self, - "V3 tick snapshot cache", - )?; - std::fs::write(path, data)?; - Ok(()) - } - - /// Get a tick snapshot for a pool. - pub fn get(&self, address: Address) -> Option<&V3PoolTickSnapshot> { - self.snapshots.get(&address) - } - - /// Store a tick snapshot for a pool. - /// - /// Overwrites any existing snapshot for `address`. - pub fn set(&mut self, address: Address, snapshot: V3PoolTickSnapshot) { - self.snapshots.insert(address, snapshot); - } - - /// Remove a tick snapshot for a pool. - /// - /// A no-op if no snapshot is stored for `address`. - pub fn remove(&mut self, address: Address) { - self.snapshots.remove(&address); - } - - /// Get the number of cached snapshots. - pub fn len(&self) -> usize { - self.snapshots.len() - } - - /// Check if the cache is empty. - pub fn is_empty(&self) -> bool { - self.snapshots.is_empty() - } -} diff --git a/src/events/mod.rs b/src/events/mod.rs index 510c558..f4dbda8 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -34,9 +34,8 @@ //! 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 +//! time*), but stateful external adapters may read the narrow read-only +//! [`StateView`] to compute a post-state from cached pre-state. 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. //! @@ -67,9 +66,6 @@ //! 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; @@ -83,10 +79,9 @@ 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`. +/// Decoders that compute post-state from pre-state read through this; stateless +/// decoders (such as the built-in ERC-20 `Transfer` decoder) 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 diff --git a/src/events/uniswap_v3.rs b/src/events/uniswap_v3.rs deleted file mode 100644 index c93d667..0000000 --- a/src/events/uniswap_v3.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! 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 f138977..19f65ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! Forked EVM **simulation engine** for DeFi search, MEV, and backtesting. +//! Forked EVM **simulation engine** for EVM search, MEV, and backtesting. //! //! `evm-fork-cache` simulates EVM transactions against recent on-chain state //! without re-deriving that state on every call. It builds on [`revm`], @@ -53,7 +53,7 @@ //! - [`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. +//! Ships an ERC-20 `Transfer` decoder plus traits for external decoders. //! - [`inspector`] — an [`Inspector`](revm::Inspector) that captures ERC20 //! `Transfer` events to reconstruct balance deltas from a simulation. //! - [`multicall`] — batched read-only calls through Multicall3. @@ -99,8 +99,6 @@ //! The `examples/` directory has runnable, documented walkthroughs of each //! module — offline ones that need no network, plus a few that fork real chain //! state over RPC. See the crate README for the full list. -#![cfg_attr(docsrs, feature(doc_cfg))] - pub mod access_list; pub mod access_set; pub mod cache; @@ -116,8 +114,6 @@ 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, diff --git a/src/multicall.rs b/src/multicall.rs index 41a7a16..2303372 100644 --- a/src/multicall.rs +++ b/src/multicall.rs @@ -2,7 +2,7 @@ //! //! This module provides utilities to batch multiple view calls into a single //! EVM execution using the Multicall3 contract. This significantly reduces -//! the number of RPC round-trips needed when loading pool state. +//! the number of RPC round-trips needed when loading related contract state. //! //! Multicall3 is deployed at the same address on all EVM chains: //! `0xcA11bde05977b3631167028862bE2a173976CA11` diff --git a/src/state_update.rs b/src/state_update.rs index 2641023..f67a474 100644 --- a/src/state_update.rs +++ b/src/state_update.rs @@ -4,9 +4,8 @@ //! This module defines the small, generic vocabulary a future event decoder //! emits and [`EvmCache::apply_update`](crate::cache::EvmCache::apply_update) //! consumes, plus the [`StateDiff`] that records what an apply actually changed. -//! It is pure data and logic on itself: it carries **no** protocol or event -//! knowledge and has no dependency on the cache or the `protocols` feature, so -//! it builds under `--no-default-features`. +//! It is pure data and logic on itself: it carries no protocol or event +//! knowledge and has no dependency on the cache implementation. //! //! # The vocabulary //! @@ -68,10 +67,9 @@ //! //! # 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 +//! A storage slot often packs several fields into 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 @@ -163,20 +161,20 @@ impl SlotDelta { /// use alloy_primitives::{Address, U256}; /// use evm_fork_cache::{AccountPatch, PurgeScope, StateUpdate}; /// -/// let pool = Address::repeat_byte(0x01); +/// let contract = Address::repeat_byte(0x01); /// /// // A storage-slot write (authoritative across both cache layers). -/// let slot = StateUpdate::slot(pool, U256::from(0), U256::from(42)); +/// let slot = StateUpdate::slot(contract, U256::from(0), U256::from(42)); /// /// // A balance-only account patch (nonce and code left untouched). -/// let bal = StateUpdate::balance(pool, U256::from(1_000)); +/// let bal = StateUpdate::balance(contract, U256::from(1_000)); /// assert_eq!( /// bal, -/// StateUpdate::Account { address: pool, patch: AccountPatch::default().balance(U256::from(1_000)) }, +/// StateUpdate::Account { address: contract, patch: AccountPatch::default().balance(U256::from(1_000)) }, /// ); /// /// // Drop just two storage slots so the next read re-fetches them. -/// let purge = StateUpdate::purge(pool, PurgeScope::Slots(vec![U256::from(0), U256::from(1)])); +/// let purge = StateUpdate::purge(contract, PurgeScope::Slots(vec![U256::from(0), U256::from(1)])); /// # let _ = (slot, purge); /// ``` #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -224,9 +222,8 @@ pub enum StateUpdate { /// `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. + /// update to a **packed** storage word 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 @@ -454,10 +451,10 @@ pub enum PurgeScope { /// [`EvmCache::purge_account`](crate::cache::EvmCache::purge_account). Account, /// All storage slots; account info preserved. Equivalent to - /// [`EvmCache::purge_pool_storage`](crate::cache::EvmCache::purge_pool_storage). + /// [`EvmCache::purge_contract_storage`](crate::cache::EvmCache::purge_contract_storage). AllStorage, /// Only the listed storage slots. Equivalent to - /// [`EvmCache::purge_pool_slots`](crate::cache::EvmCache::purge_pool_slots). + /// [`EvmCache::purge_contract_slots`](crate::cache::EvmCache::purge_contract_slots). Slots(Vec), } @@ -649,15 +646,6 @@ pub struct SkippedBalanceDelta { /// 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. diff --git a/tests/cache_state.rs b/tests/cache_state.rs index e306511..cd663a4 100644 --- a/tests/cache_state.rs +++ b/tests/cache_state.rs @@ -413,7 +413,7 @@ async fn seed_erc20_balance_slots_skips_scan() -> Result<()> { /// Regression test: both cache layers (the `CacheDB` overlay and the /// `BlockchainDb` backend) must be purged together. Clearing only the backend -/// leaves stale data in the overlay; `purge_pool_storage` clears both. +/// leaves stale data in the overlay; `purge_contract_storage` clears both. #[tokio::test(flavor = "multi_thread")] async fn two_layer_cache_staleness_requires_full_purge() -> Result<()> { let mut cache = setup_cache().await?; @@ -439,7 +439,7 @@ async fn two_layer_cache_staleness_requires_full_purge() -> Result<()> { // Seed the BlockchainDb backend (layer 2) directly so both layers hold data. cache.inject_storage_batch(&[(token, U256::from(7), U256::from(1))]); assert!( - cache.pool_storage_slot_count(token) > 0, + cache.contract_storage_slot_count(token) > 0, "backend should hold the seeded slot" ); @@ -461,21 +461,21 @@ async fn two_layer_cache_staleness_requires_full_purge() -> Result<()> { // Re-seed the backend, then purge BOTH layers and confirm each is cleared. cache.inject_storage_batch(&[(token, U256::from(7), U256::from(1))]); - assert!(cache.pool_storage_slot_count(token) > 0); - let backend_cleared = cache.purge_pool_storage(token); + assert!(cache.contract_storage_slot_count(token) > 0); + let backend_cleared = cache.purge_contract_storage(token); assert!( backend_cleared > 0, - "purge_pool_storage should report cleared backend slots" + "purge_contract_storage should report cleared backend slots" ); assert_eq!( cache.cache_db_storage_slot_count(token), 0, - "overlay should be empty after purge_pool_storage" + "overlay should be empty after purge_contract_storage" ); assert_eq!( - cache.pool_storage_slot_count(token), + cache.contract_storage_slot_count(token), 0, - "backend should be empty after purge_pool_storage" + "backend should be empty after purge_contract_storage" ); Ok(()) @@ -511,7 +511,7 @@ async fn purge_all_storage_clears_both_layers() -> Result<()> { } #[tokio::test(flavor = "multi_thread")] -async fn purge_pool_slots_is_selective() -> Result<()> { +async fn purge_contract_slots_is_selective() -> Result<()> { let mut cache = setup_cache().await?; let contract = Address::repeat_byte(0xCC); @@ -533,7 +533,7 @@ async fn purge_pool_slots_is_selective() -> Result<()> { assert_eq!(cache.cache_db_storage_slot_count(contract), 3); // Purge only slot_a and slot_c. - cache.purge_pool_slots(contract, &[slot_a, slot_c]); + cache.purge_contract_slots(contract, &[slot_a, slot_c]); assert_eq!(cache.cache_db_storage_slot_count(contract), 1); let remaining = cache diff --git a/tests/event_ground_truth.rs b/tests/event_ground_truth.rs deleted file mode 100644 index e382d32..0000000 --- a/tests/event_ground_truth.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! **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(()) -} diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index bc6b2be..5cadd13 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -1,11 +1,11 @@ //! 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. +//! `EventDecoder` / `StateView` traits, the `DecoderRegistry`, the built-in +//! ERC-20 `Transfer` decoder, 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`). @@ -532,370 +532,3 @@ async fn reconcile_errors_without_fetcher() -> Result<()> { ); Ok(()) } - -// =========================================================================== -// UniswapV3 adapter (protocols-gated). -// =========================================================================== - -#[cfg(feature = "protocols")] -mod uniswap_v3 { - use super::*; - 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_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}; - - 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 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/freshness.rs b/tests/freshness.rs index 629c7f7..79a79fb 100644 --- a/tests/freshness.rs +++ b/tests/freshness.rs @@ -218,7 +218,7 @@ async fn purge_account_drops_account_and_storage_from_both_layers() -> Result<() cache.inject_storage_batch(&[(token, U256::from(99), U256::from(1))]); assert!( - cache.pool_storage_slot_count(token) > 0, + cache.contract_storage_slot_count(token) > 0, "backend populated" ); @@ -242,7 +242,7 @@ async fn purge_account_drops_account_and_storage_from_both_layers() -> Result<() ); // Storage gone from the backend. assert_eq!( - cache.pool_storage_slot_count(token), + cache.contract_storage_slot_count(token), 0, "backend storage gone" ); diff --git a/tests/public_release_surface.rs b/tests/public_release_surface.rs new file mode 100644 index 0000000..e311943 --- /dev/null +++ b/tests/public_release_surface.rs @@ -0,0 +1,192 @@ +use std::{fs, path::Path}; + +fn read(path: &str) -> String { + fs::read_to_string(path).unwrap_or_else(|err| panic!("failed to read {path}: {err}")) +} + +#[test] +fn manifest_no_longer_defines_protocols_feature_or_protocol_benchmarks() { + let manifest = read("Cargo.toml"); + + for forbidden in [ + "protocols", + "name = \"storage_keys\"", + "benches/storage_keys.rs", + ] { + assert!( + !manifest.contains(forbidden), + "Cargo.toml should not expose protocol-specific surface: {forbidden}" + ); + } +} + +#[test] +fn protocol_modules_are_not_part_of_the_core_crate_surface() { + for path in [ + "src/events/uniswap_v3.rs", + "src/cache/storage_keys.rs", + "src/cache/tick_snapshot.rs", + "tests/storage_keys.rs", + "tests/event_ground_truth.rs", + "benches/storage_keys.rs", + ] { + assert!( + !Path::new(path).exists(), + "{path} belongs in the protocol adapter crate" + ); + } + + for path in ["src/lib.rs", "src/events/mod.rs", "src/cache/mod.rs"] { + let text = read(path); + for forbidden in [ + "cfg(feature = \"protocols\")", + "doc(cfg(feature = \"protocols\"))", + "UniswapV3Decoder", + "UniswapV3Layout", + "tick_snapshot", + "storage_keys", + "inject_v2_pool_metadata", + "inject_v3_", + ] { + assert!( + !text.contains(forbidden), + "{path} still contains protocol-specific surface: {forbidden}" + ); + } + } +} + +#[test] +fn immutable_cache_is_generic_token_decimals_only() { + let metadata = read("src/cache/metadata.rs"); + + assert!( + metadata.contains("IMMUTABLE_CACHE_VERSION: u32 = 2"), + "metadata cache format should be bumped after removing old pool metadata fields" + ); + assert!( + metadata.contains("token_decimals"), + "token decimals remain the generic immutable cache payload" + ); + + for forbidden in [ + "V2PoolMetadata", + "V3PoolMetadata", + "BalancerPoolMetadata", + "v2_pools", + "v3_pools", + "balancer_pools", + "get_v2_pool", + "set_v2_pool", + "get_v3_pool", + "set_v3_pool", + "get_balancer_pool", + "set_balancer_pool", + "tick_snapshot_cache_path", + ] { + assert!( + !metadata.contains(forbidden), + "immutable cache should not retain protocol metadata: {forbidden}" + ); + } +} + +#[test] +fn release_docs_and_ci_do_not_advertise_removed_protocol_feature() { + for path in [ + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + ".github/workflows/ci.yml", + "docs/KNOWN_ISSUES.md", + "docs/ROADMAP.md", + ] { + let text = read(path); + for forbidden in [ + "protocols feature", + "feature = \"protocols\"", + "default = [\"protocols\"]", + "--no-default-features", + "non-`protocols`", + "UniswapV3Decoder", + "inject_v3_", + "V3 tick snapshot", + "storage_keys", + ] { + assert!( + !text.contains(forbidden), + "{path} still advertises removed protocol surface: {forbidden}" + ); + } + } +} + +#[test] +fn phase_specs_are_marked_as_archival_after_protocol_extraction() { + for path in [ + "docs/phase-2-spec.md", + "docs/phase-3-spec.md", + "docs/phase-4-spec.md", + "docs/phase-5-spec.md", + ] { + let text = read(path); + let top = text.lines().take(10).collect::>().join("\n"); + + assert!( + top.contains("Archival pre-release implementation note"), + "{path} should clearly mark the phase spec as archival" + ); + assert!( + top.contains("protocol adapter surface"), + "{path} should explain that protocol adapters were extracted before release" + ); + assert!( + top.contains("evm-amm-state"), + "{path} should point protocol-specific state tracking to evm-amm-state" + ); + } +} + +#[test] +fn generic_storage_purge_api_uses_contract_terminology() { + let cache = read("src/cache/mod.rs"); + + for required in [ + "has_contract_storage", + "contract_storage_slot_count", + "purge_contract_storage", + "purge_contract_slots", + ] { + assert!( + cache.contains(required), + "cache API should expose generic contract terminology: {required}" + ); + } + + for path in [ + "src/cache/mod.rs", + "src/state_update.rs", + "README.md", + "CHANGELOG.md", + "docs/ROADMAP.md", + "docs/KNOWN_ISSUES.md", + "tests/cache_state.rs", + "tests/state_update.rs", + "tests/freshness.rs", + "examples/state_update_apply.rs", + ] { + let text = read(path); + for forbidden in [ + "has_pool_storage", + "pool_storage_slot_count", + "purge_pool_storage", + "purge_pool_slots", + "hot pool state", + ] { + assert!( + !text.contains(forbidden), + "{path} still exposes pool-oriented cache wording: {forbidden}" + ); + } + } +} diff --git a/tests/serialization_roundtrip.rs b/tests/serialization_roundtrip.rs index 27bdc32..3d79fbd 100644 --- a/tests/serialization_roundtrip.rs +++ b/tests/serialization_roundtrip.rs @@ -1,21 +1,12 @@ //! Round-trip persistence tests for the on-disk side caches. //! -//! `ImmutableDataCache` (token decimals + pool metadata) and, under the -//! `protocols` feature, `V3TickSnapshotCache` are serialized with bincode and -//! reloaded across runs. These modules had no test coverage; the tests here pin -//! that a save/load cycle preserves the data, that a missing file is reported as -//! "no cache", and the current (silent-drop) behavior of the string-keyed V3 tick -//! snapshot — see `docs/KNOWN_ISSUES.md`. -//! -//! Files are written under the system temp directory and cleaned up, following -//! the dependency-free pattern used by the `binary_state` unit tests. +//! `ImmutableDataCache` persists generic immutable side data that belongs in the +//! core engine. Protocol-specific metadata lives in higher-level adapter crates. use std::path::PathBuf; -use alloy_primitives::{Address, B256, U256}; -use evm_fork_cache::cache::{ - BalancerPoolMetadata, ImmutableDataCache, V2PoolMetadata, V3PoolMetadata, -}; +use alloy_primitives::Address; +use evm_fork_cache::cache::ImmutableDataCache; /// A unique temp directory for one test, removed on drop so a failing assertion /// still cleans up. @@ -41,50 +32,21 @@ impl Drop for TempDir { } #[test] -fn immutable_data_cache_round_trips() { +fn immutable_data_cache_round_trips_token_decimals() { let dir = TempDir::new("immutable"); let path = dir.path("immutable_data.bin"); let token_a = Address::repeat_byte(0xA1); let token_b = Address::repeat_byte(0xB2); - let v2_pool = Address::repeat_byte(0x22); - let v3_pool = Address::repeat_byte(0x33); - let balancer_id = B256::repeat_byte(0x44); let mut cache = ImmutableDataCache::default(); assert!(cache.is_empty()); cache.set_token_decimals(token_a, 6); cache.set_token_decimals(token_b, 18); - cache.set_v2_pool( - v2_pool, - V2PoolMetadata { - token0: token_a, - token1: token_b, - last_block_timestamp: 1_700_000_000, - }, - ); - cache.set_v3_pool( - v3_pool, - V3PoolMetadata { - token0: token_a, - token1: token_b, - fee: 3000, - tick_spacing: 60, - }, - ); - cache.set_balancer_pool( - balancer_id, - BalancerPoolMetadata { - tokens: vec![token_a, token_b], - weights: vec![U256::from(80u64), U256::from(20u64)], - swap_fee: U256::from(1_000u64), - last_change_block: U256::from(18_000_000u64), - }, - ); assert!(!cache.is_empty()); - let len_before = cache.len(); + assert_eq!(cache.len(), 2); cache.save(&path).expect("save immutable cache"); let bytes = std::fs::read(&path).expect("read immutable cache file"); @@ -94,38 +56,15 @@ fn immutable_data_cache_round_trips() { ); assert_eq!( &bytes[8..12], - &1u32.to_le_bytes(), + &2u32.to_le_bytes(), "immutable cache must carry an explicit version" ); let loaded = ImmutableDataCache::load(&path).expect("load immutable cache"); - // Counts and scalar values survive the round trip. - assert_eq!(loaded.len(), len_before); + assert_eq!(loaded.len(), 2); assert_eq!(loaded.get_token_decimals(token_a), Some(6)); assert_eq!(loaded.get_token_decimals(token_b), Some(18)); assert_eq!(loaded.get_token_decimals(Address::ZERO), None); - - // Metadata structs do not derive PartialEq, so compare field-by-field. - let v2 = loaded.get_v2_pool(v2_pool).expect("v2 pool present"); - assert_eq!(v2.token0, token_a); - assert_eq!(v2.token1, token_b); - assert_eq!(v2.last_block_timestamp, 1_700_000_000); - - let v3 = loaded.get_v3_pool(v3_pool).expect("v3 pool present"); - assert_eq!(v3.token0, token_a); - assert_eq!(v3.token1, token_b); - assert_eq!(v3.fee, 3000); - assert_eq!(v3.tick_spacing, 60); - - // The Balancer pool is keyed by the id's Debug formatting; a lookup with the - // same B256 after reload must still resolve. - let bal = loaded - .get_balancer_pool(balancer_id) - .expect("balancer pool present after reload (Debug-key round trip)"); - assert_eq!(bal.tokens, vec![token_a, token_b]); - assert_eq!(bal.weights, vec![U256::from(80u64), U256::from(20u64)]); - assert_eq!(bal.swap_fee, U256::from(1_000u64)); - assert_eq!(bal.last_change_block, U256::from(18_000_000u64)); } #[test] @@ -154,124 +93,5 @@ fn immutable_data_cache_load_corrupt_file_is_none() { let dir = TempDir::new("immutable_corrupt"); let path = dir.path("corrupt.bin"); std::fs::write(&path, b"not valid bincode at all").expect("write corrupt file"); - // A decode failure is swallowed and reported as "no cache" (see KNOWN_ISSUES). assert!(ImmutableDataCache::load(&path).is_none()); } - -#[cfg(feature = "protocols")] -mod tick_snapshots { - use super::*; - use std::collections::HashMap; - - use evm_fork_cache::cache::{TickInfo, V3PoolTickSnapshot, V3TickSnapshotCache}; - - #[test] - fn v3_tick_snapshot_round_trips_including_negative_keys() { - let dir = TempDir::new("v3_ticks"); - let path = dir.path("v3_tick_snapshots.bin"); - let pool = Address::repeat_byte(0x77); - - // Word positions and tick indices are signed; include negatives, which - // are exactly where the string-key encoding could go wrong. - let mut bitmap: HashMap = HashMap::new(); - bitmap.insert(-3, U256::from(0b1010u64)); - bitmap.insert(0, U256::from(1u64)); - bitmap.insert(5, U256::from(u128::MAX)); - - let mut ticks: HashMap = HashMap::new(); - ticks.insert( - -887_272, - TickInfo { - liquidity_gross: 1_000, - liquidity_net: -500, - initialized: true, - }, - ); - ticks.insert( - 60, - TickInfo { - liquidity_gross: 42, - liquidity_net: 7, - initialized: false, - }, - ); - - let snapshot = V3PoolTickSnapshot::from_pool_data(&bitmap, &ticks, 12_345u128, -120); - - let mut cache = V3TickSnapshotCache::default(); - assert!(cache.is_empty()); - cache.set(pool, snapshot); - assert_eq!(cache.len(), 1); - - cache.save(&path).expect("save tick cache"); - let bytes = std::fs::read(&path).expect("read tick cache file"); - assert!( - bytes.starts_with(b"EFCTICK\0"), - "tick snapshot cache must carry a magic header" - ); - assert_eq!( - &bytes[8..12], - &1u32.to_le_bytes(), - "tick snapshot cache must carry an explicit version" - ); - let loaded = V3TickSnapshotCache::load(&path).expect("load tick cache"); - - let snap = loaded.get(pool).expect("snapshot present"); - assert_eq!(snap.last_liquidity, 12_345u128); - assert_eq!(snap.last_tick, -120); - // TickInfo derives PartialEq/Eq, so the recovered maps compare directly. - assert_eq!(snap.to_tick_bitmap(), bitmap, "bitmap survives round trip"); - assert_eq!(snap.to_ticks(), ticks, "ticks survive round trip"); - } - - #[test] - fn v3_tick_snapshot_cache_load_legacy_raw_bincode_is_none() { - let dir = TempDir::new("v3_ticks_legacy"); - let path = dir.path("legacy_v3_tick_snapshots.bin"); - let pool = Address::repeat_byte(0x77); - let mut cache = V3TickSnapshotCache::default(); - cache.set( - pool, - V3PoolTickSnapshot::from_pool_data(&HashMap::new(), &HashMap::new(), 0, 0), - ); - std::fs::write(&path, bincode::serialize(&cache).unwrap()).expect("write legacy cache"); - - assert!( - V3TickSnapshotCache::load(&path).is_none(), - "unversioned legacy bincode must be treated as a cache miss" - ); - } - - #[test] - fn v3_tick_snapshot_silently_drops_unparseable_keys() { - // Pin the documented behavior (KNOWN_ISSUES): a string key that does not - // parse as the expected integer type is dropped without error. - let mut snapshot = V3PoolTickSnapshot::from_pool_data( - &HashMap::from([(1i16, U256::from(9u64))]), - &HashMap::new(), - 0, - 0, - ); - snapshot - .tick_bitmap - .insert("not-a-number".to_string(), U256::from(123u64)); - - let recovered = snapshot.to_tick_bitmap(); - assert_eq!(recovered.len(), 1, "the unparseable key is dropped"); - assert_eq!(recovered.get(&1i16), Some(&U256::from(9u64))); - } - - #[test] - fn v3_tick_snapshot_cache_remove() { - let pool = Address::repeat_byte(0x01); - let mut cache = V3TickSnapshotCache::default(); - cache.set( - pool, - V3PoolTickSnapshot::from_pool_data(&HashMap::new(), &HashMap::new(), 0, 0), - ); - assert_eq!(cache.len(), 1); - cache.remove(pool); - assert!(cache.is_empty()); - assert!(cache.get(pool).is_none()); - } -} diff --git a/tests/state_update.rs b/tests/state_update.rs index 815758e..1cdbf95 100644 --- a/tests/state_update.rs +++ b/tests/state_update.rs @@ -428,7 +428,7 @@ async fn apply_purge_account_clears_both_layers() -> Result<()> { "overlay account removed" ); assert_eq!( - cache.pool_storage_slot_count(token), + cache.contract_storage_slot_count(token), 0, "backend storage gone" ); @@ -459,7 +459,7 @@ async fn apply_purge_all_storage_keeps_account() -> Result<()> { let diff = cache.apply_update(&StateUpdate::purge(token, PurgeScope::AllStorage)); assert_eq!( - cache.pool_storage_slot_count(token), + cache.contract_storage_slot_count(token), 0, "backend storage gone" ); @@ -507,16 +507,16 @@ async fn apply_purge_specific_slots() -> Result<()> { #[tokio::test] async fn apply_updates_merges_mixed_batch() -> Result<()> { let acct = Address::repeat_byte(0x66); - let pool = Address::repeat_byte(0x77); + let contract = Address::repeat_byte(0x77); let mut cache = setup_cache().await?; install_default_account(&mut cache, acct); - cache.inject_storage_batch(&[(pool, U256::from(9), U256::from(1))]); + cache.inject_storage_batch(&[(contract, U256::from(9), U256::from(1))]); let diff = cache.apply_updates(&[ - StateUpdate::slot(pool, U256::from(1), U256::from(100)), + StateUpdate::slot(contract, U256::from(1), U256::from(100)), StateUpdate::balance(acct, U256::from(500)), - StateUpdate::purge(pool, PurgeScope::Slots(vec![U256::from(9)])), + StateUpdate::purge(contract, PurgeScope::Slots(vec![U256::from(9)])), ]); assert!(!diff.slots.is_empty(), "slot write recorded"); @@ -564,7 +564,7 @@ async fn apply_updates_same_slot_later_overrides() -> Result<()> { // =========================================================================== #[tokio::test] -async fn refold_purge_pool_storage_returns_same_count() -> Result<()> { +async fn refold_purge_contract_storage_returns_same_count() -> Result<()> { let token = Address::repeat_byte(0x99); let mut cache = setup_cache().await?; install_mock_erc20(&mut cache, token); @@ -574,9 +574,9 @@ async fn refold_purge_pool_storage_returns_same_count() -> Result<()> { ]); // The wrapper still returns the backend slot count it removed. - let removed = cache.purge_pool_storage(token); + let removed = cache.purge_contract_storage(token); assert_eq!(removed, 2); - assert_eq!(cache.pool_storage_slot_count(token), 0); + assert_eq!(cache.contract_storage_slot_count(token), 0); Ok(()) } @@ -612,35 +612,6 @@ async fn refold_inject_storage_batch_fresh_matches_apply_updates() -> Result<()> Ok(()) } -// =========================================================================== -// Decision 2 (LOCKED: normalize) — protocols inject_v3_* now writes through to -// the backend (layer 2). Pre-fix this wrote layer 1 only. -// =========================================================================== - -#[cfg(feature = "protocols")] -#[tokio::test] -async fn inject_v3_tick_bitmap_writes_through_to_backend() -> Result<()> { - use std::collections::HashMap; - - let pool = Address::repeat_byte(0xb2); - let mut cache = setup_cache().await?; - - let mut bitmap = HashMap::new(); - bitmap.insert(0i16, U256::from(123)); - bitmap.insert(1i16, U256::from(456)); - - let injected = cache.inject_v3_tick_bitmap(pool, &bitmap)?; - assert_eq!(injected, 2); - - // Normalized to write-through: the backend (layer 2) now holds the slots. - // Before the refold this count was 0 (overlay-only write). - assert!( - cache.pool_storage_slot_count(pool) > 0, - "inject_v3_tick_bitmap must write through to the backend (Decision 2)" - ); - Ok(()) -} - // =========================================================================== // §15 addendum — relative / read-modify-write updates. // @@ -1513,59 +1484,6 @@ fn state_update_account_field_constructors() { ); } -// =========================================================================== -// §16.8 — Decision-2 write-through pins for the remaining protocols injectors. -// =========================================================================== - -#[cfg(feature = "protocols")] -#[tokio::test] -async fn inject_v2_pool_metadata_writes_through_to_backend() -> Result<()> { - use evm_fork_cache::cache::V2PoolMetadata; - - let pool = Address::repeat_byte(0xb3); - let mut cache = setup_cache().await?; - let meta = V2PoolMetadata { - token0: Address::repeat_byte(0x01), - token1: Address::repeat_byte(0x02), - last_block_timestamp: 0, - }; - - cache.inject_v2_pool_metadata(pool, &meta)?; - - assert!( - cache.pool_storage_slot_count(pool) > 0, - "inject_v2_pool_metadata must write through to the backend (Decision 2)" - ); - Ok(()) -} - -#[cfg(feature = "protocols")] -#[tokio::test] -async fn inject_v3_ticks_writes_through_to_backend() -> Result<()> { - use evm_fork_cache::cache::TickInfo; - use std::collections::HashMap; - - let pool = Address::repeat_byte(0xb4); - let mut cache = setup_cache().await?; - let mut ticks = HashMap::new(); - ticks.insert( - 0i32, - TickInfo { - liquidity_gross: 100, - liquidity_net: 50, - initialized: true, - }, - ); - - let injected = cache.inject_v3_ticks(pool, &ticks)?; - assert!(injected > 0); - assert!( - cache.pool_storage_slot_count(pool) > 0, - "inject_v3_ticks must write through to the backend (Decision 2)" - ); - Ok(()) -} - // =========================================================================== // §16 fix-review regressions — account_state-awareness on the account axis. // =========================================================================== @@ -1717,12 +1635,12 @@ async fn slot_masked_noop_when_masked_bits_already_equal() -> Result<()> { 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 contract = Address::repeat_byte(0x13); let slot = U256::from(0); let mut cache = setup_cache().await?; let diff = cache.apply_update(&StateUpdate::slot_masked( - pool, + contract, slot, U256::from(0xFF), U256::from(0x42), @@ -1732,7 +1650,7 @@ async fn slot_masked_cold_slot_is_skipped_and_surfaced() -> Result<()> { assert_eq!( diff.skipped_masks, vec![SkippedMask { - address: pool, + address: contract, slot, mask: U256::from(0xFF), value: U256::from(0x42), @@ -1742,7 +1660,7 @@ async fn slot_masked_cold_slot_is_skipped_and_surfaced() -> Result<()> { 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); + assert_eq!(cache.cached_storage_value(contract, slot), None); Ok(()) } diff --git a/tests/storage_keys.rs b/tests/storage_keys.rs deleted file mode 100644 index 553cace..0000000 --- a/tests/storage_keys.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Tests for the Uniswap V3-style storage-key derivation helpers, exercised -//! through their public re-export path so the coverage travels with the crate. -//! -//! Gated on the `protocols` feature: the helpers under test are only compiled -//! (and re-exported) when that feature is on, so without it this whole file is -//! cfg'd out rather than failing to build under `--no-default-features`. -#![cfg(feature = "protocols")] - -use alloy_primitives::U256; -use evm_fork_cache::cache::{v3_tick_bitmap_storage_key, v3_tick_info_storage_keys}; - -#[test] -fn tick_bitmap_storage_key_is_consistent_and_distinct() { - // Same word -> same key. - assert_eq!( - v3_tick_bitmap_storage_key(0), - v3_tick_bitmap_storage_key(0), - "same word should produce the same key" - ); - - // Distinct words -> distinct keys. - let key0 = v3_tick_bitmap_storage_key(0); - let key_neg1 = v3_tick_bitmap_storage_key(-1); - let key_pos1 = v3_tick_bitmap_storage_key(1); - assert_ne!(key0, key_neg1); - assert_ne!(key0, key_pos1); - assert_ne!(key_neg1, key_pos1); - - // Keys are keccak outputs, never zero. - assert_ne!(key0, U256::ZERO); -} - -#[test] -fn tick_info_storage_keys_are_four_consecutive_slots() { - // Same tick -> same keys. - let keys = v3_tick_info_storage_keys(0); - assert_eq!(keys, v3_tick_info_storage_keys(0)); - - // Tick.Info occupies four consecutive slots. - assert_eq!(keys[1], keys[0] + U256::from(1)); - assert_eq!(keys[2], keys[0] + U256::from(2)); - assert_eq!(keys[3], keys[0] + U256::from(3)); - - // Distinct ticks -> distinct base slots. - let pos = v3_tick_info_storage_keys(60); - let neg = v3_tick_info_storage_keys(-60); - assert_ne!(keys[0], pos[0]); - assert_ne!(keys[0], neg[0]); - assert_ne!(pos[0], neg[0]); - - assert_ne!(keys[0], U256::ZERO); -}