diff --git a/CHANGELOG.md b/CHANGELOG.md index 1310bc0..36d415e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,20 @@ surface freezes at 1.0. ## [Unreleased] +_Nothing yet._ + +## [0.1.0] - 2026-06-25 + 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:** the in-memory chain ID is no longer hard-defaulted to Arbitrum + (`42161`). When no chain ID is set explicitly and no disk `CacheConfig` is + supplied, the cache now infers it from the provider (`eth_chainId`) and falls + back to `1` (Ethereum mainnet) only if that query fails. Set it explicitly with + the new `EvmCacheBuilder::chain_id` / `EvmCache::set_chain_id`. - **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`; @@ -33,12 +42,27 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). ### Added +- **Honest performance section & benchmarks.** The README "Performance" section + is framed against a *competent* baseline (a shared `foundry-fork-db` + `SharedBackend` + `checkpoint`/`revert` isolation), not a naive + fork-per-candidate strawman. It states plainly that within-block fetch count, + single-threaded CPU, and time-to-result are ~1× against that baseline, and + leads with the genuinely-unique wins: cross-block freshness (0 RPC fetches/block, + pinned in `tests/event_pipeline.rs` — `foundry-fork-db`'s cache is not + block-keyed), parallel `Send` fan-out (`benches/fanout.rs`, a modest measured + ~1.2× on micro-sims), point-in-time consistency, and the act-then-validate + control plane (`benches/freshness.rs`). Adds `examples/fetch_minimization_counted.rs` + and `tests/fetch_minimization.rs` (the fetch-once mechanic, with the + shared-backend caveat stated). The internal copy-on-write snapshot cost model + moved to [`docs/INTERNALS.md`](docs/INTERNALS.md). - **Forked EVM cache** (`cache::EvmCache`) backed by `foundry-fork-db` with lazy 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. + block pin, EVM spec, cache-config, chain-ID, and shared-memory-capacity + configuration. `EvmCacheBuilder::chain_id` sets the `CHAINID` opcode value + explicitly (recommended); `EvmCache::set_chain_id` sets it post-construction. - **Snapshots and overlays** — `create_snapshot()` produces an immutable, `Send + Sync` `EvmSnapshot`; `EvmOverlay` is a cheap per-simulation clone for isolated parallel evaluation. @@ -235,7 +259,7 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). 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. + EVM halts, and host errors. ### Fixed @@ -327,4 +351,5 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - `EvmCache` requires a multi-thread tokio runtime for any RPC-touching path. - See [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) for current limitations. -[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/commits/main +[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/KaiCode2/evm-fork-cache/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 845b370..e01d00f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ readme = "README.md" repository = "https://github.com/KaiCode2/evm-fork-cache" documentation = "https://docs.rs/evm-fork-cache" +# Keep the published crate lean: the per-phase development specs are internal +# planning artifacts (they reference an adapter surface extracted before release), +# and the CI workflow has no consumer value. The consumer-facing ROADMAP.md and +# KNOWN_ISSUES.md under docs/ are still shipped. +exclude = ["docs/phase-*-spec.md", ".github/"] + # Standalone workspace root: keeps this crate from being absorbed by any # ancestor-directory workspace and gives it its own Cargo.lock. [workspace] @@ -63,6 +69,10 @@ harness = false name = "simulation" harness = false +[[bench]] +name = "fanout" +harness = false + [[bench]] name = "freshness" harness = false diff --git a/README.md b/README.md index 0c6d13b..b2fa09a 100644 --- a/README.md +++ b/README.md @@ -125,16 +125,20 @@ println!( ## Core concepts The state stack flows bottom-to-top; reads flow up and the fork DB lazily fetches -misses from RPC: - -``` -EvmOverlay × N isolated, Send simulations (cheap Arc clones) - ▲ clone × N -EvmSnapshot immutable, point-in-time, Send + Sync - ▲ create_snapshot() -EvmCache lazy RPC fetch + local state cache + targeted writes/purge - ▲ lazy fetch -RPC provider +misses from RPC. The event-log path writes hot state in with **no RPC** (the +reactive-sync control plane): + +```mermaid +flowchart BT + RPC["RPC provider"] -->|"lazy fetch · once"| CACHE + LOGS["on-chain event logs"] -.->|"decode → write · 0 RPC"| CACHE + CACHE["EvmCache · !Send
fetch · cache · targeted writes/purge"] -->|"create_snapshot()"| SNAP + SNAP["EvmSnapshot · Send + Sync
immutable · Arc · point-in-time"] -->|"cheap Arc clone × N"| OV + OV["EvmOverlay × N · Send
isolated parallel simulations"] + classDef hot fill:#102a17,stroke:#3fb950,color:#e6edf3; + classDef cool fill:#0d1f2d,stroke:#388bfd,color:#e6edf3; + class SNAP,OV hot; + class RPC,CACHE,LOGS cool; ``` - **`EvmCache`** owns the mutable fork: it fetches, caches, persists, and applies @@ -149,7 +153,30 @@ The [`freshness`](src/freshness.rs) module layers a freshness controller on top: classify each address/slot (`Pinned` / `Volatile` / `ValidThrough`), observe how often slots change, pick what to verify each cycle with a `FreshnessPolicy`, and run the optimistic loop that returns speculative results immediately and a -`Confirmed`/`Corrected`/`Unverified` verdict asynchronously. +`Confirmed`/`Corrected`/`Unverified` verdict asynchronously. Time-to-actionable-result +is gated on local simulation, not on the RPC validation that runs behind it: + +```mermaid +sequenceDiagram + autonumber + participant S as Search loop + participant C as FreshnessController + participant V as Background validator + participant R as RPC + S->>C: run(candidate sims) + C->>C: snapshot + run optimistic sims + C-->>S: SpeculativeSim — optimistic results (~µs) + Note over S: act on speculative results now + C->>V: spawn (Send data only) + V->>R: verify volatile read-set (~L ms) + R-->>V: fresh values + alt nothing the sims read changed + V-->>S: validate().await → Confirmed + else a read slot changed + V->>V: re-run only the affected sims + V-->>S: Corrected { results, changed } + end +``` ## Examples @@ -220,26 +247,96 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address); # } ``` +## Performance & honest trade-offs + +It is easy to post huge multipliers against a *naive* loop (a fresh cold fork per +candidate that re-fetches everything and deep-clones to isolate). That is **not** +the loop a competent revm user writes. Measured against a **competent baseline** — +one shared [`foundry-fork-db`] `SharedBackend` (which caches and deduplicates +fetches) plus `checkpoint`/`revert` isolation on a single fork — this crate is +**roughly at parity on raw within-block speed**, and we say so plainly: + +| Axis | vs a competent shared-backend / checkpoint-revert loop | +|---|---| +| RPC reads **within one block** | **~1×** — a shared `SharedBackend` also fetches each hot slot once | +| Single-threaded per-candidate CPU | **~1×** — `checkpoint`/`revert` isolation is as cheap as an overlay | +| Time-to-result vs *blocking* validation | not a fair comparison — a competent loop doesn't block on a fetch before acting | + +The value of this crate is **not** a within-block speed multiplier. It is +correctness, cross-block freshness, and a structured control plane the bare +primitives don't give you: + +**① Cross-block freshness — the one quantitative win (exact, CI-pinned integer).** +`foundry-fork-db`'s cache is **not block-keyed**: re-pinning to a new block does +not invalidate cached slots, so a refresh-by-refetch loop must re-read every slot +that changed *each block* to stay correct. Decoding the block's logs into targeted +writes keeps that hot state correct with **0 RPC fetches/block** — pinned in +[`tests/event_pipeline.rs`](tests/event_pipeline.rs). Sampled `reconcile()` +re-reads a fraction to catch drift (the honesty backstop). See +[`reactive_cache`](examples/reactive_cache.rs). + +

+ Cumulative RPC slot reads over 8 blocks: refresh-by-refetch climbs linearly to 64 while event-driven writes stay flat at 8 (warmed once, then 0 per block) +

+ +> Honest caveat: an equally sophisticated peer running their *own* log +> subscription + delta applier also reaches 0 fetches/block. The crate's +> contribution is the packaged, cold-aware, reorg-safe, reconcilable vocabulary — +> not an unreachable number. + +**② Parallel fan-out — available, modest, workload-dependent.** +`create_snapshot()` is an immutable `Send + Sync` view; cloning the `Arc` hands +each thread its own overlay, so candidates fan out across cores — which a single +mutable fork cannot do. The measured speedup is honest and modest: **~1.2×** over a +4,096-candidate batch on a 10-core M1 Pro, because these micro-sims are bound by +per-candidate allocation, not EVM compute. Heavier candidates (real txs doing +substantial execution) parallelize better; trivial ones barely. We don't headline a +core-count multiplier we can't reproduce. `cargo bench --bench fanout`; +[`parallel_overlays`](examples/parallel_overlays.rs). + +**③ Point-in-time consistency.** Every overlay reads one frozen, consistent block +state. A lazily-filled shared backend can interleave reads taken at slightly +different moments unless carefully pinned; the snapshot removes that class of bug. + +**④ Act-then-validate control plane (structure, not speed).** Run optimistically +against current state, return immediately, and validate the volatile read-set in +the background — re-running *only* the sims whose slots changed (`rerun_count`), +with `Confirmed`/`Corrected`/`Unverified` verdicts and block-pinned validation. A +searcher who simply acts on warm state is equally fast; the value is the **safe, +selective re-run**, not a latency multiplier. See +[`freshness_optimistic`](examples/freshness_optimistic.rs). + +> [!NOTE] +> **Methodology & candor.** Offline (mocked provider, state injected — no +> network). We deliberately do **not** lead with the headline multipliers a naive +> baseline would produce (~500× fewer reads, ~545× throughput, ~3,800× latency): +> all three collapse toward ~1× against a competent `SharedBackend` + +> `checkpoint`/`revert` loop — which is the very primitive this crate wraps. The +> "0 fetches/block" integer is real and CI-pinned (`cargo test --test +> event_pipeline`); the parallel-fan-out ratio is a Criterion median on an Apple +> M1 Pro, read as a ratio not an absolute. Live-RPC checks live behind the +> `RPC_URL` gate. + ## Benchmarks -Criterion benchmarks live in [`benches/`](benches). The offline benches exercise -the current hot paths at a range of cache sizes, including the Phase 5 -copy-on-write snapshot implementation and retained deep-clone baselines where -useful for A/B comparison: +Criterion benchmarks live in [`benches/`](benches) and run fully offline (mocked +provider) so they are reproducible: | Bench | Measures | | --- | --- | -| `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. | +| `fanout` | **Parallel fan-out (②).** N candidates **sequential vs across cores** over one shared snapshot — the parallelism a live mutable fork can't do. | +| `freshness` | **Act-then-validate (④).** The optimistic loop CPU cost, selective re-run, and the latency-hiding shape (vs a baseline that elects to block). `verify_slots` at scale; multi-sim fan-out. | +| `event_pipeline` | **Cross-block freshness (①).** `ingest_logs` decode+apply throughput (1 → 1000 logs), `reorg_to` purge; the 0-fetch/block property is pinned in `tests/event_pipeline.rs`. | | `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-decoder cost (ERC-20 `Transfer`, generic slot marker), `ingest_logs` decode+apply throughput (1 → 1000 logs), and `reorg_to` purge cost. | +| `simulation` | Hot-path micro-benches and snapshot-implementation regression guards (`create_snapshot` vs the deep-clone reference — an internal cost model, see [`docs/INTERNALS.md`](docs/INTERNALS.md)). | | `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. | +| `revert_decoding` | Built-in (`Error`/`Panic`) and custom-error revert decoding, and decoder dispatch over a registered custom error. | | `create3` | CREATE3 address derivation. | ```sh cargo bench # all offline benches -cargo bench --bench simulation # one suite +cargo bench --bench fanout # one suite ``` The `rpc_mainnet` bench runs against **live mainnet state** to validate diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bb9a9de --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +`evm-fork-cache` is a forked-EVM simulation engine used in search / MEV / +backtesting pipelines, where a correctness bug can have direct financial +consequences for downstream users. Security reports are taken seriously. + +## Supported versions + +This crate is **pre-1.0** and under active development. Only the latest published +`0.x` release line receives security fixes; there is no back-porting to older +`0.x` versions before 1.0. See [`CHANGELOG.md`](CHANGELOG.md) for what shipped in +each release. + +| Version | Supported | +| ------- | --------- | +| latest `0.x` | ✅ | +| older `0.x` | ❌ | + +## Reporting a vulnerability + +**Please do not open a public issue for security-sensitive reports.** + +Report privately via GitHub's +[private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +on this repository: open the **Security** tab → **Report a vulnerability**. If +that channel is unavailable, open a minimal public issue asking for a private +contact (without disclosing details) and a maintainer will follow up. + +Please include: + +- the crate version, Rust version, and feature flags; +- a description of the issue and its impact; +- a minimal reproduction if possible. + +You can expect an initial acknowledgement within a few business days. Once a fix +is available it will be released and the advisory published, crediting the +reporter unless anonymity is requested. + +## Scope and known limitations + +This crate exposes deliberate, documented escape hatches that **bypass its safety +invariants** — notably `unchecked_blockchain_db()` / `unchecked_backend()` (which +sidestep the copy-on-write snapshot invalidation funnel) and the freshness model's +documented reconciliation scope (storage slots only, not account-level state). +These behaviors and their correct usage are described in +[`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md). Misuse of a documented escape hatch +is a usage error, not a vulnerability; a way to violate a documented invariant +*without* using an escape hatch is in scope. When in doubt, report it. diff --git a/assets/cross_block_freshness.svg b/assets/cross_block_freshness.svg new file mode 100644 index 0000000..24fcef0 --- /dev/null +++ b/assets/cross_block_freshness.svg @@ -0,0 +1,62 @@ + + Cumulative RPC slot reads as blocks advance + When a hot set of ~8 slots mutates each block, a refresh-by-refetch loop re-reads them every block (cumulative reads climb linearly), while event-driven writes keep state fresh with zero re-fetches after the first block (flat at 8). + + + + Cross-block freshness — cumulative RPC slot reads + a ~8-slot hot set mutates each block; foundry-fork-db's cache is not block-keyed, so staying correct means re-reading + + + + + + + + + + + + 0 + 16 + 32 + 48 + 64 + + cumulative RPC slot reads + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + + block + + + + + 64 + + + + + + 8 + + + + refresh by re-fetch = ~8 reads / block + + event-driven writes = 0 reads / block (warm once) + + + 0 re-fetches / block + decode the block's logs → targeted writes + + Within ONE block a shared backend also fetches once (~1×); this is the cross-block win. A peer subscribing to logs themselves also reaches 0. + diff --git a/benches/fanout.rs b/benches/fanout.rs new file mode 100644 index 0000000..a99acad --- /dev/null +++ b/benches/fanout.rs @@ -0,0 +1,160 @@ +//! **Pillar 1 — parallel fan-out throughput.** +//! +//! The honest, defensible win of the snapshot/overlay model is **parallelism**. +//! A live mutable fork (one `revm` EVM isolated with `checkpoint`/`revert`) can +//! only evaluate candidates *sequentially* — it cannot be shared mutably across +//! threads. `create_snapshot()` produces an immutable `Send + Sync` view; cloning +//! the `Arc` hands each worker thread its own cheap `EvmOverlay`, so the same N +//! candidates fan out across cores. +//! +//! This bench therefore compares like-for-like execution, sequential vs parallel, +//! over the SAME warm in-memory snapshot (no RPC on either side): +//! +//! - **`sequential`** — N candidates one after another on overlays from one +//! snapshot. This is also where a competent single-threaded baseline lands: a +//! single fork isolated with `checkpoint`/`revert` per candidate is O(touched) +//! and runs in the same per-candidate range, so single-threaded the snapshot +//! model is **~1×** — not a throughput win. We do not claim one. +//! - **`parallel`** — the same N candidates split across +//! `available_parallelism()` worker threads, each driving its own overlays from +//! an `Arc::clone` of the shared snapshot. +//! +//! The win is the `parallel/sequential` ratio. Honest result: it is **modest** +//! (~1.2× on a 10-core M1 Pro for these workloads), because even at 48 ops per +//! candidate the cost is dominated by per-call revm allocation, which contends on +//! the allocator rather than scaling with cores. Heavier, compute-bound candidates +//! parallelize better; we report what we measure and do not claim a core-count +//! multiplier. Wall-clock and machine-dependent — read the ratio. The internal +//! copy-on-write snapshot-construction cost model (COW vs deep clone) lives in +//! `benches/simulation.rs` / `docs/INTERNALS.md`, not here. + +use std::hint::black_box; +use std::sync::Arc; +use std::thread; + +use alloy_primitives::{Address, Bytes, U256, hex}; +use alloy_provider::RootProvider; +use alloy_provider::network::AnyNetwork; +use alloy_rpc_client::RpcClient; +use alloy_sol_types::{SolCall, sol}; +use alloy_transport::mock::Asserter; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use evm_fork_cache::cache::{EvmCache, EvmOverlay}; +use revm::context::result::ExecutionResult; +use revm::state::{AccountInfo, Bytecode}; +use tokio::runtime::Runtime; + +const MOCK_ERC20_RUNTIME_HEX: &str = include_str!("../fixtures/mock_erc20_runtime.hex"); +const BALANCE_SLOT: u64 = 3; + +sol! { + interface MockERC20 { + function balanceOf(address account) returns (uint256); + } +} + +/// A warm cache holding a MockERC20 with one funded owner (state in memory, no RPC). +fn warm_cache(rt: &Runtime) -> (EvmCache, Address, Address, Bytes) { + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider))); + + let token = Address::repeat_byte(0xAA); + let owner = Address::repeat_byte(0xBB); + let runtime = Bytecode::new_raw(Bytes::from( + hex::decode(MOCK_ERC20_RUNTIME_HEX.trim()).unwrap(), + )); + let code_hash = runtime.hash_slow(); + cache.db_mut().insert_account_info( + token, + AccountInfo { + balance: U256::ZERO, + nonce: 0, + code: Some(runtime), + code_hash, + account_id: None, + }, + ); + cache + .insert_mapping_storage_slot(token, U256::from(BALANCE_SLOT), owner, U256::from(1_000u64)) + .unwrap(); + + let calldata = Bytes::from(MockERC20::balanceOfCall { account: owner }.abi_encode()); + (cache, token, owner, calldata) +} + +fn bench_candidate_fanout(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let (mut cache, token, owner, calldata) = warm_cache(&rt); + black_box(cache.create_snapshot()); // warm the memoized base once + + let workers = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(8); + + // Each candidate runs a non-trivial simulation (many reads), modeling a real + // candidate tx that touches dozens of slots. Even so, the per-candidate cost + // stays largely allocation-bound (revm allocates per call), so the measured + // parallel speedup is modest (~1.2×) rather than core-count — reported + // honestly, not tuned until it looks good. + const OPS_PER_CANDIDATE: usize = 48; + let run_candidate = |overlay: &mut EvmOverlay| { + for _ in 0..OPS_PER_CANDIDATE { + let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + black_box(result); + } + }; + + let mut group = c.benchmark_group("candidate_fanout"); + for &n in &[64usize, 256, 1_024] { + group.throughput(Throughput::Elements(n as u64)); + + // Single-threaded: N candidates one after another. The competent + // single-thread baseline (one fork, checkpoint/revert per candidate) lands + // here too — single-threaded the snapshot model is ~1×, not a win. + group.bench_with_input(BenchmarkId::new("sequential", n), &n, |b, &n| { + b.iter(|| { + let snapshot = cache.create_snapshot(); + for _ in 0..n { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + run_candidate(&mut overlay); + } + }) + }); + + // Parallel: the same N candidates across `workers` threads, each driving + // its own overlays from an Arc::clone of the shared immutable snapshot — + // which a single mutable fork cannot do. + group.bench_with_input(BenchmarkId::new("parallel", n), &n, |b, &n| { + b.iter(|| { + let snapshot = cache.create_snapshot(); + let per = n.div_ceil(workers); + thread::scope(|s| { + for start in (0..n).step_by(per) { + let end = (start + per).min(n); + let snapshot = snapshot.clone(); + let calldata = calldata.clone(); + s.spawn(move || { + for _ in start..end { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for _ in 0..OPS_PER_CANDIDATE { + let result = + overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!( + result, + ExecutionResult::Success { .. } + )); + black_box(result); + } + } + }); + } + }); + }) + }); + } + group.finish(); +} + +criterion_group!(benches, bench_candidate_fanout); +criterion_main!(benches); diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md new file mode 100644 index 0000000..30468cb --- /dev/null +++ b/docs/INTERNALS.md @@ -0,0 +1,46 @@ +# Internals — the copy-on-write snapshot cost model + +> This is an implementation note, not a value-prop benchmark. For the numbers a +> consumer cares about (fetch minimization, candidate throughput, reactive sync, +> optimistic latency) see the **Performance** section of the [README](../README.md). + +`create_snapshot()` is the operation a search loop runs once per block before +fanning candidates out. Phase 5 replaced its original O(total state) deep clone +with a two-tier copy-on-write view: the cold `BlockchainDb` index (layer 2) is +flattened once into an immutable, `Arc`-shared base (per-account storage shared by +`Arc`), memoized across snapshots and rebuilt copy-on-write only for changed +addresses; each snapshot then folds just the hot CacheDB delta (layer 1). Reads +stay O(1) and bit-for-bit identical to the deep clone (pinned by the differential +gate in `tests/cow_snapshot.rs`). + +The retained `create_snapshot_deep_clone()` is kept as (a) the read-equivalence +reference and (b) the A/B regression baseline, so a future change can't silently +regress `create_snapshot` back toward O(total state). The `create_snapshot` group +in `benches/simulation.rs` measures both. + +Indicative A/B (Apple M1 Pro, `aarch64-apple-darwin`, Criterion medians; offline, +state injected directly — read the ratio, not the absolute): + +| Cache size (accounts × slots) | Deep clone | COW `create_snapshot` | Ratio | +|:-----------------------------:|:----------:|:---------------------:|:-----:| +| 100 × 8 | 53 µs | 2.1 µs | ~25× | +| 1,000 × 8 | 791 µs | 19 µs | ~41× | +| 2,000 × 16 | 3.2 ms | 52 µs | ~61× | +| 5,000 × 16 | 9.5 ms | 113 µs | ~84× | +| 10,000 × 16 | 16.5 ms | 214 µs | ~77× | + +The deep clone copies every account and slot on every snapshot (O(total state)); +the COW snapshot shares the memoized base and folds only the hot delta, so its +cost tracks *changed* state, not *total* state. This is what makes the +[per-block fan-out](../README.md#performance) cheap — but it is an internal cost +model, which is why it lives here rather than in the README headline. + +**Residual cost (honest):** a COW snapshot is not free. When layer 2 is unchanged +it still pays an O(accounts) length-scan of the layer-2 maps plus an O(layer-1) +fold of the hot delta; a full rebuild (first snapshot, or after `set_block`) is +still O(total state). The decisions and the full cost model are in +[`phase-5-spec.md`](phase-5-spec.md) and [`ROADMAP.md`](ROADMAP.md), and the +residual is recorded in [`KNOWN_ISSUES.md`](KNOWN_ISSUES.md). + +Reproduce: `cargo bench --bench simulation` (the `create_snapshot` and +`resnapshot_hot_loop` groups). diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index da9bfeb..7d74d87 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -106,6 +106,18 @@ surface was moved out of this crate. ## Limitations by design / roadmap +- **Freshness reconciles storage slots only, not account-level state.** The + optimistic verify-and-rerun loop (`FreshnessController::run`) builds its verify + set exclusively from the volatile storage *slots* in each sim's read set; it + never re-fetches or diffs an account's native balance, nonce, or bytecode. + Consequently `Validation::Confirmed` guarantees only that *no volatile storage + slot the sims read had changed* — a sim whose result depends on a + `BALANCE`/`SELFBALANCE` (or nonce/code) that moved on-chain without a + co-changing storage slot can still be reported `Confirmed`. If account-level + state matters to a sim, classify the account `Pinned` and keep it fresh via + event-driven writes (`apply_update`/`apply_updates`), or reconcile it out of + band. The verdict types and the `freshness` module docs state this scope; a + future revision may extend reconcile to touched accounts. - **Solidity `Panic(uint256)` codes above `u64::MAX` decode as `Unknown`.** `decode_solidity_panic` drops out-of-range codes rather than exposing a lossy `u64`. Real compiler-emitted panic codes are single-byte constants, so this is @@ -114,7 +126,27 @@ surface was moved out of this crate. reads `from`/`to` from indexed topics and `value` from the first 32 data bytes. Non-standard or packed `Transfer` encodings may parse incorrectly or be skipped. A self-transfer where `from == to` nets to zero for that owner; this is - documented at the call site. + documented at the call site. Transfer **values ≥ 2^255** are reinterpreted as + negative when accumulated as a signed `I256` delta (`I256::from_raw`), so a + token emitting such a value would corrupt the reconstructed delta. Real ERC-20 + supplies are far below 2^255, so this is unreachable for honest tokens; a + malicious token can misreport balances by other means regardless. +- **`BLOCKHASH` resolves to ZERO in ext-db-less overlays.** Snapshots do not track + block hashes (`block_hashes` is always empty — the live cache does not track + them either), so an `EvmOverlay` built without an `ext_db` (e.g. the freshness + validator's internal overlays) returns `B256::ZERO` for the `BLOCKHASH` opcode. + A simulation of a contract that reads `BLOCKHASH` through such an overlay sees + ZERO rather than the real historical hash. +- **`EventPipeline::derived_slots` grows unbounded without reorgs.** Every + event-derived `(address, slot)` is retained for sampled reconciliation and is + only pruned by `reorg_to`. In long-running steady-state ingestion (no reorgs) + the set grows monotonically (~52 bytes/slot). The field doc says "seen so far"; + a future revision may bound it to the reorg ring's horizon. +- **Reorgs deeper than `ReorgConfig::depth` cannot fully purge.** The touched- + address ring is bounded to `depth` blocks; `reorg_to(n)` only purges addresses + still in the ring, so state touched solely in blocks that have already aged out + of the horizon is silently left un-purged (no error). Size `depth` above the + deepest reorg you expect to handle. - **Copy-on-write snapshots (Phase 5, Pillar A) — done.** `create_snapshot()` is no longer an O(total state) deep clone. The cold `BlockchainDb` index (layer 2) is flattened once into an internal, immutable, `Arc`-shared base (per-account diff --git a/examples/fetch_minimization_counted.rs b/examples/fetch_minimization_counted.rs new file mode 100644 index 0000000..e160970 --- /dev/null +++ b/examples/fetch_minimization_counted.rs @@ -0,0 +1,167 @@ +//! **Pillar 2 — data-fetch minimization (the headline number).** +//! +//! A searcher evaluating N candidate transactions against the *same* recent block +//! reads the same hot working set (a pool's slots, a handful of token balances) +//! over and over. The naive loop — a fresh fork/cache per candidate — cold-fetches +//! that working set *every* candidate. `evm-fork-cache` fetches it **once**, freezes +//! it into a snapshot, and fans every candidate out over cheap in-memory overlays +//! that hold no RPC backend, so the fan-out adds **zero** further fetches. +//! +//! This example counts real fetcher invocations (an `AtomicUsize`-wrapped +//! [`StorageBatchFetchFn`]) and prints the exact integers. The count is +//! deterministic and machine-independent — it is a literal tally of RPC reads +//! avoided, not a wall-clock measurement. +//! +//! Run with: `cargo run --example fetch_minimization_counted` + +#[path = "support/mock.rs"] +mod mock; + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use anyhow::Result; +use evm_fork_cache::cache::{EvmCache, EvmOverlay, StorageBatchFetchFn}; +use revm::context::result::ExecutionResult; +use revm::state::AccountInfo; + +/// Candidate transactions evaluated against the head this block. +const N_CANDIDATES: usize = 500; +/// Distinct storage slots in the shared hot working set (token balance slots). +const WORKING_SET: usize = 8; + +/// An owner address derived from an index. +fn owner(i: usize) -> Address { + let mut bytes = [0u8; 20]; + bytes[12..20].copy_from_slice(&(i as u64 + 1).to_be_bytes()); + Address::from(bytes) +} + +/// `keccak256(abi.encode(owner, MOCK_ERC20_BALANCE_SLOT))` — the balance slot. +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes( + keccak256((owner, U256::from(mock::MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0, + ) +} + +/// An `AtomicUsize`-counting batch fetcher: tallies every requested slot and +/// returns a canned non-zero balance, standing in for the RPC backend. +fn counting_fetcher(counter: Arc) -> StorageBatchFetchFn { + Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + counter.fetch_add(requests.len(), Ordering::Relaxed); + requests + .into_iter() + .map(|(addr, slot)| (addr, slot, Ok(U256::from(1_000u64)))) + .collect() + }, + ) +} + +/// Build an offline cache with a MockERC20 installed at `token` (storage left +/// non-cleared so warmed layer-2 slots are readable) plus a counting fetcher. +async fn cache_with_counter(token: Address) -> Result<(EvmCache, Arc)> { + let mut cache = mock::offline_cache().await?; + let runtime = mock::mock_erc20_runtime(); + let code_hash = runtime.hash_slow(); + cache.db_mut().insert_account_info( + token, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(runtime), + code_hash, + account_id: None, + }, + ); + let counter = Arc::new(AtomicUsize::new(0)); + cache.set_storage_batch_fetcher(counting_fetcher(counter.clone())); + Ok((cache, counter)) +} + +fn balance_of_calldata(account: Address) -> Bytes { + Bytes::from(mock::MockERC20::balanceOfCall { account }.abi_encode()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + let token = Address::repeat_byte(0x42); + let working_set: Vec<(Address, U256)> = (0..WORKING_SET) + .map(|i| (token, balance_slot(owner(i)))) + .collect(); + + // ── evm-fork-cache: fetch the working set ONCE, then fan out ────────────── + let (mut cache, counter) = cache_with_counter(token).await?; + + // Warm the hot working set in a single batch (the prefetch a searcher does + // once per block). Every slot is fetched exactly once. + cache.verify_slots(&working_set)?; + let warmup_fetches = counter.load(Ordering::Relaxed); + + // Freeze the warmed state, then fan N candidates out over cheap overlays. + let snapshot = cache.create_snapshot(); + counter.store(0, Ordering::Relaxed); // measure only the fan-out from here + + for c in 0..N_CANDIDATES { + // Each candidate is an isolated simulation reading the shared hot set. + // The overlay holds no RPC backend (`None`), so it cannot fetch — every + // read is served from the frozen snapshot in memory. + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for i in 0..WORKING_SET { + let result = + overlay.call_raw(owner(c % WORKING_SET), token, balance_of_calldata(owner(i)))?; + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + } + } + let fanout_fetches = counter.load(Ordering::Relaxed); + + // ── Vanilla baseline: a fresh cold cache per candidate ─────────────────── + // Each independent cache cold-fetches the working set it reads. We measure + // the per-candidate cost on one real cold cache; it then repeats N times. + let (mut cold, cold_counter) = cache_with_counter(token).await?; + cold.verify_slots(&working_set)?; + let per_candidate_fetches = cold_counter.load(Ordering::Relaxed); + let vanilla_total = per_candidate_fetches * N_CANDIDATES; + + let crate_total = warmup_fetches + fanout_fetches; + + println!( + "Workload: {N_CANDIDATES} candidate txs, each reading a shared {WORKING_SET}-slot hot set\n" + ); + println!("evm-fork-cache"); + println!(" warm-up fetches (once): {warmup_fetches}"); + println!( + " fan-out fetches ({N_CANDIDATES} sims): {fanout_fetches} <- overlays read the frozen snapshot, no backend" + ); + println!(" TOTAL slots fetched: {crate_total}"); + println!("\nVanilla fork-per-candidate"); + println!(" fetches per candidate: {per_candidate_fetches}"); + println!( + " TOTAL slots fetched: {vanilla_total} ({N_CANDIDATES} x {per_candidate_fetches})" + ); + println!( + "\n=> {}x fewer reads than a NAIVE un-shared loop ({} slots avoided)", + vanilla_total / crate_total.max(1), + vanilla_total - crate_total + ); + println!( + "\nHonest caveat: this beats only the *naive* baseline that builds a fresh cold\n\ + cache per candidate. A competent searcher sharing ONE foundry-fork-db\n\ + SharedBackend across candidates ALSO fetches each slot once within a block\n\ + (its cache dedups) — so within a single block this is ~1x, not {}x.\n\ + The durable win is CROSS-block: as blocks advance and the hot set mutates,\n\ + event-driven writes (see the reactive_cache example) keep it fresh with 0\n\ + re-fetches, where a refetch loop must re-read the changed slots every block.", + vanilla_total / crate_total.max(1), + ); + + assert_eq!( + warmup_fetches, WORKING_SET, + "warm-up fetches the working set once" + ); + assert_eq!(fanout_fetches, 0, "the fan-out adds zero fetches"); + Ok(()) +} diff --git a/examples/freshness_multi_sim.rs b/examples/freshness_multi_sim.rs index 15a8780..c837b12 100644 --- a/examples/freshness_multi_sim.rs +++ b/examples/freshness_multi_sim.rs @@ -29,7 +29,7 @@ use alloy_eips::BlockId; use alloy_primitives::{Address, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use anyhow::Result; -use evm_fork_cache::cache::StorageBatchFetchFn; +use evm_fork_cache::cache::{SimStatus, StorageBatchFetchFn}; use evm_fork_cache::freshness::{ AlwaysVerify, FreshnessController, FreshnessRegistry, SimRequest, Validation, Validity, }; @@ -133,7 +133,7 @@ async fn main() -> Result<()> { let optimistic_ok: Vec = sim .optimistic() .iter() - .map(|r| !r.logs.is_empty()) + .map(|r| matches!(r.status, SimStatus::Success)) .collect(); println!("optimistic (against the snapshot): {optimistic_ok:?} (all succeed)"); @@ -146,7 +146,10 @@ async fn main() -> Result<()> { for c in &changed { println!(" sender slot {} : {} -> {}", c.slot, c.old, c.new); } - let corrected_ok: Vec = results.iter().map(|r| !r.logs.is_empty()).collect(); + let corrected_ok: Vec = results + .iter() + .map(|r| matches!(r.status, SimStatus::Success)) + .collect(); println!("corrected results: {corrected_ok:?}"); // Exactly the second sim flipped success -> revert; the others are diff --git a/examples/freshness_optimistic.rs b/examples/freshness_optimistic.rs index 42fa191..d4b8c5d 100644 --- a/examples/freshness_optimistic.rs +++ b/examples/freshness_optimistic.rs @@ -74,11 +74,20 @@ async fn main() -> Result<()> { ); cache.set_storage_batch_fetcher(fetcher); - // Classification: the balance slot is volatile (default), and slot 6 (a - // would-be immutable like `token0`) is pinned so it is never re-verified. + // Classification: by default every slot is volatile (re-verified); we pin + // slot 6 (a would-be immutable, constructor-set config value) so it is never + // re-verified. let mut registry = FreshnessRegistry::new(); registry.pin_slot(token, U256::from(6)); + // Show the classification before the registry is moved into the controller: + // an unpinned slot is volatile, the pinned one is not (so it is skipped). + println!( + "classification — slot 0 volatile? {} | slot 6 (pinned) volatile? {}", + registry.is_volatile(token, U256::from(0), 0), + registry.is_volatile(token, U256::from(6), 0), + ); + let mut controller = FreshnessController::new(registry, AlwaysVerify); // A non-committing `transfer(recipient, 100)` evaluation sim. diff --git a/examples/reactive_cache.rs b/examples/reactive_cache.rs index e498ad1..45544c3 100644 --- a/examples/reactive_cache.rs +++ b/examples/reactive_cache.rs @@ -21,6 +21,7 @@ mod mock; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use alloy_eips::BlockId; use alloy_primitives::{Address, Bytes, Log, U256, keccak256}; @@ -77,6 +78,25 @@ async fn main() -> Result<()> { let mut controller = FreshnessController::new(FreshnessRegistry::new(), AlwaysVerify); + // Install a counting fetcher up front so we can show that *ingest* performs no + // RPC reads (it decodes logs into writes), while *reconcile* below samples a + // few slots on purpose. It returns the (deliberately drifted) chain value for + // bob so the reconcile step has something to correct. + let fetches = Arc::new(AtomicUsize::new(0)); + let counter = fetches.clone(); + 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| { + counter.fetch_add(requests.len(), Ordering::Relaxed); + 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 block = 100u64; let digest = pipeline.ingest_logs( &mut cache, @@ -85,6 +105,11 @@ async fn main() -> Result<()> { ); println!("=== ingested block {} ===", digest.block); + println!( + " RPC slot fetches during ingest: {} <- a poller would refetch {} touched slot(s)/block", + fetches.load(Ordering::Relaxed), + digest.touched_slots.len(), + ); println!( " decoded {} log(s) -> {} slot change(s), {} skipped", digest.decoded_logs, @@ -108,20 +133,13 @@ async fn main() -> Result<()> { digest.touched_slots.len() ); - 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); - + fetches.store(0, Ordering::Relaxed); // measure reconcile's sampling cost let report = pipeline.reconcile(&mut cache, &[(token, bob_slot)])?; println!("\n=== reconcile (sampled {} slot) ===", report.checked); + println!( + " RPC slot fetches during reconcile: {} <- the sampled honesty backstop", + fetches.load(Ordering::Relaxed), + ); if report.mismatched.is_empty() { println!(" no drift: event-derived state matches chain"); } else { @@ -137,8 +155,12 @@ async fn main() -> Result<()> { mock::balance_of(&mut cache, token, bob)? ); + // `EventPipeline::new` uses the default `ReorgConfig`, whose + // `PurgeScope::AllStorage` clears the storage of every address touched after + // the new head — so bob's balance slot reads back as uncached (`None`) and the + // next access would re-fetch it from RPC. let purge = pipeline.reorg_to(&mut cache, 99); - println!("\n=== reorg to block 99 ==="); + println!("\n=== reorg to block 99 (default scope: AllStorage) ==="); println!( " purged {} address(es); bob balance slot now cached as {:?}", purge.purged.len(), diff --git a/fixtures/README.md b/fixtures/README.md index ce6f067..60703de 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -43,3 +43,19 @@ 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 ``` + +## `Multicall3` + +- `multicall3_runtime.hex` — the canonical [Multicall3](https://github.com/mds1/multicall) + deployed (runtime) bytecode. Multicall3 lives at the same address + (`0xcA11bde05977b3631167028862bE2a173976CA11`) on virtually every EVM chain, so + etching this bytecode there lets the offline [`../tests/multicall.rs`](../tests/multicall.rs) + suite exercise the real `aggregate3` build/execute/decode path (input-order + results and `allowFailure` semantics) with no network. + +### Regenerating + +```sh +cast code 0xcA11bde05977b3631167028862bE2a173976CA11 --rpc-url "$RPC_URL" \ + | sed 's/^0x//' > fixtures/multicall3_runtime.hex +``` diff --git a/fixtures/multicall3_runtime.hex b/fixtures/multicall3_runtime.hex new file mode 100644 index 0000000..9013a03 --- /dev/null +++ b/fixtures/multicall3_runtime.hex @@ -0,0 +1 @@ +6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c146101d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e1461015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d610128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b604051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b2366004610c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffffffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d610242366004610a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b50604051418152602001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b3480156102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102d8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190915260008152606060208201528152602001906001900390816102f65790505b5092503660005b8281101561047757600085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b905060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffffffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d6000602084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b60405190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a5790505b5091503660005b8281101561068657600087878381811061056257610562610d60565b90506020028101906105749190610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061060957610609610d60565b602090810291909101015290508061067d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b6040805180820190915260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e657600084828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b905060200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b6040805180820190915260008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a105760008482815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af19150503d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b606091505b506020830152151581528715610a07578051610a07576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b6000806060610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b50813567ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed57602081850181015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ffffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b838152826020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf457600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284031215610d2a57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffffffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b8183823760009101908152919050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112610dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603018112610dc357600080fdfea2646970667358221220bb2b5c71a328032f97c676ae39a1ec2148d3e5d6f73d95e9b17910152d61f16264736f6c634300080c0033 diff --git a/src/access_list.rs b/src/access_list.rs index 4bb44d0..39d495b 100644 --- a/src/access_list.rs +++ b/src/access_list.rs @@ -1,9 +1,14 @@ -//! Selective EIP-2930 access list builder. +//! EIP-2930 access list builder with L2 profitability accounting. //! -//! Builds an access list containing only well-known, low-entropy storage slots -//! (V3 slot0/liquidity, V2 reserves) whose serialized form is mostly zero bytes -//! and cheap to post as L1 data. Skips keccak-derived mapping keys (tick bitmap, -//! tick info) which are 32 random bytes and expensive on L1. +//! The caller supplies the addresses and storage slots to include; this module +//! decides *whether attaching the list is profitable*, not *which slots are +//! interesting* (it carries no protocol-specific slot knowledge). The trade-off +//! is purely economic: pre-declaring an account/slot warms it (cheaper EIP-2929 +//! execution) but costs L1 data to post the list. Slots whose serialized form is +//! mostly zero bytes (e.g. small, low-entropy values) are cheap to post; dense, +//! high-entropy 32-byte keys are expensive on L1 — so the value of a given entry +//! depends on its bytes, which is why the include/exclude decision is left to the +//! caller and the profitability check below. //! //! On L2, automatically disables itself when L1 fees rise high enough that the //! L1 data cost exceeds the L2 execution savings. Arbitrum uses `ArbGasInfo` @@ -89,10 +94,13 @@ sol! { } } -/// A selective EIP-2930 access list built from the execution plan. +/// An EIP-2930 access list builder with L2 profitability accounting. /// -/// Only includes entries where the L2 execution savings (100 gas each) -/// are likely to exceed the L1 data posting cost of the serialized entry. +/// The caller decides which addresses/slots to add (via +/// [`add_address`](Self::add_address) / [`add_storage_key`](Self::add_storage_key)); +/// the builder itself applies no per-entry selection. The `into_access_list_*` +/// finalizers decide whether attaching the *whole* list is profitable, comparing +/// its L1 data-posting cost against the L2 warm-access savings. pub struct SmartAccessList { items: Vec, } diff --git a/src/cache/binary_state.rs b/src/cache/binary_state.rs index ccfb3d0..369d433 100644 --- a/src/cache/binary_state.rs +++ b/src/cache/binary_state.rs @@ -114,13 +114,22 @@ pub fn save_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> Result<() /// /// Returns `false` (rather than erroring) when `path` cannot be read or its /// contents fail the magic/version check or fail to decode as the expected -/// bincode layout; failures are logged at `warn` level. +/// bincode layout. A missing file (the normal cold-start case) is logged at +/// `debug`; an actual read error (e.g. permission denied) and any +/// magic/version/decode failure are logged at `warn`. pub fn load_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> bool { let start = Instant::now(); let data = match std::fs::read(path) { Ok(d) => d, - Err(_) => return false, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + debug!("No binary EVM state file found, starting fresh"); + return false; + } + Err(e) => { + warn!(error = %e, "Failed to read binary EVM state, starting fresh"); + return false; + } }; let Some(state) = versioned::decode::( diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 19af0e4..8cadf91 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -294,6 +294,7 @@ pub struct EvmCacheBuilder

{ cache_config: Option, spec_id: SpecId, shared_memory_capacity: SharedMemoryCapacity, + chain_id: Option, } impl

EvmCacheBuilder

@@ -308,6 +309,7 @@ where cache_config: None, spec_id: SpecId::CANCUN, shared_memory_capacity: SharedMemoryCapacity::default(), + chain_id: None, } } @@ -337,6 +339,20 @@ where self } + /// Set the chain ID reported to simulations via the `CHAINID` opcode. + /// + /// **Recommended.** This is the explicit, authoritative way to set the chain + /// ID. If left unset, [`build`](Self::build) infers it from the provider + /// (`eth_chainId`), falling back to `1` (Ethereum mainnet) only if that query + /// fails. A disk [`cache_config`](Self::cache_config) also carries a + /// `chain_id` (which additionally namespaces the on-disk cache directory); + /// when both are set, the value passed here wins for the `CHAINID` opcode, so + /// keep them consistent. + pub fn chain_id(mut self, chain_id: u64) -> Self { + self.chain_id = Some(chain_id); + self + } + /// Enable disk-backed caching with the given configuration. /// /// Supplying a [`CacheConfig`] turns on persistence of EVM state, bytecodes, @@ -362,15 +378,26 @@ where } /// Build the [`EvmCache`], fetching the pinned block's header for context. + /// + /// If a chain ID was not set via [`chain_id`](Self::chain_id), it is inferred + /// from the provider (`eth_chainId`); see [`chain_id`](Self::chain_id) for the + /// full resolution order. pub async fn build(self) -> EvmCache { - EvmCache::with_cache_capacity( + let explicit_chain_id = self.chain_id; + let mut cache = EvmCache::with_cache_capacity( self.provider, self.block, self.cache_config, self.spec_id, self.shared_memory_capacity, ) - .await + .await; + // An explicit builder value is authoritative for the `CHAINID` opcode and + // overrides both the inferred value and any `cache_config` chain id. + if let Some(chain_id) = explicit_chain_id { + cache.set_chain_id(chain_id); + } + cache } } @@ -985,15 +1012,32 @@ impl EvmCache { }, ); + // Resolve the chain ID reported to simulations (the `CHAINID` opcode). A + // disk `CacheConfig` is authoritative (its `chain_id` also namespaces the + // on-disk cache directory); otherwise infer it from the provider via + // `eth_chainId`, falling back to 1 (Ethereum mainnet) only if that query + // fails. Resolved before `provider` is moved into the backend below. + // Prefer setting it explicitly through `EvmCacheBuilder::chain_id`. + let chain_id = match cache_config.as_ref() { + Some(cfg) => cfg.chain_id, + None => match provider.get_chain_id().await { + Ok(id) => id, + Err(e) => { + debug!( + error = %e, + "Failed to infer chain ID from provider; defaulting to 1 (Ethereum mainnet). Set it explicitly via EvmCacheBuilder::chain_id." + ); + 1 + } + }, + }; + // Spawn the backend handler on a background task let backend = SharedBackend::spawn_backend(provider, blockchain_db.clone(), Some(block_id)).await; let db = CacheDB::new(backend.clone()); - // Extract chain_id from cache config if available, default to Arbitrum - let chain_id = cache_config.as_ref().map(|c| c.chain_id).unwrap_or(42161); - // Resolve the shared-memory pre-allocation. For `Auto` we size from the // amount of layer-2 chain state actually loaded (post-filter), so a large // bincode state file yields a larger buffer; `Fixed` ignores the count. @@ -1073,6 +1117,13 @@ impl EvmCache { /// /// Useful when you want to share a backend between multiple caches /// (e.g. parallel simulation threads). + /// + /// **Shared pinned block.** A `SharedBackend` owns a single pinned fork + /// height. Calling [`set_block`](Self::set_block) / `repin_to_block` on *any* + /// cache built from the same backend re-pins the RPC fork height for **all** + /// of them. Sibling caches sharing one backend should agree on a block and not + /// re-pin independently; build separate backends if they must fork at + /// different heights. pub fn from_backend( backend: SharedBackend, blockchain_db: BlockchainDb, @@ -2156,6 +2207,17 @@ impl EvmCache { self.chain_id } + /// Set the chain ID reported to simulations via the `CHAINID` opcode. + /// + /// Prefer setting this at construction through + /// [`EvmCacheBuilder::chain_id`]. This setter exists for cases where the + /// chain ID must change after construction. It takes effect on the next + /// [`create_snapshot`](Self::create_snapshot) / `build_evm`; existing + /// snapshots and overlays keep the chain ID captured when they were created. + pub fn set_chain_id(&mut self, chain_id: u64) { + self.chain_id = chain_id; + } + /// Take a low-level, same-thread snapshot of the CacheDB overlay for /// in-place restore. /// diff --git a/src/cache/overlay.rs b/src/cache/overlay.rs index 372f256..395815a 100644 --- a/src/cache/overlay.rs +++ b/src/cache/overlay.rs @@ -745,6 +745,11 @@ impl Database for EvmOverlay { if let Some(ref ext_db) = self.ext_db { return ext_db.block_hash_ref(number); } + // Snapshots never populate `block_hashes` (the live cache does not track + // block hashes), so without an `ext_db` the `BLOCKHASH` opcode resolves to + // ZERO. Overlays built internally (e.g. the freshness validator) pass + // `ext_db = None`; a contract that reads `BLOCKHASH` through such an + // overlay sees ZERO. Documented in docs/KNOWN_ISSUES.md. Ok(B256::ZERO) } } diff --git a/src/create3.rs b/src/create3.rs index b7d7082..e3f2779 100644 --- a/src/create3.rs +++ b/src/create3.rs @@ -9,13 +9,20 @@ use alloy_primitives::{Address, B256, address, b256, keccak256}; -/// Address of the widely deployed universal CREATE3 factory (the CreateX / -/// CREATE3 factory implementation). +/// Address of the widely deployed `CREATE3Factory` (the ZeframLou / Solmate-style +/// implementation, deterministically deployed cross-chain — e.g. by LiFi). /// -/// This is the canonical cross-chain address at which the CreateX-style -/// CREATE3 factory has been deterministically deployed on many EVM networks. -/// The derivation in this module assumes the factory at this address uses the -/// CREATE3 proxy init code whose hash is `CREATE3_PROXY_INITCODE_HASH`. +/// This is the canonical cross-chain address at which this factory has been +/// deployed on many EVM networks. Its derivation salts as +/// `keccak256(abi.encodePacked(deployer, salt))` over the standard Solady/Solmate +/// CREATE3 proxy init code whose hash is `CREATE3_PROXY_INITCODE_HASH`, which is +/// what [`derive_universal_create3_address`] reproduces. +/// +/// **This is not pcaversaccio's CreateX** (`0xba5Ed0...28ba5Ed`). CreateX applies +/// a guarded-salt transformation (permissioned-deploy / cross-chain-redeploy +/// protection, chain-ID mixing) that differs from the plain +/// `keccak256(deployer, salt)` used here, so this module does **not** predict +/// CreateX deployment addresses. /// /// Callers must verify the factory is actually deployed at this address on /// their target chain before relying on a derived address: if the factory is @@ -23,7 +30,7 @@ use alloy_primitives::{Address, B256, address, b256, keccak256}; /// address will not correspond to any real deployment. pub const UNIVERSAL_CREATE3_FACTORY: Address = address!("93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1"); -// CREATE3 proxy initcode used by the universal factory implementation. +// Standard Solady/Solmate CREATE3 proxy initcode used by this factory. // keccak256(0x67363d3d37363d34f03d5260086018f3) const CREATE3_PROXY_INITCODE_HASH: B256 = b256!("21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f"); diff --git a/src/errors.rs b/src/errors.rs index 46b9582..649ada7 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -654,13 +654,6 @@ impl From for SimError { } } -/// Deprecated alias for [`SimError`]. -#[deprecated( - since = "0.2.0", - note = "renamed to `SimError`; `Halt` is now a distinct variant" -)] -pub type SimulationErrorKind = SimError; - #[cfg(test)] mod tests { use super::*; diff --git a/src/events/mod.rs b/src/events/mod.rs index f4dbda8..0bfe210 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -297,6 +297,13 @@ impl EventPipeline { touched_addrs.insert(skip.address); self.note_touched_slot(&mut digest, skip.address, skip.slot); } + // Skipped (cold) `Account` patches carry an address but no slot, so + // track the address only — mirroring `skipped_balances`. A third-party + // decoder emitting `StateUpdate::Account` against a cold account would + // otherwise be invisible to reorg purge and freshness tracking. + for skip in &diff.skipped_accounts { + touched_addrs.insert(skip.address); + } digest.applied.merge(diff); } diff --git a/src/freshness.rs b/src/freshness.rs index f741243..5fe2957 100644 --- a/src/freshness.rs +++ b/src/freshness.rs @@ -21,6 +21,18 @@ //! `clock.now()` as `now: u64` through the tracker, the policy, and //! [`FreshnessRegistry::is_volatile`]. //! +//! # Reconciliation scope +//! +//! The optimistic loop verifies only **volatile storage slots** in each sim's +//! read set. Account-level state — native balance, nonce, and bytecode — is +//! **not** re-fetched or diffed, so [`Validation::Confirmed`] means *"no volatile +//! storage slot the sims read had changed"*, not *"no account state changed"*. A +//! sim whose result depends on a `BALANCE`/`SELFBALANCE` (or nonce/code) that +//! moved on-chain without a co-changing storage slot in its read set can still be +//! reported `Confirmed`. If account-level state matters to a sim, mark the +//! account [`Validity::Pinned`] and keep it fresh via event-driven writes, or +//! reconcile it out of band. See [`Validation`] for the per-verdict note. +//! //! # Example //! //! Classification + policy selection, no network required: @@ -32,22 +44,25 @@ //! }; //! use evm_fork_cache::cache::SlotObservationTracker; //! -//! let pool = Address::repeat_byte(0x01); -//! let slot0 = U256::from(0); -//! let immutable = U256::from(6); // e.g. token0 +//! let contract = Address::repeat_byte(0x01); +//! let volatile_slot = U256::from(0); +//! let immutable_slot = U256::from(6); // e.g. a constructor-set config value //! //! let mut registry = FreshnessRegistry::new(); // default: Volatile -//! registry.pin_slot(pool, immutable); // never re-verified +//! registry.pin_slot(contract, immutable_slot); // never re-verified //! //! // `now` is in clock units (block number for the default BlockClock). //! let now = 100; -//! assert!(registry.is_volatile(pool, slot0, now)); -//! assert!(!registry.is_volatile(pool, immutable, now)); +//! assert!(registry.is_volatile(contract, volatile_slot, now)); +//! assert!(!registry.is_volatile(contract, immutable_slot, now)); //! //! // Policies pick which volatile candidates to verify this cycle. //! let obs = SlotObservationTracker::new(); -//! let candidates = [(pool, slot0)]; -//! assert_eq!(AlwaysVerify.select(&candidates, &obs, now), vec![(pool, slot0)]); +//! let candidates = [(contract, volatile_slot)]; +//! assert_eq!( +//! AlwaysVerify.select(&candidates, &obs, now), +//! vec![(contract, volatile_slot)] +//! ); //! assert!(NeverVerify.select(&candidates, &obs, now).is_empty()); //! ``` @@ -458,12 +473,27 @@ pub struct SlotChange { } /// The deferred verdict on a [`SpeculativeSim`]'s optimistic results. +/// +/// # Reconciliation scope +/// +/// The verdict reflects only **volatile storage slots** in each sim's read set. +/// Account-level state — native balance, nonce, and bytecode — is **not** +/// re-verified, so a sim whose result depends on a `BALANCE`/`SELFBALANCE` (or +/// nonce/code) that changed on-chain *without* a co-changing storage slot in its +/// read set can still be reported [`Confirmed`](Validation::Confirmed). Classify +/// such accounts as [`Validity::Pinned`] and keep them fresh via event-driven +/// writes if their account-level state matters to your sims. See the module-level +/// docs for the full freshness contract. pub enum Validation { - /// Nothing the sims read had changed; the optimistic results are correct. + /// No **volatile storage slot** in the sims' read sets had changed, so the + /// optimistic results are trusted. Note this does *not* cover account-level + /// balance/nonce/code changes — see the [type-level scope](Validation). Confirmed, - /// At least one read slot changed. `results` is the optimistic set with the - /// affected sims re-run against the fresh values; `changed` lists the slots - /// that differed (also queued for flow-back into the cache). + /// At least one read storage slot changed. `results` is the optimistic set + /// with the affected sims re-run against the fresh values; `changed` lists the + /// slots that differed (also queued for flow-back into the cache). Only + /// storage slots are reconciled — account-level state is not (see the + /// [type-level scope](Validation)). Corrected { /// Optimistic results with the affected sims replaced by re-runs. results: Vec, @@ -558,12 +588,16 @@ impl SimRequest { /// cancel flag and aborts the background task. Cancellation is **cooperative and /// best-effort, not instantaneous**: `run_validator` is synchronous, so an abort /// cannot preempt it once it is running. Instead the validator checks the flag at -/// a few checkpoints — before fetching, and before recording observations or -/// queuing a correction — so a cancel observed at a checkpoint prevents the -/// remaining side effects. A validator already executing a synchronous step (e.g. -/// mid-fetch) completes that step before reaching the next checkpoint. The intent -/// is that a dropped speculation does not flow its corrections back into the -/// cache; it does not guarantee that an in-flight fetch is interrupted. +/// several checkpoints — before each fetch, and (on the first pass) after a fetch +/// returns but before it records observations or queues corrections — so a cancel +/// observed at a checkpoint skips the side effects downstream of it. A validator +/// already executing a synchronous step (e.g. mid-fetch) completes that step +/// before reaching the next checkpoint, and corrections accumulated up to a fetch +/// that completed in the *final* loop iteration may still be queued for flow-back +/// (the post-loop queue is not guarded by an immediately-preceding checkpoint). +/// The intent is that a dropped speculation stops doing further work and, in the +/// common case, does not flow corrections back into the cache; it does not +/// guarantee that an in-flight or just-completed fetch's correction is withheld. pub struct SpeculativeSim { optimistic: Vec, /// `Option` so `validate`/`into_optimistic` can take the handle and skip the @@ -577,6 +611,11 @@ pub struct SpeculativeSim { impl SpeculativeSim { /// The optimistic results, readable before validation completes. + /// + /// These (and the re-run results in [`Validation::Corrected`]) carry an + /// **empty `token_deltas`** map: the optimistic loop does not run transfer + /// tracking, so the signed per-token balance deltas populated by the + /// committing `simulate_call_with_balance_deltas` path are not available here. pub fn optimistic(&self) -> &[CallSimulationResult] { &self.optimistic } @@ -765,11 +804,13 @@ impl FreshnessController { /// [`SpeculativeSim`] immediately. /// /// # Panics - /// Spawns a background task whose (synchronous) fetcher uses - /// `tokio::task::block_in_place` internally, so it must run on a - /// **multi-thread** tokio runtime (`#[tokio::main(flavor = "multi_thread")]` - /// or `Builder::new_multi_thread()`). On a current-thread runtime - /// `block_in_place` panics, mirroring the [`EvmCache`] constructor note. + /// Must be called from within a tokio runtime: it calls `tokio::spawn` to + /// launch the background validator, which panics (`there is no reactor + /// running`) if no runtime is active. The spawned (synchronous) fetcher + /// additionally uses `tokio::task::block_in_place` internally, so the runtime + /// must be **multi-thread** (`#[tokio::main(flavor = "multi_thread")]` or + /// `Builder::new_multi_thread()`); on a current-thread runtime `block_in_place` + /// panics, mirroring the [`EvmCache`] constructor note. /// /// # Errors /// Returns an error if any optimistic simulation fails to execute against the diff --git a/src/state_update.rs b/src/state_update.rs index f67a474..8e688e9 100644 --- a/src/state_update.rs +++ b/src/state_update.rs @@ -207,9 +207,11 @@ pub enum StateUpdate { /// /// Read-modify-write: the current `AccountInfo::balance` is read, the /// [`SlotDelta`] applied, and the result written back through both layers - /// (nonce and code preserved). **Cold-aware** — "cold" here means the account - /// is absent from *both* layers (its balance is unknown). A `BalanceDelta` on a - /// cold account is not applied; it is surfaced in + /// (nonce and code preserved). **Cold-aware** — "cold" here means the + /// account's info is unknown to the EVM: absent from *both* layers, **or** + /// present in the overlay as revm `NotExisting` (which the internal account + /// read also treats as cold, mirroring `DbAccount::info()`). A `BalanceDelta` + /// on a cold account is not applied; it is surfaced in /// [`StateDiff::skipped_balances`] instead (so no default account is /// materialized to mask the real on-chain one — see the module docs). BalanceDelta { @@ -246,8 +248,9 @@ pub enum StateUpdate { /// Patch an already-known account's balance/nonce/code (partial — see /// [`AccountPatch`]). /// - /// Cold-aware: if the account is absent from **both** layers, the patch is not - /// applied and is surfaced in [`StateDiff::skipped_accounts`] as a + /// Cold-aware: if the account's info is unknown to the EVM (absent from + /// **both** layers, or present in the overlay as revm `NotExisting`), the patch + /// is not applied and is surfaced in [`StateDiff::skipped_accounts`] as a /// [`SkippedAccountPatch`]. Use [`StateUpdate::AccountUpsert`] when /// materializing a cold/default account is intentional. Account { @@ -474,7 +477,8 @@ pub enum PurgeScope { /// /// [`is_empty`](Self::is_empty) / [`len`](Self::len) are **changes-only**, so a /// cold-skipped update ([`SlotDelta`](StateUpdate::SlotDelta) / -/// [`BalanceDelta`](StateUpdate::BalanceDelta) / [`Account`](StateUpdate::Account)) +/// [`BalanceDelta`](StateUpdate::BalanceDelta) / +/// [`SlotMasked`](StateUpdate::SlotMasked) / [`Account`](StateUpdate::Account)) /// is invisible to them. After applying cold-aware updates, check /// [`has_skipped`](Self::has_skipped) (or inspect the `skipped_*` fields) — a cold /// target was dropped, not applied. @@ -511,28 +515,32 @@ pub struct StateDiff { impl StateDiff { /// Whether the diff recorded no change at all. /// - /// Changes-only: counts `slots` + `accounts` + `purged`. A skipped relative - /// update ([`skipped`](Self::skipped) / [`skipped_balances`](Self::skipped_balances)) - /// is informational metadata, not a change, so it does not affect this. + /// Changes-only: counts `slots` + `accounts` + `purged`. Any skipped update + /// (any of the `skipped_*` fields) is informational metadata, not a change, so + /// it does not affect this. pub fn is_empty(&self) -> bool { self.slots.is_empty() && self.accounts.is_empty() && self.purged.is_empty() } /// Total number of changed entries (slots + accounts + purges). /// - /// Changes-only: skipped relative updates are not counted (a skip is not a - /// change). See [`skipped_len`](Self::skipped_len) for the skip count. + /// Changes-only: skipped updates (any `skipped_*` field) are not counted (a + /// skip is not a change). See [`skipped_len`](Self::skipped_len) for the skip + /// count. pub fn len(&self) -> usize { self.slots.len() + self.accounts.len() + self.purged.len() } - /// Whether any relative update was skipped (slot **or** balance). + /// Whether any cold-aware update was skipped (relative slot, balance, masked + /// slot, **or** cold account patch). /// - /// `true` iff [`skipped`](Self::skipped) or - /// [`skipped_balances`](Self::skipped_balances) is non-empty. A cold-skipped + /// `true` iff any of [`skipped`](Self::skipped), + /// [`skipped_balances`](Self::skipped_balances), + /// [`skipped_masks`](Self::skipped_masks), or + /// [`skipped_accounts`](Self::skipped_accounts) is non-empty. A cold-skipped /// update produces no change, so it is invisible to - /// [`is_empty`](Self::is_empty) — callers applying relative updates should - /// check this to avoid silently dropping a balance update. + /// [`is_empty`](Self::is_empty) — callers applying cold-aware updates should + /// check this to avoid silently dropping an update. pub fn has_skipped(&self) -> bool { !self.skipped.is_empty() || !self.skipped_balances.is_empty() @@ -618,8 +626,9 @@ pub struct SkippedDelta { } /// A relative balance update ([`StateUpdate::BalanceDelta`]) that could not be -/// applied because the account is absent from **both** cache layers (its native -/// balance is unknown). +/// applied because the account's info is unknown to the EVM — absent from +/// **both** cache layers, or present in the overlay as revm `NotExisting` (so its +/// native balance is unknown). /// /// A delta against a cold account is skipped rather than applied (applying it /// against an assumed-zero balance would corrupt an unknown value, and @@ -662,7 +671,8 @@ pub struct SkippedMask { } /// An account patch ([`StateUpdate::Account`]) that could not be applied because -/// the account is absent from **both** cache layers. +/// the account's info is unknown to the EVM — absent from **both** cache layers, +/// or present in the overlay as revm `NotExisting`. /// /// A partial patch against a cold account is skipped rather than applied against /// [`AccountInfo::default`](revm::state::AccountInfo::default), because default diff --git a/tests/builder_chain_id.rs b/tests/builder_chain_id.rs new file mode 100644 index 0000000..cea0780 --- /dev/null +++ b/tests/builder_chain_id.rs @@ -0,0 +1,99 @@ +//! Offline tests for chain-ID resolution on [`EvmCacheBuilder`] / [`EvmCache`]. +//! +//! Resolution order (highest priority first): +//! 1. an explicit [`EvmCacheBuilder::chain_id`] value; +//! 2. a disk [`CacheConfig`]'s `chain_id` (also the on-disk namespace); +//! 3. the value inferred from the provider via `eth_chainId`; +//! 4. `1` (Ethereum mainnet) as a last-resort fallback when inference fails. +//! +//! All tests run fully offline over a mocked provider whose `eth_chainId` query +//! errors (empty `Asserter`), so the inference branch deterministically takes the +//! mainnet fallback — letting us assert the explicit / config / fallback rungs +//! without a network. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy_provider::RootProvider; +use alloy_provider::network::AnyNetwork; +use alloy_rpc_client::RpcClient; +use alloy_transport::mock::Asserter; +use anyhow::Result; +use evm_fork_cache::cache::{CacheConfig, EvmCache, EvmCacheBuilder}; + +fn mock_provider() -> Arc> { + Arc::new(RootProvider::::new(RpcClient::mocked( + Asserter::new(), + ))) +} + +fn unique_cache_dir(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("evm_fork_cache_chainid_{tag}_{nanos}")) +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_builder_chain_id_wins() -> Result<()> { + // 8453 = Base. The explicit builder value is authoritative. + let cache = EvmCacheBuilder::new(mock_provider()) + .chain_id(8453) + .build() + .await; + assert_eq!(cache.chain_id(), 8453); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn unset_chain_id_falls_back_to_mainnet_when_inference_fails() -> Result<()> { + // No explicit value, no cache_config, and the mock provider's eth_chainId + // errors — so resolution lands on the mainnet (1) fallback, not Arbitrum. + let cache = EvmCacheBuilder::new(mock_provider()).build().await; + assert_eq!(cache.chain_id(), 1); + + // The bare `new` constructor takes the same inference path. + let via_new = EvmCache::new(mock_provider()).await; + assert_eq!(via_new.chain_id(), 1); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cache_config_chain_id_is_used_when_no_explicit_value() -> Result<()> { + // 10 = OP Mainnet. With a disk cache and no explicit builder value, the + // CacheConfig chain id is authoritative. + let dir = unique_cache_dir("config"); + let cfg = CacheConfig::new(&dir, 10, Default::default(), Default::default()); + let cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg) + .build() + .await; + assert_eq!(cache.chain_id(), 10); + let _ = std::fs::remove_dir_all(&dir); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_builder_chain_id_overrides_cache_config() -> Result<()> { + let dir = unique_cache_dir("override"); + let cfg = CacheConfig::new(&dir, 10, Default::default(), Default::default()); + let cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg) + .chain_id(8453) + .build() + .await; + // Explicit builder value wins for the CHAINID opcode even when a config is set. + assert_eq!(cache.chain_id(), 8453); + let _ = std::fs::remove_dir_all(&dir); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_chain_id_updates_after_construction() -> Result<()> { + let mut cache = EvmCacheBuilder::new(mock_provider()).build().await; + assert_eq!(cache.chain_id(), 1); + cache.set_chain_id(137); // Polygon + assert_eq!(cache.chain_id(), 137); + Ok(()) +} diff --git a/tests/cache_state.rs b/tests/cache_state.rs index cd663a4..b01c6fa 100644 --- a/tests/cache_state.rs +++ b/tests/cache_state.rs @@ -8,6 +8,7 @@ mod common; +use alloy_eips::eip2930::{AccessList, AccessListItem}; use alloy_primitives::{Address, B256, Bytes, I256, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use anyhow::{Context, Result}; @@ -119,6 +120,74 @@ async fn call_raw_with_carries_native_value() -> Result<()> { Ok(()) } +/// `TxConfig.access_list` is wired into EIP-2929/EIP-2930 gas accounting: declaring +/// the touched account + slot warms them for the call but adds the EIP-2930 +/// intrinsic cost (2400/address + 1900/key). For a `balanceOf` that reads the slot +/// once, the intrinsic outweighs the ~2000-gas warm SLOAD saving, so the declared +/// run costs *more* — what matters is that the field measurably changes gas, i.e. +/// it is not silently ignored. +#[tokio::test(flavor = "multi_thread")] +async fn tx_config_access_list_changes_eip2929_gas_accounting() -> Result<()> { + let mut cache = setup_cache().await?; + let token = Address::repeat_byte(0x11); + let owner = Address::repeat_byte(0x22); + install_default_account(&mut cache, Address::ZERO); + install_mock_erc20(&mut cache, token); + + // Layer-1 CacheDB balance seed so `balanceOf` reads a real (non-zero) value. + let slot = + U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0); + cache + .db_mut() + .insert_account_storage(token, slot, U256::from(123u64))?; + + let calldata = Bytes::from(MockERC20::balanceOfCall { account: owner }.abi_encode()); + + let gas_of = |result: ExecutionResult| match result { + ExecutionResult::Success { gas_used, .. } => gas_used, + other => panic!("balanceOf did not succeed: {other:?}"), + }; + + // No access list: the `balanceOf` SLOAD pays the cold (2100-gas) cost. + let gas_plain = gas_of(cache.call_raw_with( + Address::ZERO, + token, + calldata.clone(), + false, + &TxConfig::default(), + )?); + + // Declare (token, slot): the SLOAD is warmed, but the access list carries its + // own EIP-2930 intrinsic cost. + let tx = TxConfig { + access_list: Some(AccessList(vec![AccessListItem { + address: token, + storage_keys: vec![B256::from(slot)], + }])), + ..Default::default() + }; + let gas_with_list = gas_of(cache.call_raw_with(Address::ZERO, token, calldata, false, &tx)?); + + assert_ne!( + gas_plain, gas_with_list, + "TxConfig.access_list must affect gas accounting, not be ignored" + ); + // Net for a single warmed-once slot: +2400 (addr) + 1900 (key) − 2000 (warm + // SLOAD) = +2300. The access list raises gas for this single-access call. + assert!( + gas_with_list > gas_plain, + "single-access declaration costs more intrinsic than it saves: \ + with_list {gas_with_list} vs plain {gas_plain}" + ); + assert_eq!( + gas_with_list - gas_plain, + 2_300, + "expected the exact EIP-2930 intrinsic minus single warm-SLOAD saving" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn simulation_reports_balance_deltas() -> Result<()> { let mut cache = setup_cache().await?; diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index 5cadd13..bea625e 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -532,3 +532,79 @@ async fn reconcile_errors_without_fetcher() -> Result<()> { ); Ok(()) } + +/// Pillar 3 (reactive sync): ingesting a block's `Transfer` logs keeps the hot +/// balance slots correct with **zero** RPC fetches — the decode→write pipeline +/// never touches the fetcher, unlike a poller that re-reads every changed slot +/// each block. Sampled `reconcile` (which *does* fetch) is the honesty backstop. +#[tokio::test(flavor = "multi_thread")] +async fn ingest_keeps_state_fresh_with_zero_fetches() -> Result<()> { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use alloy_eips::BlockId; + use common::{balance_of, install_default_account}; + use evm_fork_cache::cache::StorageBatchFetchFn; + + let token = Address::repeat_byte(0x33); + let owners: Vec

= (0..8u8).map(|i| Address::repeat_byte(0x40 + i)).collect(); + + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + install_default_account(&mut cache, Address::ZERO); // fee beneficiary + for o in &owners { + // Install each owner as an account (so it can be the balanceOf caller + // without a lazy RPC load) and seed its balance (layer 1, readable) so the + // deltas apply hot. + install_default_account(&mut cache, *o); + cache + .db_mut() + .insert_account_storage(token, mapping_slot(*o, 3), U256::from(1_000))?; + } + + // A counting fetcher: `ingest_logs` must never invoke it. + let fetches = Arc::new(AtomicUsize::new(0)); + let counter = fetches.clone(); + let f: StorageBatchFetchFn = + Arc::new(move |reqs: Vec<(Address, U256)>, _b: Option| { + counter.fetch_add(reqs.len(), Ordering::Relaxed); + reqs.into_iter() + .map(|(a, s)| (a, s, Ok(U256::from(1_000)))) + .collect() + }); + cache.set_storage_batch_fetcher(f); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from(3)))); + let mut pipeline = EventPipeline::new(registry); + + // A block of transfers around a ring (each owner sends 10 and receives 10 → + // balances net back to 1000), touching every owner's balance slot. + let logs: Vec = (0..owners.len()) + .map(|i| { + transfer_log( + token, + owners[i], + owners[(i + 1) % owners.len()], + U256::from(10), + ) + }) + .collect(); + + let digest = pipeline.ingest_logs(&mut cache, 100, &logs); + + assert_eq!( + fetches.load(Ordering::Relaxed), + 0, + "ingest decodes logs into writes and performs ZERO RPC fetches" + ); + assert_eq!(digest.decoded_logs, owners.len()); + assert!( + !digest.applied.has_skipped(), + "all hot balance deltas applied" + ); + // Correctness: state kept fresh from the logs alone equals the true post-state. + for o in &owners { + assert_eq!(balance_of(&mut cache, token, *o)?, U256::from(1_000)); + } + Ok(()) +} diff --git a/tests/fetch_minimization.rs b/tests/fetch_minimization.rs new file mode 100644 index 0000000..b622bcb --- /dev/null +++ b/tests/fetch_minimization.rs @@ -0,0 +1,128 @@ +//! Pins the Pillar 2 headline (data-fetch minimization) as a deterministic, +//! CI-guaranteed integer invariant: the crate fetches a shared hot working set +//! ONCE, and fanning N candidate simulations out over the frozen snapshot adds +//! ZERO further fetches — so a fork-per-candidate loop fetches `N x` as much. + +mod common; + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use anyhow::Result; +use evm_fork_cache::cache::{EvmCache, EvmOverlay, StorageBatchFetchFn}; +use revm::context::result::ExecutionResult; +use revm::state::AccountInfo; + +use common::{MOCK_ERC20_BALANCE_SLOT, MockERC20, mock_erc20_runtime, setup_cache}; + +const WORKING_SET: usize = 8; +const N_CANDIDATES: usize = 500; +const SEEDED_BALANCE: u64 = 1_000; + +fn owner(i: usize) -> Address { + let mut bytes = [0u8; 20]; + bytes[12..20].copy_from_slice(&(i as u64 + 1).to_be_bytes()); + Address::from(bytes) +} + +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0) +} + +fn counting_fetcher(counter: Arc) -> StorageBatchFetchFn { + Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + counter.fetch_add(requests.len(), Ordering::Relaxed); + requests + .into_iter() + .map(|(addr, slot)| (addr, slot, Ok(U256::from(SEEDED_BALANCE)))) + .collect() + }, + ) +} + +async fn cache_with_counter(token: Address) -> Result<(EvmCache, Arc)> { + let mut cache = setup_cache().await?; + let runtime = mock_erc20_runtime(); + let code_hash = runtime.hash_slow(); + cache.db_mut().insert_account_info( + token, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(runtime), + code_hash, + account_id: None, + }, + ); + let counter = Arc::new(AtomicUsize::new(0)); + cache.set_storage_batch_fetcher(counting_fetcher(counter.clone())); + Ok((cache, counter)) +} + +fn balance_of(overlay: &mut EvmOverlay, caller: Address, token: Address, account: Address) -> U256 { + let calldata = Bytes::from(MockERC20::balanceOfCall { account }.abi_encode()); + match overlay.call_raw(caller, token, calldata).unwrap() { + ExecutionResult::Success { output, .. } => { + MockERC20::balanceOfCall::abi_decode_returns(&output.into_data()).unwrap() + } + other => panic!("balanceOf failed: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn fan_out_reuses_one_warmup_fetch_across_all_candidates() -> Result<()> { + let token = Address::repeat_byte(0x42); + let working_set: Vec<(Address, U256)> = (0..WORKING_SET) + .map(|i| (token, balance_slot(owner(i)))) + .collect(); + + let (mut cache, counter) = cache_with_counter(token).await?; + + // Warm the working set once. + cache.verify_slots(&working_set)?; + assert_eq!( + counter.load(Ordering::Relaxed), + WORKING_SET, + "warm-up fetches each working-set slot exactly once" + ); + + let snapshot = cache.create_snapshot(); + counter.store(0, Ordering::Relaxed); + + // Fan N candidates out; each reads the whole shared hot set from the snapshot. + for c in 0..N_CANDIDATES { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for i in 0..WORKING_SET { + // Correctness: the warmed value is actually served (reuse works), and + // the read does not fetch. + assert_eq!( + balance_of(&mut overlay, owner(c % WORKING_SET), token, owner(i)), + U256::from(SEEDED_BALANCE) + ); + } + } + + assert_eq!( + counter.load(Ordering::Relaxed), + 0, + "the {N_CANDIDATES}-candidate fan-out adds zero fetches (overlays read the snapshot)" + ); + + // The fork-per-candidate baseline: one real cold cache fetches the whole + // working set, so N of them fetch N x as much. + let (mut cold, cold_counter) = cache_with_counter(token).await?; + cold.verify_slots(&working_set)?; + let per_candidate = cold_counter.load(Ordering::Relaxed); + assert_eq!(per_candidate, WORKING_SET); + assert_eq!( + per_candidate * N_CANDIDATES, + WORKING_SET * N_CANDIDATES, + "vanilla fork-per-candidate re-fetches the working set every candidate" + ); + + Ok(()) +} diff --git a/tests/multicall.rs b/tests/multicall.rs index 8ac1895..60e3dbc 100644 --- a/tests/multicall.rs +++ b/tests/multicall.rs @@ -1,23 +1,61 @@ //! Offline integration tests for the Multicall3 helpers. //! -//! The live `aggregate3` execution path requires the Multicall3 contract to be -//! deployed in the fork, which the RPC-gated `multicall_batch` example exercises. -//! These tests pin the network-free behavior: empty-batch short-circuits, the -//! result-decoding helpers, and the documented batch constants. +//! These pin the network-free behavior end to end: the empty-batch short-circuit, +//! the result-decoding helpers, the documented batch constants, and — by etching +//! the real Multicall3 runtime at its canonical address — the live `aggregate3` +//! build/execute/decode path, including input-order results and `allowFailure` +//! partial-result semantics. mod common; -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, Bytes, U256, hex, keccak256}; use alloy_sol_types::{SolCall, SolValue, sol}; use anyhow::Result; +use revm::state::{AccountInfo, Bytecode}; -use common::setup_cache; +use common::{MOCK_ERC20_BALANCE_SLOT, install_default_account, install_mock_erc20, setup_cache}; +use evm_fork_cache::cache::EvmCache; use evm_fork_cache::multicall::{ - IMulticall3, MAX_BATCH_SIZE, MulticallBatch, decode_result, execute_batched, try_decode_result, + IMulticall3, MAX_BATCH_SIZE, MULTICALL3_ADDRESS, MulticallBatch, decode_result, + execute_batched, try_decode_result, }; +/// Real Multicall3 deployed (runtime) bytecode, fetched from mainnet. Multicall3 +/// is deployed at the same address ([`MULTICALL3_ADDRESS`]) on virtually all EVM +/// chains, so etching it locally reproduces the real `aggregate3` behavior. +const MULTICALL3_RUNTIME_HEX: &str = include_str!("../fixtures/multicall3_runtime.hex"); + sol! { function getValue() external returns (uint256); + function balanceOf(address account) external returns (uint256); +} + +/// `keccak256(abi.encode(owner, balanceSlot))` — the MockERC20 balance mapping slot. +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0) +} + +/// Etch deployed runtime bytecode at `addr` and mark its storage local, so an +/// unseeded slot reads as zero rather than hitting the mocked RPC backend. +fn install_runtime(cache: &mut EvmCache, addr: Address, runtime_hex: &str) { + let code = Bytecode::new_raw(Bytes::from( + hex::decode(runtime_hex.trim()).expect("valid runtime hex"), + )); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + addr, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(code), + code_hash, + account_id: None, + }, + ); + cache + .db_mut() + .replace_account_storage(addr, Default::default()) + .expect("clear etched storage"); } /// An empty batch returns empty results without invoking the EVM, on all three @@ -95,3 +133,88 @@ fn decode_result_rejects_garbage_payload() { fn max_batch_size_constant() { assert_eq!(MAX_BATCH_SIZE, 200); } + +/// Etching the real Multicall3 runtime lets the live `aggregate3` path run fully +/// offline: results come back one-per-call in input order and decode to the +/// seeded values. +#[tokio::test(flavor = "multi_thread")] +async fn aggregate3_executes_offline_in_input_order() -> Result<()> { + let mut cache = setup_cache().await?; + install_runtime(&mut cache, MULTICALL3_ADDRESS, MULTICALL3_RUNTIME_HEX); + + let token = Address::repeat_byte(0x22); + let owner = Address::repeat_byte(0x33); + let other = Address::repeat_byte(0x44); + install_default_account(&mut cache, Address::ZERO); + install_mock_erc20(&mut cache, token); + // Seed the layer-1 CacheDB (the layer the EVM reads from) so the StorageCleared + // mock reports it; a backend-only seed would read as zero via the + // account-state-aware read path. + cache + .db_mut() + .insert_account_storage(token, balance_slot(owner), U256::from(5_000u64))?; + + let mut batch = MulticallBatch::new(); + batch.add_call(token, balanceOfCall { account: owner }, false); + batch.add_call(token, balanceOfCall { account: other }, false); + + let results = batch.execute(&mut cache)?; + assert_eq!(results.len(), 2, "one result per input call"); + assert!(results[0].success && results[1].success); + // Input order is preserved: owner first (5000), other second (0). + assert_eq!( + decode_result::(&results[0])?, + U256::from(5_000u64) + ); + assert_eq!(decode_result::(&results[1])?, U256::ZERO); + Ok(()) +} + +/// `allowFailure = true` lets a reverting call surface as `success = false` +/// alongside successful calls; `allowFailure = false` makes the whole batch revert +/// (an `Err`). `execute_tracked` captures the touched accounts. +#[tokio::test(flavor = "multi_thread")] +async fn aggregate3_allow_failure_partial_results_and_strict_revert() -> Result<()> { + let mut cache = setup_cache().await?; + install_runtime(&mut cache, MULTICALL3_ADDRESS, MULTICALL3_RUNTIME_HEX); + + let token = Address::repeat_byte(0x22); + let owner = Address::repeat_byte(0x33); + install_default_account(&mut cache, Address::ZERO); + install_mock_erc20(&mut cache, token); + cache + .db_mut() + .insert_account_storage(token, balance_slot(owner), U256::from(7u64))?; + + // An unknown selector reverts in the MockERC20 (no matching function). + let reverting = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); + + let mut batch = MulticallBatch::new(); + batch.add_call(token, balanceOfCall { account: owner }, false); + batch.add(token, reverting.clone(), true); // allowed to fail + let (results, access) = batch.execute_tracked(&mut cache)?; + + assert_eq!(results.len(), 2); + assert!(results[0].success); + assert_eq!( + decode_result::(&results[0])?, + U256::from(7u64) + ); + assert!( + !results[1].success, + "the reverting call surfaces as success = false, not an Err" + ); + assert!( + access.accounts.contains(&token), + "execute_tracked must capture the token account the inner call touched" + ); + + // The same revert with allow_failure = false fails the entire aggregate3. + let mut strict = MulticallBatch::new(); + strict.add(token, reverting, false); + assert!( + strict.execute(&mut cache).is_err(), + "a non-allowed revert reverts the whole batch" + ); + Ok(()) +}