diff --git a/CHANGELOG.md b/CHANGELOG.md index 0968cf3..6808c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,9 +142,80 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - **`protocols` feature** (default-on) gating the Uniswap V2/V3 storage layouts, V3 tick snapshots, and `inject_v3_*` / `inject_v2_pool_metadata` helpers, so the generic engine builds with `--no-default-features`. +- **Copy-on-write snapshots** (Phase 5, Pillar A) — `create_snapshot` is now a + two-tier copy-on-write view instead of an O(total state) deep clone. The cold + `BlockchainDb` index (layer 2) is flattened once into an internal, immutable, + `Arc`-shared base (`Arc` per account storage map, structural sharing — no new + dependency, Decision D1), memoized across snapshots and rebuilt copy-on-write + only for the addresses that changed; each `create_snapshot` then folds just the + hot CacheDB delta (layer 1) over a cheap `Arc::clone` of that base. Reads stay + O(1) and lock-free and are bit-for-bit identical to the deep clone (pinned by + the `tests/cow_snapshot.rs` differential-equivalence gate). The retained + `EvmCache::create_snapshot_deep_clone()` (`#[doc(hidden)] pub`, Decision D3) is + the equivalence reference and the A/B benchmark baseline. `EvmSnapshot` stays + `Send + Sync` and `EvmOverlay` stays `Send`. +- **`EvmOverlay::reset()`** (Phase 5, Pillar A.2) — recycle one overlay across + many simulations against the same snapshot without reallocating: it clears the + per-simulation dirty layer (keeping the snapshot `Arc`, `ext_db`, and the + reusable shared-memory buffer), reading the pristine snapshot again and behaving + exactly like a freshly-built overlay. The 64 KiB shared-memory buffer is also + recycled across the build→transact→revert call methods (stored as a plain + `Vec`, so the overlay stays `Send`). +- **Configurable EVM shared-memory pre-allocation** — `SharedMemoryCapacity` + (`Fixed(usize)` / `Auto`, default `Fixed(64 * 1024)` / 65,536 bytes) set via + `EvmCacheBuilder::shared_memory_capacity`. `Fixed` pins the per-context working- + memory buffer (general users running wide fan-outs of small simulations can lower + it to cut per-overlay memory; the previous behavior is the default); `Auto` sizes + it from the chain state loaded at build time (e.g. a bincode state file), clamped + to a 64 KiB floor / 4 MiB ceiling. The resolved size is readable via + `EvmCache::shared_memory_capacity()` and is propagated to every snapshot so + snapshot-backed overlays pre-allocate the same amount. `with_cache_capacity` is + the lower-level constructor behind the builder setter. +- **Explicit cold-account materialization** — `StateUpdate::AccountUpsert` and + `StateUpdate::account_upsert(...)` intentionally materialize an account absent + from both layers. Normal `StateUpdate::Account` patches are now cold-aware and + surface skipped cold patches through `StateDiff.skipped_accounts: + Vec`. +- **Invalidating layer-2 mutation wrapper** — `EvmCache::with_blockchain_db_mut` + runs a synchronous direct `BlockchainDb` mutation and invalidates the Phase 5 + memoized COW base automatically after the closure returns. +- **Exact access-list RLP data-gas helper** — + `access_list::access_list_rlp_data_gas(&AccessList)` returns the EIP-2930 RLP + calldata gas for an access list and backs the L2 profitability calculation. +- **Versioned on-disk cache envelope** — binary EVM state, bytecode, + `ImmutableDataCache`, and V3 tick snapshot cache files now start with + crate-specific magic bytes plus a `u32` version before the bincode payload. ### Changed +- **`EvmCache::create_snapshot` is now `&mut self`** (Phase 5, Decision D5) — + taking a snapshot memoizes/refreshes the cold copy-on-write base, which requires + a mutable borrow. All callers (the freshness controller, tests, examples, + benches) are updated; the return type (`Arc`) is unchanged. + Permitted under the pre-1.0 break policy. +- **`EvmCache::inject_storage_batch` is now `&mut self`** (Phase 5) — the + layer-2 bulk write now marks the touched addresses dirty for the memoized + copy-on-write base. The write itself is still a direct backend (layer-2) write + with the same semantics; only the receiver mutability changed. +- **Raw layer-2 handles were renamed to unchecked accessors** (Phase 5) — + `EvmCache::blockchain_db()` is now `unchecked_blockchain_db()` and + `EvmCache::backend()` is now `unchecked_backend()`. The rename makes the + bypass explicit; use `with_blockchain_db_mut` for synchronous direct writes that + should automatically invalidate the snapshot base. +- **Persistence APIs now return `Result<()>`** — `cache::save_binary_state`, + `PrefetchRegistry::save`, and `EvmCache::flush` report serialization, + directory-creation, and write failures to explicit callers. `Drop` remains + best-effort and logs `flush()` errors. +- **Block re-pins clear stale context** — `set_block` sets `block_number` only + for concrete numeric pins, clears it for tag/hash/`None` pins, and clears stale + `basefee` on block changes and on non-concrete pin calls that can drift under + the same tag. `repin_to_block` follows the same no-stale-basefee rule; callers + refresh `NUMBER`/`BASEFEE` via `set_block_context` after fetching the new + header. +- **Legacy raw-bincode cache files are treated as misses** — the versioned cache + envelope intentionally rejects unversioned `evm_state.bin`, `bytecodes.bin`, + `immutable_data.bin`, and `v3_tick_snapshots.bin` payloads rather than trying + to deserialize ambiguous layouts. - Simulation entry points that distinguish failure modes return `SimulationResult` (`Result`), separating decoded reverts, EVM halts, and host errors. `SimulationErrorKind` remains as a deprecated alias. @@ -160,6 +231,19 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). ### Fixed +- **Cold absolute account patches no longer mask on-chain accounts.** + `StateUpdate::Account` on an account absent from both layers now skips instead + of writing `AccountInfo::default()` fields through the shared backend. The + skipped patch is visible in `StateDiff.skipped_accounts`; intentional cold + creation uses `StateUpdate::AccountUpsert`. +- **Access-list profitability no longer conflates provider failures with + unprofitable lists.** `SmartAccessList::into_access_list_if_profitable` and + `access_list_if_profitable` now propagate provider/pricing failures as `Err` + and reserve `Ok(None)` for empty, zero-priced, or genuinely unprofitable lists. +- **`simulate_call_with_balance_deltas` now reports a real access list.** It + extracts the EIP-2930 touched account/slot list from the EVM journal before + commit/revert, including the pre/post `balanceOf` reads and the simulated call, + instead of returning `AccessList::default()`. - **`cached_storage_value` silent-corruption bug** (Phase 3 §16.0, audit HIGH + MED). For a storage slot absent from an overlay account whose revm `account_state` is `StorageCleared` or `NotExisting`, the accessor now returns @@ -175,7 +259,9 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). **skips both layer writes** (returning an empty diff) when no field actually changes, instead of unconditionally inserting `AccountInfo::default()` into the shared backend for an all-`None` (or value-unchanged) patch on an absent address. - A real field change still materializes the backend account (unchanged intent). + Phase 5 later tightened this further: real field changes on cold accounts now + skip through `StateDiff.skipped_accounts` unless the caller uses + `StateUpdate::AccountUpsert`. - **`account_state`-awareness extended to the snapshot + account-info paths** (Phase 3 fix-review, HIGH + MED). A follow-up adversarial review found the §16.0 `cached_storage_value` fix had not been propagated to two sibling read paths: diff --git a/Cargo.lock b/Cargo.lock index db4da26..dc8eaf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1852,6 +1852,7 @@ dependencies = [ "alloy-node-bindings", "alloy-primitives", "alloy-provider", + "alloy-rlp", "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-sol-types", diff --git a/Cargo.toml b/Cargo.toml index 4a928d4..5f7cf9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ alloy-eips = "1.0.38" alloy-network = "1.0.38" alloy-primitives = { version = "1.4", features = ["map"] } alloy-provider = "1.0.38" +alloy-rlp = "0.3" alloy-rpc-client = "1.0.38" alloy-rpc-types-eth = "1.0.38" alloy-sol-types = "1.4" diff --git a/README.md b/README.md index f106255..2d97a5c 100644 --- a/README.md +++ b/README.md @@ -217,10 +217,10 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address); ## Benchmarks -Criterion benchmarks live in [`benches/`](benches). The offline benches are the -baseline against which the planned copy-on-write snapshot rewrite (roadmap -Pillar A) will be measured, so they exercise the real hot paths at a range of -cache sizes: +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: | Bench | Measures | | --- | --- | diff --git a/benches/simulation.rs b/benches/simulation.rs index 10d0d2d..aca01e1 100644 --- a/benches/simulation.rs +++ b/benches/simulation.rs @@ -3,14 +3,25 @@ //! bundle simulation, and batched storage injection. //! //! These run fully offline (mocked provider) so they're reproducible. They -//! establish the baseline for the Pillar A (copy-on-write snapshot) rewrite: -//! `create_snapshot` is currently an O(total state) deep clone, so its cost -//! scales with the populated cache size (the `create_snapshot` group sweeps -//! 100 → 10,000 accounts to show that slope). Once Pillar A lands, the same -//! sweep should flatten toward O(changed state) — re-run this group before and -//! after to quantify the win. The `overlay_fanout` group measures the other -//! half of the value proposition: how cheaply one frozen snapshot fans out into -//! many isolated simulations. +//! quantify the Pillar A (copy-on-write snapshot) win. +//! +//! Expected shapes: +//! - **`create_snapshot` group (A/B).** The cold index is seeded into **layer 2** +//! via `inject_storage_batch` (`populated_cache_layer2`), the way a fork cache +//! actually holds it. For each size it benches both the COW `create_snapshot` +//! and the retained `create_snapshot_deep_clone`. The deep clone is an O(total +//! state) copy, so its cost slopes up with the index size; the COW path shares +//! the memoized base and avoids cloning total storage slots. It still scans +//! accounts and new layer-1 entries, so it should be much flatter than the deep +//! clone, especially as slots/account grows, but not strictly flat by account +//! count. +//! - **`resnapshot_hot_loop`.** Warms the base with one snapshot, applies a small +//! `apply_updates` layer-1 mutation, then measures `create_snapshot`. This is +//! the memoization win: the COW path avoids cloning cold storage slots but +//! remains sensitive to account scans and new layer-1 entries. +//! - **`overlay_fanout`.** Measures fanning one frozen snapshot out into many +//! isolated simulations, comparing a fresh `EvmOverlay::new` per sim against a +//! single `reset()`-recycled overlay (Pillar A.2). use std::hint::black_box; use std::sync::Arc; @@ -49,34 +60,35 @@ fn offline_cache(rt: &Runtime) -> EvmCache { rt.block_on(EvmCache::new(Arc::new(provider), None)) } -/// A cache populated with `accounts` accounts, each holding `slots_per` slots. -fn populated_cache(rt: &Runtime, accounts: usize, slots_per: usize) -> EvmCache { +/// A cache whose cold index lives in **layer 2** — seeded via +/// `inject_storage_batch`, the path a fork cache actually uses to bulk-load its +/// cold state. This is what the COW `create_snapshot` memoizes into its base. +fn populated_cache_layer2(rt: &Runtime, accounts: usize, slots_per: usize) -> EvmCache { let mut cache = offline_cache(rt); + let mut batch: Vec<(Address, U256, U256)> = Vec::with_capacity(accounts * slots_per); for a in 0..accounts { let address = addr(a); - cache - .db_mut() - .insert_account_info(address, AccountInfo::default()); for s in 0..slots_per { - cache - .db_mut() - .insert_account_storage( - address, - U256::from(s as u64), - U256::from((a * 31 + s) as u64), - ) - .unwrap(); + batch.push(( + address, + U256::from(s as u64), + U256::from((a * 31 + s) as u64), + )); } } + cache.inject_storage_batch(&batch); cache } +/// A/B snapshot creation across cold-index sizes: the COW `create_snapshot` vs. +/// the retained `create_snapshot_deep_clone`, both over a layer-2-seeded index. +/// +/// The deep clone slopes up with total slots; the COW path, after a warm-up +/// snapshot has memoized the base, avoids cloning those slots but still pays the +/// O(accounts) growth scan. fn bench_create_snapshot(c: &mut Criterion) { let rt = Runtime::new().unwrap(); let mut group = c.benchmark_group("create_snapshot"); - // Sweep from a small pool up to a production-scale index (10k contracts) so - // the O(total state) slope of the current deep clone is visible. Pillar A - // (copy-on-write) should flatten this curve. for &(accounts, slots) in &[ (100usize, 8usize), (1_000, 8), @@ -84,12 +96,50 @@ fn bench_create_snapshot(c: &mut Criterion) { (5_000, 16), (10_000, 16), ] { - let cache = populated_cache(&rt, accounts, slots); + let mut cache = populated_cache_layer2(&rt, accounts, slots); + // Warm the memoized base once so the COW measurement reflects the + // steady-state (reuse) cost, not the first full build. + black_box(cache.create_snapshot()); + group.throughput(criterion::Throughput::Elements((accounts * slots) as u64)); + group.bench_with_input( + BenchmarkId::new("cow", format!("{accounts}acct_x{slots}slot")), + &accounts, + |b, _| b.iter(|| black_box(cache.create_snapshot())), + ); + group.bench_with_input( + BenchmarkId::new("deep_clone", format!("{accounts}acct_x{slots}slot")), + &accounts, + |b, _| b.iter(|| black_box(cache.create_snapshot_deep_clone())), + ); + } + group.finish(); +} + +/// The memoization win: a hot re-snapshot loop. Warm the base once, apply a +/// *small* layer-1 mutation, then measure `create_snapshot`. Cost should track +/// account scanning plus new layer-1 entries, staying much flatter than the deep +/// clone as cold storage grows. +fn bench_resnapshot_hot_loop(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut group = c.benchmark_group("resnapshot_hot_loop"); + for &(accounts, slots) in &[(1_000usize, 8usize), (5_000, 16), (10_000, 16)] { + let mut cache = populated_cache_layer2(&rt, accounts, slots); + // Warm the base. + black_box(cache.create_snapshot()); + // A handful of layer-1 writes (does not dirty the memoized base). + let target = addr(0); group.throughput(criterion::Throughput::Elements((accounts * slots) as u64)); group.bench_with_input( BenchmarkId::from_parameter(format!("{accounts}acct_x{slots}slot")), - &cache, - |b, cache| b.iter(|| black_box(cache.create_snapshot())), + &accounts, + |b, _| { + b.iter(|| { + cache + .db_mut() + .insert_account_info(target, AccountInfo::default()); + black_box(cache.create_snapshot()); + }) + }, ); } group.finish(); @@ -125,20 +175,31 @@ fn bench_overlay_fanout(c: &mut Criterion) { let mut group = c.benchmark_group("overlay_fanout"); for &k in &[1usize, 8, 32] { - group.bench_with_input( - BenchmarkId::from_parameter(format!("{k}way")), - &k, - |b, &k| { - b.iter(|| { - for _ in 0..k { - let mut overlay = EvmOverlay::new(snapshot.clone(), None); - let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); - debug_assert!(matches!(result, ExecutionResult::Success { .. })); - black_box(result); - } - }) - }, - ); + // Baseline: a fresh `EvmOverlay::new` (+ dirty maps + Arc clone + buffer) + // per simulation. + group.bench_with_input(BenchmarkId::new("new_per_sim", k), &k, |b, &k| { + b.iter(|| { + for _ in 0..k { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + black_box(result); + } + }) + }); + // Pillar A.2: one overlay built once, `reset()` between sims (reuses the + // dirty maps, the snapshot Arc, and the shared-memory buffer). + group.bench_with_input(BenchmarkId::new("reset_recycled", k), &k, |b, &k| { + b.iter(|| { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for _ in 0..k { + let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + black_box(result); + overlay.reset(); + } + }) + }); } group.finish(); } @@ -253,7 +314,7 @@ fn bench_sim_bundle(c: &mut Criterion) { /// Batched direct storage injection (the bypass-RPC write path) across sizes. fn bench_inject_storage_batch(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - let cache = offline_cache(&rt); + let mut cache = offline_cache(&rt); let mut group = c.benchmark_group("inject_storage_batch"); for &n in &[100usize, 1_000, 10_000] { @@ -271,6 +332,7 @@ fn bench_inject_storage_batch(c: &mut Criterion) { criterion_group!( benches, bench_create_snapshot, + bench_resnapshot_hot_loop, bench_overlay_fanout, bench_cache_call_raw, bench_sim_bundle, diff --git a/benches/state_update.rs b/benches/state_update.rs index 907954c..8bf74e3 100644 --- a/benches/state_update.rs +++ b/benches/state_update.rs @@ -181,7 +181,7 @@ fn bench_apply_per_variant(c: &mut Criterion) { group.bench_function("purge_all_storage", |b| { b.iter_batched( || { - let cache = pool_cache(&rt); + let mut cache = pool_cache(&rt); cache.inject_storage_batch(&[ (POOL, U256::from(0), U256::from(1)), (POOL, U256::from(1), U256::from(2)), @@ -203,7 +203,7 @@ fn bench_apply_per_variant(c: &mut Criterion) { group.bench_function("purge_account", |b| { b.iter_batched( || { - let cache = pool_cache(&rt); + let mut cache = pool_cache(&rt); cache.inject_storage_batch(&[ (POOL, U256::from(0), U256::from(1)), (POOL, U256::from(1), U256::from(2)), @@ -223,7 +223,7 @@ fn bench_apply_per_variant(c: &mut Criterion) { group.bench_function("purge_slots", |b| { b.iter_batched( || { - let cache = pool_cache(&rt); + let mut cache = pool_cache(&rt); cache.inject_storage_batch(&[ (POOL, U256::from(0), U256::from(1)), (POOL, U256::from(1), U256::from(2)), @@ -254,7 +254,7 @@ fn bench_apply_heterogeneous(c: &mut Criterion) { group.bench_function("slot_account_purge", |b| { b.iter_batched( || { - let cache = pool_cache(&rt); + let mut cache = pool_cache(&rt); cache.inject_storage_batch(&[(POOL, U256::from(9), U256::from(1))]); cache }, diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index ce24b7c..9a72081 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -1,128 +1,116 @@ # Known issues & limitations A living triage list of bugs, smells, and limitations surfaced during the -publication-readiness review. Items here are **flagged, not fixed** — the test -suite deliberately pins *current* behavior, so changing any of these is a -conscious, reviewable decision (and a `CHANGELOG.md` entry). +publication-readiness review. Items here are either **remaining limitations** or +**recently-fixed issues kept for auditability**; behavior-changing fixes should +carry red/green tests and a `CHANGELOG.md` entry. Confidence legend: **[V]** verified against the source during review; **[R]** reported by the review and worth confirming before acting. -## Correctness / behavior to review - -1. **[V] Silent persistence failures.** `cache::save_binary_state`, - `PrefetchRegistry::save`, and `ImmutableDataCache::save` log a warning on I/O - error but return `()`, so callers cannot detect a failed write (full disk, - permissions, partial flush). Consider returning `Result<()>` (a breaking - change worth taking pre-1.0). Tested today only insofar as the happy-path - round-trip succeeds. - -2. **[V] Access-list L2 profitability uses an approximate gas model.** In - `access_list.rs`, `into_access_list_if_profitable` / `access_list_if_profitable` - estimate L1 calldata cost with hand-rolled RLP-overhead constants - (`4 * 16` per address, `16` per key, `3 * 16` for the list header). This is an - intentional heuristic, not a precise EIP-2930 serialization cost — verify it - against real serialized sizes before relying on the profitability verdict for - anything other than a rough gate. The two functions also duplicate this logic - (a maintenance hazard: a fix to one must be mirrored). - -3. **[V] Profitability swallows provider errors.** The same functions catch all - provider errors and return `Ok(None)`, which is indistinguishable from - "computed: not profitable." A caller cannot tell a skipped check (RPC down) - from a real negative. Consider a result type that distinguishes the two. - -4. **[R] `set_block` with a tag leaves `block.number` stale.** Only - `BlockId::Number(n)` syncs the `NUMBER` opcode value; pinning to a tag (e.g. - `BlockId::latest()`) leaves the previously-set number in the block env. Either - resolve tags to a concrete number at pin time or document the constraint - loudly. - -5. **[R] Duplicate custom-error selectors shadow silently.** `RevertDecoder` +## Recently fixed by `codex/phase-5-known-issues-top5` + +1. **[FIXED] Cold absolute `Account` patches no longer materialize unknown + accounts.** `StateUpdate::Account` is cold-aware: a partial patch against an + address absent from both layers is skipped, does not write a default backend + account, and is surfaced through `StateDiff.skipped_accounts`. Intentional + cold materialization is now explicit via `StateUpdate::AccountUpsert` / + `StateUpdate::account_upsert(...)`. + +2. **[FIXED] Block-context drift after re-pinning.** `set_block` now sets + `block_number` only for concrete numeric pins and clears it for tag/hash/`None` + pins. Block changes, plus non-concrete pin calls that can drift under the same + tag, clear stale `basefee`; callers refresh `NUMBER`/`BASEFEE` together with + `set_block_context` after fetching the new header. + +3. **[FIXED] Synchronous layer-2 escape hatches have an invalidating wrapper.** + Raw handles are now visibly named `unchecked_blockchain_db()` / + `unchecked_backend()`, and `EvmCache::with_blockchain_db_mut(...)` runs a + synchronous `BlockchainDb` mutation and invalidates the COW snapshot base + automatically. + +4. **[FIXED] Explicit persistence failures are observable.** + `cache::save_binary_state`, `PrefetchRegistry::save`, and + `EvmCache::flush` now return `anyhow::Result<()>`. `Drop` remains best-effort + and logs flush errors. + +5. **[FIXED] Access-list profitability uses exact EIP-2930 RLP bytes.** + Arbitrum profitability now centralizes data-gas accounting in + `access_list_rlp_data_gas(...)` and provider/pricing failures propagate as + `Err`, leaving `Ok(None)` for empty/zero-priced/unprofitable lists. + +6. **[FIXED] `simulate_call_with_balance_deltas` now returns the touched access + list.** The pre/post `balanceOf` reads and simulated call share one EVM + journal, and the method now extracts the EIP-2930 access list before + commit/revert, matching the transfer-inspector simulation path. + +7. **[FIXED] On-disk cache files carry magic bytes and a version number.** + `binary_state`, `bytecode`, `ImmutableDataCache`, and V3 tick snapshots now + write a crate-specific magic header plus version `1` before the bincode + payload. Unknown magic/version values and legacy raw-bincode files are treated + as cache misses. + +8. **[FIXED] `call_raw_with_access_list` did not revert its checkpoint on a + transact error.** Both `EvmCache::call_raw_with_access_list` and + `EvmOverlay::call_raw_with_access_list_with` now match on the `transact_one` + result and `checkpoint_revert` on **every** path (success and host error), + matching `call_raw` / `simulate_with_transfer_tracking`. A host-level transact + error no longer leaves the overlay checkpoint un-reverted. + +## Remaining open issues ranked by unexpected-result risk + +1. **[R] Duplicate custom-error selectors shadow silently.** `RevertDecoder` registration replaces an existing entry for the same 4-byte selector with no warning, so an accidental double-registration silently wins. Consider a debug-level log or a `try_register` that reports collisions. -6. **[R] ERC20 `Transfer` decoding assumes the standard layout.** `inspector.rs` +2. **[R] ERC20 `Transfer` decoding assumes the standard layout.** `inspector.rs` reads `from`/`to` from indexed topics and `value` from the first 32 data - bytes. Non-standard or packed `Transfer` encodings parse incorrectly. Also, an - address that appears as both `from` and `to` in one transfer is both - subtracted and added (a semantically-invalid self-transfer is not rejected). + 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 now documented at the call site. -7. **[R] Panic codes above `u64::MAX` are dropped.** `decode_solidity_panic` +3. **[V] `SystemTime::now().unwrap()` panic risk in EVM construction.** + `build_evm` / `make_local_context` (and the overlay equivalents) call + `SystemTime::now().duration_since(UNIX_EPOCH).unwrap()` when no timestamp + override is set, which panics if the system clock is before the Unix epoch. + Setting an explicit timestamp avoids it; consider a saturating fallback. + +4. **[R] Panic codes above `u64::MAX` are dropped.** `decode_solidity_panic` converts out-of-range panic codes to `None`. Real compiler-emitted panic codes - are single-byte constants, so this is benign in practice; now documented at the + are single-byte constants, so this is benign in practice and documented at the call site. -8. **[V] `simulate_call_with_balance_deltas` returns an empty access list.** It - sets `CallSimulationResult.access_list = AccessList::default()`, unlike - `simulate_with_transfer_tracking` which populates it via `extract_access_list`. - Either the field is meaningless on this path or the population was missed — - the docs now state the field is empty here; reconcile before relying on it. - -9. **[FIXED] `call_raw_with_access_list` did not revert its checkpoint on a - transact error.** Both `EvmCache::call_raw_with_access_list` and - `EvmOverlay::call_raw_with_access_list_with` now match on the `transact_one` - result and `checkpoint_revert` on **every** path (success and host error), - matching `call_raw` / `simulate_with_transfer_tracking`. A host-level transact - error no longer leaves the overlay checkpoint un-reverted. - -10. **[V] `SystemTime::now().unwrap()` panic risk in EVM construction.** - `build_evm` / `make_local_context` (and the overlay equivalents) call - `SystemTime::now().duration_since(UNIX_EPOCH).unwrap()` when no timestamp - override is set, which panics if the system clock is before the Unix epoch. - Setting an explicit timestamp avoids it; consider a saturating fallback. - -18. **[V] Cold absolute `Account` patch masks the real on-chain account.** A - *partial* absolute [`StateUpdate::Account`] patch (e.g. balance-only) applied - to an address absent from **both** cache layers writes default values for the - un-patched fields (nonce `0`, empty code) through the shared BlockchainDb - backend as authoritative — pre-empting a later RPC fetch of the real account - (`apply_account_patch` materializes the backend account on any real change, by - design / spec §5.2). This is a live-fork footgun for callers reconstructing an - account from one event field. Mitigations: fetch+seed the account first, or use - the relative `StateUpdate::BalanceDelta` / `EvmCache::modify_account_balance` - (Phase 3 §16.5), which are cold-aware (a cold target is skipped and surfaced in - `StateDiff.skipped_balances`, never materialized). A no-op patch (no field - actually changes) does **not** materialize anything (Phase 3 §16.1 fix). The - rustdoc on `apply_update` / `StateUpdate::Account` / `AccountPatch` carries a - `# Warning` to this effect. - ## Code-quality nits -11. **[V] Dead branch in `i128_to_u256`** (`cache/storage_keys.rs`): both the +5. **[V] Dead branch in `i128_to_u256`** (`cache/storage_keys.rs`): both the `value >= 0` and `else` arms evaluate the identical `U256::from(value as u128)`. The two's-complement cast is correct for both signs, so the `if`/`else` can collapse to one line (keep the explanatory comment). -12. **[R] V3 tick-snapshot keys serialize as strings.** `V3PoolTickSnapshot` +6. **[R] V3 tick-snapshot keys serialize as strings.** `V3PoolTickSnapshot` stringifies `i16`/`i32` tick/word keys for bincode, then `parse()`s them back in `to_tick_bitmap`/`to_ticks`, silently dropping any key that fails to parse. A native integer-keyed encoding would be faster and would not fail silently. -13. **[V] On-disk caches have no version header.** `binary_state`, `bytecode`, - `metadata` (`ImmutableDataCache`), and `tick_snapshot` all persist raw bincode - with no magic bytes or version field, so a struct-layout change silently - invalidates every existing cache file (decoded as a miss). A version header - would enable detection/migration. - -14. **[R] Balancer pool id keyed by `Debug` formatting.** `ImmutableDataCache` +7. **[R] Balancer pool id keyed by `Debug` formatting.** `ImmutableDataCache` keys `balancer_pools` by `format!("{:?}", pool_id)`. `Debug` output is not a stable encoding contract; a hex encoding would be safer for a persisted key. ## API ergonomics -15. **[R] `snapshot()` vs `create_snapshot()`.** `snapshot()` returns a low-level +8. **[R] `snapshot()` vs `create_snapshot()`.** `snapshot()` returns a low-level `revm::database::Cache` for in-place `restore()`; `create_snapshot()` returns an `Arc` for cross-thread fan-out. The names don't convey the difference. Docs now cross-reference them (see the rustdoc), but a rename could be considered pre-1.0. -16. **[R] Process-global cache speed mode.** `set_cache_speed_mode` / +9. **[R] Process-global cache speed mode.** `set_cache_speed_mode` / `cache_speed_mode` are a process-wide `static`, so two caches in one process cannot tune concurrency independently. Phase 1 moved configuration toward per-instance (`EvmCacheBuilder::cache_config`); the global setter remains. -17. **[V] `SpeculativeSim` consumption contract.** Both `validate()` and +10. **[V] `SpeculativeSim` consumption contract.** Both `validate()` and `into_optimistic()` take `self` by value, so double-consumption is unreachable under normal ownership. Internally `validate()` uses `.expect("validation handle taken twice")` (defensive) while `into_optimistic()` no-ops if the @@ -131,14 +119,52 @@ Confidence legend: **[V]** verified against the source during review; ## Limitations by design / roadmap -- **No copy-on-write snapshots yet.** `create_snapshot()` deep-clones state - (`O(accounts + slots)`); the COW rewrite is roadmap Pillar A. The `simulation` - benchmarks exist to measure the baseline this will improve on. +- **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 + storage shared by `Arc`), memoized across snapshots and rebuilt copy-on-write + only for the addresses that changed; each snapshot folds just the hot CacheDB + delta (layer 1) over a cheap `Arc::clone`. **Residual cost model (honest):** a + snapshot is no longer free. When layer 2 is unchanged since the last snapshot + it still pays an **O(accounts) length-scan** of the layer-2 storage/account + maps (to catch uncontrolled lazy-fetch growth that bypasses the write funnel, + since `foundry-fork-db` cannot be hooked) plus an **O(layer-1) fold** of the hot + delta — so the cost tracks `accounts + changed state`, not total slots. A + full rebuild (first snapshot, or after `set_block`/re-pin) is still O(total + state). `create_snapshot` is now `&mut self` (it memoizes the base, Decision + D5). The retained `create_snapshot_deep_clone()` (the legacy full flatten) is + kept as the A/B benchmark baseline and the read-equivalence reference; the + `create_snapshot` group in `benches/simulation.rs` measures both. Decisions and + the cost model are in [`phase-5-spec.md`](phase-5-spec.md) / `ROADMAP.md`. +- **Layer-2 unchecked accessors remain an explicit contract boundary (Phase 5).** The + snapshot base's growth scan is count/absence-based, which is sufficient for the + supported writers: the crate's own mutators (`apply_update`, `inject_storage_batch`, + the `inject_*` helpers, purges, code overrides) explicitly mark the base dirty, + and the `foundry-fork-db` `SharedBackend` lazy fetch is append-only at a fixed + block (it only inserts on a cache miss, never overwrites in place — a load-bearing + invariant noted in `refresh_base`). Direct out-of-band writes through the + `unchecked_blockchain_db()` / `unchecked_backend()` handles still bypass the + normal write funnel by design. For synchronous `BlockchainDb` map writes, prefer + [`EvmCache::with_blockchain_db_mut`], which invalidates the base automatically + after the closure returns. If using the unchecked handle directly, call + [`EvmCache::invalidate_snapshot_base`] after the write lands and before the next + snapshot (or re-pin via `set_block`). For + `SharedBackend::insert_or_update_storage` / `insert_or_update_address`, the call + only enqueues work on the backend handler; `invalidate_snapshot_base()` does not + wait for that queued update. First synchronize or read back until the expected + value is visible in `BlockchainDb` / through the backend, then invalidate before + creating the snapshot. The rustdoc on both accessors and the hook carries this + warning, and `tests/cow_snapshot.rs` + (`invalidate_snapshot_base_rehonest_after_escape_hatch_write`, + `invalidate_snapshot_base_rehonest_after_existing_account_write`, + `with_blockchain_db_mut_rehonest_after_storage_overwrite`, + `with_blockchain_db_mut_rehonest_after_account_overwrite`) + pins it. - **`protocols` not yet extracted.** The DeFi surface is feature-gated but still - in-crate; `cargo test --no-default-features` is not yet supported because some - unit tests assume the default feature. Extraction into `evm-amm-state` is - planned (roadmap), blocked partly by `ImmutableDataCache` coupling generic - token-decimals with V2/V3/Balancer pool metadata. + in-crate. The generic core builds and tests with `--no-default-features`, but + extraction into `evm-amm-state` is still planned (roadmap), blocked partly by + `ImmutableDataCache` coupling generic token-decimals with V2/V3/Balancer pool + metadata. - **Event-driven sync (roadmap Pillar B) — reader/writer halves done; live WS transport is not.** The Phase 3 **writer half** (`StateUpdate` + `apply_update`/`apply_updates`) and the Phase 4 **reader half** (the `events` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8eaa262..36d2fef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -74,7 +74,7 @@ RPC node Event-driven sync ← WS logs · new block | **2** | Freshness core (Pillar C): `Validity` + `FreshnessRegistry`; observation tracker; policies; optimistic verify-and-rerun loop. | **Done** (`phase-2-freshness`) | | **3** | State-update primitives (Pillar B.1): `StateUpdate` + targeted writers; refold `inject_*`; surface state-diff output. | **Done** (`phase-3-state-updates`) | | **4** | Event pipeline + adapters (Pillar B.2): `EventDecoder` trait, ERC-20 + V3 adapters, ingest/reorg/reconcile pipeline. | **Done** (`phase-4-event-pipeline`) | -| **5** | COW snapshots (Pillar A): structural sharing; overlay buffer reuse. | Planned | +| **5** | COW snapshots (Pillar A): structural sharing; overlay buffer reuse. | **Done** (`phase-5-cow-snapshots`) | Cross-cutting (land opportunistically): call tracer Inspector, full offline (`default-features = false`, no provider) build split, CHANGELOG/CONTRIBUTING. @@ -406,6 +406,56 @@ is recorded in `KNOWN_ISSUES.md`. --- +## Phase 5 — copy-on-write snapshots (detailed, decisions locked) + +Builds **Pillar A**: replace the O(total state) deep-clone `create_snapshot` with +a two-tier copy-on-write view whose cost tracks *changed* state, not *total* +state. The cold `BlockchainDb` index (layer 2) is flattened once into an +internal, immutable, `Arc`-shared base — both the base as a whole and each +account's storage map are shared by `Arc`, so structural sharing needs no new +dependency (Decision D1) — memoized across snapshots and rebuilt copy-on-write +only for the addresses that changed; each snapshot then folds just the hot +CacheDB delta (layer 1). Reads stay O(1), lock-free, and bit-for-bit identical to +the deep clone. The full build contract is in +[`phase-5-spec.md`](phase-5-spec.md). + +### Locked decisions + +1. **`Arc`-shared maps, not a persistent HAMT** (D1). Reads stay O(1) with no + per-`SLOAD` regression and no external dependency. +2. **Base memoized as immutable; over-invalidation is acceptable, silent + staleness is not** (D2). The write-through funnel marks an address dirty + unconditionally; the differential-equivalence test is the hard backstop. +3. **Keep the deep clone** as `create_snapshot_deep_clone` (D3) — the A/B + benchmark baseline and the read-equivalence reference. +4. **Overlay reuse: buffer reuse *and* `reset()` recycle** (D4) — both in scope. +5. **`create_snapshot` becomes `&mut self`** (D5) — the memoization cost; the + freshness controller and all callers are updated. + +### Acceptance — met + +`cargo fmt --check`, `clippy --all-targets -- -D warnings` (default + +`--lib --no-default-features`), `cargo test` (both feature configs), +`RUSTDOCFLAGS=-D warnings cargo doc`, `cargo bench --no-run`; the +`tests/cow_snapshot.rs` differential-equivalence gate and the existing +snapshot/overlay/freshness tests pass unchanged. + +Landed on `phase-5-cow-snapshots`: the memoized two-tier base (`BaseState` + +the rewritten two-tier `EvmSnapshot` with `account_info`/`storage_value`/`code` +accessors, `src/cache/snapshot.rs`); `EvmCache::refresh_base`/`build_base_full`, +the COW `create_snapshot` (now `&mut self`), the retained +`create_snapshot_deep_clone`, and the `mark_base_dirty`/`invalidate_base` +invalidation wired into `write_slot_through`/`apply_slot_run`/ +`write_account_info_through`/`inject_storage_batch`/the `purge_*` paths and +`set_block` (`src/cache/mod.rs`); `EvmOverlay::reset` plus the reusable +shared-memory buffer recycled across the call methods (`src/cache/overlay.rs`); +the layer-2-seeded A/B + hot-loop + `reset()`-fanout benches +(`benches/simulation.rs`); and the differential-equivalence gate +(`tests/cow_snapshot.rs`). The residual O(accounts) length-scan / O(layer-1) +fold cost model is recorded in `KNOWN_ISSUES.md`. + +--- + ## Key abstractions for later phases (sketches) ```rust diff --git a/docs/phase-2-spec.md b/docs/phase-2-spec.md index c61f19c..baff6b2 100644 --- a/docs/phase-2-spec.md +++ b/docs/phase-2-spec.md @@ -54,7 +54,7 @@ evaluation sims only. `storage_batch_fetcher() -> Option<&StorageBatchFetchFn>`, `inject_storage_batch(&[(Address,U256,U256)])`, `purge_pool_storage`, `purge_pool_slots`, `call_raw_with`/`TxConfig`, `CallSimulationResult`, - `blockchain_db()`, `db_mut()`. + `unchecked_blockchain_db()`, `db_mut()`. - `cache::EvmOverlay` / `cache::EvmSnapshot` (`overlay.rs`/`snapshot.rs`): `EvmOverlay::new(Arc, Option)`, `call_raw`, `simulate_with_transfer_tracking`. `EvmOverlay` is `Send`. diff --git a/docs/phase-3-spec.md b/docs/phase-3-spec.md index 199eaf5..e51886c 100644 --- a/docs/phase-3-spec.md +++ b/docs/phase-3-spec.md @@ -358,7 +358,9 @@ in a new `tests/state_update.rs` (reuse `tests/common`). `nonce/code_hash == None`. 6. **Account code patch:** patch code; assert `code_hash` recomputed (`Bytecode::hash_slow`), `code_hash` delta recorded; balance/nonce preserved. -7. **Account create:** patch an absent account → materialized with patched fields. +7. **Cold account patch:** patch an absent account → skipped and surfaced in + `StateDiff.skipped_accounts`; explicit `AccountUpsert` materializes with + patched fields. 8. **Purge Account / AllStorage / Slots:** correct layers cleared; `PurgeRecord` counts (`slots_removed`, `account_removed`) correct on both layers. 9. **`apply_updates` fold + merge:** a mixed batch (Slot, Account, Purge) → @@ -645,7 +647,7 @@ the `SlotDelta`/`modify_slot` base read (HIGH) and `apply_slot`'s `old`/predicat **and** no backend value) stays skip-and-surface. Add a test for the hot-zero case (it is currently the untested seam between Decision-4 skip and apply). -### 16.1 No-op `Account` patch must not materialize a backend account (audit LOW) +### 16.1 No-op / cold `Account` patch must not materialize a backend account (audit LOW; tightened in Phase 5) `apply_account_patch` (src/cache/mod.rs ~1331-1340) writes the patched `AccountInfo` into the backend **unconditionally**, so an all-`None` (or @@ -654,23 +656,21 @@ otherwise no-change) patch on an address absent from both layers inserts diff — breaking no-op parity with the Slot path and (per the cold-account hazard) masking a future RPC fetch. **Fix (LOCKED):** compute the change first; **only write-through when at least one field actually changes** (i.e. skip both layer -writes and return `None` when the patched `info` equals the loaded base). A real -field change on an absent address still materializes the backend account (the -existing intended behavior — keep `apply_account_patch_materializes_absent_account` -green). Add a no-op idempotence test (patching balance to its current value ⇒ -empty diff, no backend account materialized). - -### 16.2 Cold absolute-`Account`-patch hazard — document (audit LOW) - -A *partial* absolute `Account` patch on a cold (un-fetched) address writes default -nonce/code through the shared backend, masking the real on-chain account. This is -spec-locked §5.2 behavior, **not** changed here, but it is an undocumented -live-fork footgun. **Fix (LOCKED, docs only):** add a `### Known issues` entry in -`docs/KNOWN_ISSUES.md` and a prominent `# Warning` doc paragraph on -`apply_update` / `StateUpdate::Account` / `AccountPatch` stating that a partial -patch on an address absent from both cache layers writes default nonce/code as -authoritative (pre-empting RPC), so callers must fetch+seed the account first, or -use `StateUpdate::BalanceDelta` (§16.5) for relative native-balance tracking. +writes and return `None` when the patched `info` equals the loaded base). +Phase 5 tightened the cold-account contract further: a real field change on an +address absent from both layers now skips and records `SkippedAccountPatch` in +`StateDiff.skipped_accounts`; explicit materialization uses +`StateUpdate::AccountUpsert`. Add no-op and cold-skip idempotence tests (patching +balance to its current value ⇒ empty diff; cold balance patch ⇒ no backend account +materialized). + +### 16.2 Cold absolute-`Account`-patch hazard — fixed in Phase 5 (audit LOW) + +A *partial* absolute `Account` patch on a cold (un-fetched) address used to write +default nonce/code through the shared backend, masking the real on-chain account. +Phase 5 changed this contract: `StateUpdate::Account` is cold-aware and records a +`SkippedAccountPatch` instead; callers that intentionally want a synthetic/default +account use `StateUpdate::AccountUpsert`. ### 16.3 `serde` on the vocabulary (audit HIGH gap) @@ -778,7 +778,7 @@ Add tests (in `tests/state_update.rs` unless noted). Each must assert the materialized where the spec says none should be (mirror `apply_slot_no_overlay_account_is_not_materialized`). - **Backend-only account patch:** seed an account only in the backend - (`blockchain_db().accounts().write().insert`), patch balance, assert + (`unchecked_blockchain_db().accounts().write().insert`), patch balance, assert `AccountChange.balance == Some((old,new))`, backend updated, overlay still absent. - **Nonce-only** and **multi-field (balance+nonce+code)** patches: assert the respective `AccountChange` fields are `Some`/`None` correctly. diff --git a/docs/phase-5-spec.md b/docs/phase-5-spec.md new file mode 100644 index 0000000..384ba14 --- /dev/null +++ b/docs/phase-5-spec.md @@ -0,0 +1,373 @@ +# Phase 5 — copy-on-write snapshots (Pillar A) + +> Status: **build contract**. Authored by the overseer before implementation; the +> red acceptance tests in [`../tests/cow_snapshot.rs`](../tests/cow_snapshot.rs) +> and the extended overlay tests pin this contract and gate the deliverable. +> Decisions below are **locked** (resolved with the user) unless marked OPEN. + +## 0. Ground rules + +1. **No behavior change on reads.** Every read against a snapshot or an overlay + built from it must return *exactly* what today's deep-clone snapshot returns — + bit-for-bit, including the audited `StorageCleared` / `NotExisting` / + two-layer-precedence semantics. This is enforced by a **differential-equivalence + test** (§8.1): the new `create_snapshot()` must be read-indistinguishable from + the retained reference `create_snapshot_deep_clone()` after *every* mutation kind. +2. **`Send + Sync` snapshot, `Send` overlay, lock-free reads.** `EvmSnapshot` + stays `Send + Sync`; `EvmOverlay` stays `Send`. Snapshot/overlay reads must not + take a lock and must not regress to a non-`O(1)` lookup (no persistent/HAMT map + on the read path — see Decision D1). The existing `test_snapshot_is_send_sync` + and `test_overlay_is_send` must keep passing unchanged. +3. **No new external dependency.** Structural sharing is achieved with `Arc` over + the per-account storage maps, not a third-party persistent-map crate (D1). +4. **Keep the deep-clone reachable** for A/B benchmarking and as the equivalence + reference (D3). It is retained as `create_snapshot_deep_clone()`. +5. **Standard bars.** `cargo fmt --check`; `cargo clippy --all-targets -- -D + warnings` (default) **and** `cargo clippy --lib --no-default-features -- -D + warnings`; `cargo test` (both feature configs); `RUSTDOCFLAGS=-D warnings cargo + doc`; `cargo bench --no-run`. + +## 1. Goal + +`EvmCache::create_snapshot()` is today an **O(total state) deep clone** +([`mod.rs`](../src/cache/mod.rs) `create_snapshot`): it copies every account and, +dominantly, **every storage slot** of both cache layers into fresh `HashMap`s on +every call. Pillar A replaces this with a **copy-on-write** scheme whose cost +tracks *changed* state, not *total* state, and whose clones are `Arc` handle +copies rather than deep copies. + +Two pillars (both in scope this phase): + +- **A.1 — structural sharing for `create_snapshot`** (§2–§4). +- **A.2 — overlay buffer / instance reuse** (§5). + +## 2. Model: memoized immutable base + fresh hot-layer fold + +### 2.1 The two layers and the cost asymmetry + +- **Layer 2 — `BlockchainDb` (the cold base).** The lazily-fetched / bulk-seeded + fork index. At a fixed block it is **append-mostly**: a fetched `(addr, slot)` + value is canonical and is not rewritten; only `set_block`/re-pin replaces it, + and the controlled bulk writers (`inject_storage_batch*`) and the write-through + funnel mutate it. This is the *large* state. +- **Layer 1 — `CacheDB` overlay (the hot delta).** revm sim commits, write-through + applies, direct inserts, freshness corrections. This is the *small, changing* + set, and it always **shadows** layer 2 on a read (overlay wins). + +The deep clone re-copies all of layer 2 every call even though it barely changes +between successive snapshots. COW memoizes layer 2 and folds only layer 1 fresh. + +### 2.2 The frozen base + +Add an internal, immutable, `Arc`-shared flatten of **layer 2 only**: + +```rust +// src/cache/snapshot.rs (or a new src/cache/cow.rs, implementer's choice) +pub(crate) struct BaseState { + /// Layer-2 account info, by address. (Layer-2 has no NotExisting concept; + /// that classification is purely a layer-1 property — see §4.) + pub(crate) accounts: HashMap, + /// Layer-2 storage, per account, **shared by `Arc`** so cloning a base is a + /// handle copy, never a per-slot copy. + pub(crate) storage: HashMap>>, + /// Bytecode by hash, derived from `accounts` at build time. + pub(crate) code_by_hash: HashMap, +} +``` + +`EvmCache` memoizes the current base and the bookkeeping needed to keep it honest: + +```rust +// fields on EvmCache +base: Option>, // None until first snapshot / after a reset +base_dirty: HashSet
, // layer-2 addrs changed since `base` was built +base_full_rebuild: bool, // set by set_block / re-pin: rebuild from scratch +base_storage_lens: HashMap, // per-acct layer-2 slot counts at last build +``` + +These fields are **not** part of any public API and **not** serialized. + +### 2.3 `refresh_base(&mut self)` — called at the top of `create_snapshot` + +Produces an up-to-date `Arc` reusing the previous one wherever layer 2 +is unchanged. It must **never mutate an `Arc` that may be shared** with a +live snapshot — on any change it builds a *new* `BaseState` that shares the `Arc`s +of unchanged accounts and rebuilds only changed ones (copy-on-write). + +Algorithm: + +1. **Full rebuild** if `base.is_none() || base_full_rebuild`: + flatten all of layer 2 into a fresh `BaseState` (one `Arc` per account); + record `base_storage_lens`; clear `base_dirty`; clear `base_full_rebuild`. +2. **Else, detect uncontrolled growth** (lazy RPC fetch / prefetch writes layer 2 + from inside `foundry-fork-db`, which we cannot hook): scan + `blockchain_db.storage().read()` and `accounts().read()`; for any address whose + slot count differs from `base_storage_lens`, or any account absent from the base, + add it to `base_dirty`. This is an `O(accounts)` length comparison — **not** an + `O(slots)` value scan. +3. **Else, if `base_dirty` is empty** → reuse the existing `Arc` + unchanged (the common hot-loop case; `create_snapshot` is then `O(1)` for the + base). +4. **Otherwise (some addresses dirty)** → build a new `BaseState`: + - clone the outer maps (an `O(accounts)` clone of `Arc` handles + plain + `AccountInfo`, **no per-slot copy**); + - for each dirty address, rebuild its `Arc>` from the current + layer-2 storage and refresh its `AccountInfo` / `code_by_hash`; + - update `base_storage_lens`; clear `base_dirty`; store as the new `Arc`. + +> Correctness rests on `base_dirty` ∪ the growth scan covering every way layer 2 +> can change such that the change is **not shadowed by layer 1**. §3 enumerates the +> sites. The equivalence test (§8.1) exercises all of them and fails loudly on any +> miss — a missed invalidation is a red test, never a silent stale read. + +### 2.4 `create_snapshot()` — the two-tier snapshot + +```rust +pub fn create_snapshot(&mut self) -> Arc { … } +``` + +Note the signature change to `&mut self` (it now refreshes/memoizes the base). +Steps: + +1. `self.refresh_base()` → `let base = Arc::clone(self.base.as_ref().unwrap());` + (`O(1)` when layer 2 is unchanged). +2. Fold **layer 1** (`self.db.cache.accounts`) into the snapshot's overlay maps and + the cleared/not-existing sets, applying the same classification as today + (§4) — `O(layer-1)`. Per-account overlay storage may be a plain + `HashMap` (the hot set is small); `Arc`-interning it is optional. +3. Construct the two-tier `EvmSnapshot { base, …overlay…, …block ctx… }`. + +Block context (`block_number`, `basefee`, `coinbase`, `prevrandao`, `gas_limit`, +`chain_id`, `timestamp`, `spec_id`) is copied as today. + +### 2.5 New `EvmSnapshot` shape + +```rust +pub struct EvmSnapshot { + pub(crate) base: Arc, + /// Layer-1 accounts that are present to the EVM (NotExisting excluded). + pub(crate) overlay_accounts: HashMap, + /// Layer-1 storage delta. A cleared account ALWAYS has an entry here (possibly + /// empty) so the cleared rule is decided without consulting the base. + pub(crate) overlay_storage: HashMap>, + /// Bytecode introduced by layer 1 (checked before `base.code_by_hash`). + pub(crate) overlay_code_by_hash: HashMap, + pub(crate) storage_cleared: HashSet
, + pub(crate) accounts_not_existing: HashSet
, + pub(crate) block_hashes: HashMap, + // …block context fields unchanged… +} +``` + +All fields stay `pub(crate)` (no public field break). In-crate `#[cfg(test)]` +constructors of `EvmSnapshot` (in `overlay.rs`) must be updated to the new shape. + +## 3. Base-invalidation sites (the correctness checklist) + +Every site below must keep the memoized base honest. Implement as a private +helper (e.g. `self.mark_base_dirty(addr)` / `self.invalidate_base()`). + +| Site | Layer touched | Action | +| --- | --- | --- | +| `write_slot_through(addr, …)` | layer 2 always; layer 1 if present | `mark_base_dirty(addr)` (over-invalidation when also in layer 1 is **safe** — it just re-folds that one account; D2 keeps it simple over clever) | +| `inject_storage_batch` / `inject_storage_batch_fresh` | layer 2 only | `mark_base_dirty(addr)` for each touched addr | +| account info / storage seeded into layer 2 (construction, `inject_v2/v3_*` paths that hit layer 2) | layer 2 | `mark_base_dirty(addr)` | +| `purge_*` removing layer-2 entries | layer 2 | `mark_base_dirty(addr)` (or `invalidate_base()` if simpler for account-level purge) | +| `set_block` / `repin_to_block` | replaces layer 2 | `base_full_rebuild = true` | +| revm commit (`call_raw(commit=true)`, session commit) | **layer 1 only** | **nothing** — folded fresh; never makes the base stale | +| direct `db_mut()` inserts (`insert_account_info`/`insert_account_storage`) | **layer 1** | **nothing** — folded fresh | +| uncontrolled lazy RPC fetch / prefetch | layer 2 | caught by the `O(accounts)` growth scan in `refresh_base` step 2 | + +> The litmus test for "needs invalidation": *can this change a layer-2 value that a +> snapshot read would surface (i.e. that layer 1 does not shadow)?* If yes → dirty. +> Layer-1-only writes are always shadowed → never dirty the base. + +## 4. Read semantics (must equal today's flatten, bit-for-bit) + +`EvmSnapshot` exposes the lookups the overlay needs; `EvmOverlay` calls these +instead of indexing fields directly. + +```rust +impl EvmSnapshot { + /// Account info as the EVM sees it. None for NotExisting (do NOT consult base). + pub(crate) fn account_info(&self, a: Address) -> Option<&AccountInfo> { + if self.accounts_not_existing.contains(&a) { return None; } + self.overlay_accounts.get(&a).or_else(|| self.base.accounts.get(&a)) + } + + /// Storage value, mirroring cached_storage_value / today's flatten. + pub fn storage_value(&self, a: Address, s: U256) -> Option { + if let Some(m) = self.overlay_storage.get(&a) { + if let Some(v) = m.get(&s) { return Some(*v); } + if self.storage_cleared.contains(&a) { return Some(U256::ZERO); } // cleared: base dropped + // not cleared: fall through to base + } + if let Some(v) = self.base.storage.get(&a).and_then(|m| m.get(&s)) { + return Some(v); + } + None + } + + pub(crate) fn code(&self, h: B256) -> Option<&Bytecode> { + self.overlay_code_by_hash.get(&h).or_else(|| self.base.code_by_hash.get(&h)) + } +} +``` + +Invariants the equivalence test pins: +- A **cleared** (`StorageCleared`/`NotExisting`) layer-1 account: snapshot holds + only its overlay slots; an absent slot reads `Some(ZERO)`; base slots are never + surfaced (this is why cleared accounts always get an `overlay_storage` entry). +- A **NotExisting** account: `account_info` → `None`, `storage_value` → `Some(ZERO)` + for any slot; excluded from `overlay_accounts`/`overlay_code_by_hash`. +- A **non-cleared** layer-1 account: overlay slot wins, else base slot, else `None`. +- An address only in layer 2: base slot, else `None`. + +`EvmOverlay::{basic, storage, code_by_hash}` are rewritten to: dirty layer → +`snapshot.account_info/storage_value/code` → (the `NotExisting`/`cleared` +short-circuits already live inside those) → `ext_db` fallback (unchanged) → +default. Behavior must match the current overlay exactly (the existing +`overlay.rs` unit tests must keep passing). + +## 5. Overlay buffer / instance reuse (Pillar A.2) + +### 5.1 `EvmOverlay::reset(&mut self)` — recycle one overlay across many sims + +```rust +/// Clear the per-simulation dirty layer so this overlay can be reused for the +/// next simulation against the same snapshot, without reallocating. +pub fn reset(&mut self) { + self.dirty_accounts.clear(); + self.dirty_storage.clear(); + // keep: snapshot Arc, ext_db, the reusable buffer (§5.2) +} +``` + +A worker doing K sims calls `EvmOverlay::new` once and `reset()` between sims +instead of allocating a fresh overlay (+ dirty maps + `Arc` clone) each time. Must +be exactly equivalent to a fresh overlay: a reset overlay reads the pristine +snapshot base again (regression test §8.2). + +### 5.2 Reusable shared-memory buffer (keep `EvmOverlay: Send`) + +Today each `build_evm` / `build_evm_with_inspector` allocates a fresh +`Rc>>` of 64 KB. Reuse it across calls **without** making the +overlay `!Send`: + +- Store the buffer on the overlay as a **plain `Vec`** (`Send`): + `reusable_buffer: Vec` (pre-allocated to `OVERLAY_SHARED_MEMORY_CAPACITY` in + `new`/`reset` keeps it). +- In the **call methods** (`call_raw`, `simulate_with_transfer_tracking`, + `call_raw_with_access_list_with`) that own the full build→transact→revert cycle: + `let buf = std::mem::take(&mut self.reusable_buffer);` **before** the + `with_db(&mut *self)` borrow, move it into a method-local + `Rc::new(RefCell::new(buf))`, build the EVM with that local context, run, then + after the EVM is dropped reclaim `self.reusable_buffer = Rc::try_unwrap(rc).into_inner(); self.reusable_buffer.clear();` + The `Rc` never lives on the overlay → the overlay stays `Send`. +- Refactor the shared body into e.g. `build_evm_with_local(&mut self, local: LocalContext)`; + the **public `build_evm`** keeps allocating a fresh buffer (it hands out the EVM + and cannot reclaim) — documented. +- A panic between take and reclaim only loses the buffer (re-allocated next call); + no correctness impact. + +`test_overlay_is_send` must still compile/pass. + +## 6. Public API surface + +- `EvmCache::create_snapshot(&mut self) -> Arc` — **signature change** + `&self` → `&mut self` (memoizes the base). Update all call sites (freshness + controller, tests, examples, benches). Record in CHANGELOG `### Changed`. +- `EvmCache::create_snapshot_deep_clone(&self) -> Arc` — **new**, + `#[doc(hidden)] pub`. The retained reference: today's flatten producing a + two-tier snapshot with `base` = the fully-merged flatten and empty overlay maps + (plus the cleared/not-existing sets in place). Used by the equivalence test and + the A/B bench. Stays `&self`. +- `EvmOverlay::reset(&mut self)` — **new** public method. +- `EvmSnapshot::storage_value` — retained (reimplemented over two tiers); used by + the freshness validator. `account_info`/`code` are `pub(crate)`. +- No other public signatures change. + +## 7. Benchmarks (`benches/simulation.rs`) + +The current `populated_cache` seeds everything via `db_mut()` into **layer 1**, +which is *not* how a fork cache holds its cold index. Update/extend: + +1. **Realistic cold index in layer 2.** Add a `populated_cache_layer2` that bulk- + seeds the index via `inject_storage_batch` (the cold-load path) so the cold + state lives in the base. The `create_snapshot` group runs the COW path on it. +2. **A/B group.** For each size, bench both `create_snapshot` (COW) and + `create_snapshot_deep_clone` (legacy) so the win is explicit in one report. +3. **Hot-loop re-snapshot.** New bench: build the cold base, take one snapshot + (warms the base), apply a *small* layer-1 mutation (a handful of slots via + `apply_updates`), then measure `create_snapshot` — this is the memoization win + (should be ≈ flat across cold-index size, vs. the deep clone's slope). +4. **`overlay_fanout`.** Add a `reset()`-recycled variant alongside the + `EvmOverlay::new`-per-iter variant to show the A.2 win. + +Keep all benches offline (mocked provider). Document expected shapes in the module +header (COW `create_snapshot` flat vs. deep-clone sloped; re-snapshot ≈ O(changed)). + +## 8. Tests (red contract — written before implementation) + +### 8.1 `tests/cow_snapshot.rs` — differential equivalence (the gate) + +A helper `assert_equivalent(cache)` builds `cow = cache.create_snapshot()` and +`deep = cache.create_snapshot_deep_clone()` and asserts they are +**read-indistinguishable**, comparing via reads (internal reprs differ by design): +- identical account set (union of probed addresses); `basic`/`account_info` equal + for each (including `None` for NotExisting); +- `storage_value(a, s)` equal for every probed `(a, s)` — including **absent** + slots (expect equal `None`/`Some(ZERO)`), cleared accounts, and not-existing + accounts; +- identical `code` for each probed code hash; identical block context; +- overlays built from each (`EvmOverlay::new(snap, None)`) return identical + `balanceOf` / `call_raw` outputs for a `MockERC20`. + +Drive a single cache through a sequence and assert equivalence **after each step**: +1. empty cache; 2. after `insert_account_info`; 3. after layer-1 storage insert; +4. after `apply_updates` `Slot` write-through (addr in layer 1 → shadowed); +5. after `apply_updates` `Slot` write-through to an addr **absent** from layer 1 + (layer-2-only — the §3 footgun); 6. after `apply_updates` `BalanceDelta`; +7. after a committing `call_raw` (revm commit → layer 1); 8. after + `inject_storage_batch` (layer-2-only, incl. **overwriting** an existing slot at + unchanged length); 9. after a simulated lazy fetch (insert directly into + `blockchain_db` to mimic backend growth — both a new account and a **new slot on + an existing account**); 10. after a `purge_*`; 11. after `set_block`. +Also: take a snapshot, then mutate the cache, and assert the **earlier** snapshot is +unchanged (memoized base is COW, not aliased). + +### 8.2 Overlay reuse (extend `tests/snapshot_overlay.rs` or new module) + +- `reset()` clears dirty state: commit a transfer into an overlay, `reset()`, then + reads observe the pristine snapshot again. +- A reset-recycled overlay across two sims yields identical results to two fresh + overlays. +- `EvmOverlay` stays `Send`; `EvmSnapshot` stays `Send + Sync` (compile asserts). +- Buffer reuse does not change call results (a second `call_raw` on the same + overlay returns the same value as the first). + +The existing `tests/snapshot_overlay.rs` cases (immutability, isolation, cleared, +not-existing) must keep passing **unchanged** — they are part of the contract. + +## 9. Locked decisions + +- **D1 — `Arc`-shared maps, not persistent HAMT.** Reads stay `O(1)` with no + per-`SLOAD` regression; no external dependency. (Rejected: `imbl`/`rpds`.) +- **D2 — base memoized as immutable; over-invalidation is acceptable, silent + staleness is not.** `write_slot_through` marks the address dirty unconditionally + (simpler than reasoning per-call about layer-1 shadowing); the equivalence test + is the hard backstop. +- **D3 — keep the deep clone** as `create_snapshot_deep_clone` for A/B + as the + equivalence reference. +- **D4 — overlay reuse: buffer reuse *and* `reset()` recycle** (both in scope). +- **D5 — `create_snapshot` becomes `&mut self`** (the memoization cost). The + freshness controller and all callers are updated. + +## 10. Acceptance + +All §0.5 bars green in both feature configs; the §8 tests pass; the existing +snapshot/overlay/freshness tests pass unchanged; `benches/simulation.rs` shows the +COW `create_snapshot` and the `reset()` fan-out beating their legacy/`new` +baselines, with the numbers reported. CHANGELOG / ROADMAP (Phase 5 → Done) / +KNOWN_ISSUES updated. Lands on `phase-5-cow-snapshots`, stacked on +`phase-4-event-pipeline`. diff --git a/examples/prefetch_registry.rs b/examples/prefetch_registry.rs index 3c57366..690092b 100644 --- a/examples/prefetch_registry.rs +++ b/examples/prefetch_registry.rs @@ -19,7 +19,7 @@ use alloy_primitives::{Address, U256}; use evm_fork_cache::StorageAccessList; use evm_fork_cache::prefetch_registry::PrefetchRegistry; -fn main() { +fn main() -> anyhow::Result<()> { let pool = Address::repeat_byte(0xAA); let vault_a = Address::repeat_byte(0x01); let vault_b = Address::repeat_byte(0x02); @@ -45,7 +45,7 @@ fn main() { // Persist to disk (bincode) and reload — the shape survives the round trip. let path = std::env::temp_dir().join("evm_fork_cache_example_prefetch.bin"); - registry.save(&path); + registry.save(&path)?; let loaded = PrefetchRegistry::load(&path); let aggregated = loaded.phase_slots("pool_refresh"); @@ -61,4 +61,5 @@ fn main() { ); let _ = std::fs::remove_file(&path); + Ok(()) } diff --git a/src/access_list.rs b/src/access_list.rs index a84c99e..bf9df24 100644 --- a/src/access_list.rs +++ b/src/access_list.rs @@ -15,8 +15,9 @@ use alloy_eips::eip2930::{AccessList, AccessListItem}; use alloy_network::Network; use alloy_primitives::{Address, B256, U256, address}; use alloy_provider::Provider; +use alloy_rlp::Encodable; use alloy_sol_types::{SolCall, sol}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use revm::context::result::ExecutionResult; use tracing::{debug, info}; @@ -141,36 +142,18 @@ impl SmartAccessList { /// - **L2 savings**: `100 gas * entry_count * perArbGas`, where each address /// and each storage key counts as one entry (the EIP-2929 warm-vs-cold /// access discount). - /// - **L1 cost**: `l1_data_gas * l1_base_fee`, where `l1_data_gas` sums the - /// per-byte calldata gas ([`l1_data_gas_for_bytes`]) of every address and - /// key plus a fixed RLP-framing surcharge. - /// - /// # Cost model is approximate - /// - /// The RLP-overhead constants — roughly `4 * 16` gas per address entry, - /// `16` gas per storage key, and `3 * 16` gas for the top-level list headers - /// — are a deliberate **approximation**, not the exact EIP-2930 RLP - /// serialization cost. They assume worst-case non-zero framing bytes and do - /// not account for the real RLP length-prefix sizing, address/key sharing, - /// or rollup-specific compression. Treat this as a rough profitability gate, - /// not a precise gas accounting: a list near the break-even point may be - /// classified either way. + /// - **L1 cost**: `l1_data_gas * l1_base_fee`, where `l1_data_gas` is the + /// exact per-byte calldata gas ([`l1_data_gas_for_bytes`]) of the EIP-2930 + /// RLP-encoded access list. /// /// # Errors /// - /// Returns `Err` only if the call wrapper itself surfaces a non-recoverable - /// error; in practice provider/pricing failures do **not** error. + /// Returns `Err` if the provider/pricing queries fail. /// /// Returns `Ok(None)` when: /// - the list is empty, - /// - the `ArbGasInfo` pricing or L1-base-fee query fails (the error is logged - /// at `debug` and swallowed — see below), /// - either the L2 or L1 gas price reads as zero, or /// - the estimated L1 cost meets or exceeds the L2 savings (not profitable). - /// - /// A `None` returned because a provider query failed is **indistinguishable** - /// from a `None` returned because the list was genuinely unprofitable: both - /// surface as a skipped access list, not as an error. pub async fn into_access_list_if_profitable( self, provider: &P, @@ -182,21 +165,15 @@ impl SmartAccessList { // Query ArbGasInfo for current pricing let arb = ArbGasInfo::new(ARB_GAS_INFO, provider); let prices_call = arb.getPricesInWei(); - let prices = match prices_call.call().await { - Ok(p) => p, - Err(e) => { - debug!(error = %e, "Failed to query ArbGasInfo prices, skipping access list"); - return Ok(None); - } - }; + let prices = prices_call + .call() + .await + .context("failed to query ArbGasInfo prices for access-list profitability")?; let l1_fee_call = arb.getL1BaseFeeEstimate(); - let l1_base_fee = match l1_fee_call.call().await { - Ok(fee) => fee, - Err(e) => { - debug!(error = %e, "Failed to query L1 base fee, skipping access list"); - return Ok(None); - } - }; + let l1_base_fee = l1_fee_call + .call() + .await + .context("failed to query ArbGasInfo L1 base fee for access-list profitability")?; let l2_gas_price = prices.perArbGas; @@ -205,46 +182,14 @@ impl SmartAccessList { return Ok(None); } - // Calculate aggregate L2 savings and L1 cost - let mut total_entries: u64 = 0; - let mut total_l1_data_gas: u64 = 0; - - for item in &self.items { - total_entries += 1; - total_l1_data_gas += l1_data_gas_for_bytes(item.address.as_slice()); - // RLP overhead per address entry (~3-4 bytes, assume non-zero) - total_l1_data_gas += 4 * 16; - - for key in &item.storage_keys { - total_entries += 1; - total_l1_data_gas += l1_data_gas_for_bytes(key.as_slice()); - // RLP length prefix (1 byte, non-zero) - total_l1_data_gas += 16; - } - } - // Top-level RLP list headers (~3 bytes) - total_l1_data_gas += 3 * 16; - - // L2 savings: 100 gas per entry × L2 gas price - let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price; - // L1 cost: serialized data gas × L1 base fee - let l1_cost_wei = U256::from(total_l1_data_gas) * l1_base_fee; - - let profitable = l2_savings_wei > l1_cost_wei; - - info!( - entries = total_entries, - items = self.items.len(), - l2_savings_wei = %l2_savings_wei, - l1_cost_wei = %l1_cost_wei, - l2_gas_price_gwei = %format_gwei(l2_gas_price), - l1_base_fee_gwei = %format_gwei(l1_base_fee), - profitable, - "Access list profitability check" - ); - - if profitable { - Ok(Some(AccessList(self.items))) + let access_list = AccessList(self.items); + if log_access_list_profitability( + &access_list, + l2_gas_price, + l1_base_fee, + "Access list profitability check", + ) { + Ok(Some(access_list)) } else { Ok(None) } @@ -261,31 +206,14 @@ impl SmartAccessList { /// [`SmartAccessList::into_access_list_if_profitable`] for a pre-built /// [`AccessList`]; the two share the same cost model and break-even comparison. /// -/// # Cost model is approximate -/// -/// As with [`SmartAccessList::into_access_list_if_profitable`], the L1 cost is -/// estimated from per-byte calldata gas ([`l1_data_gas_for_bytes`]) plus fixed -/// RLP-framing surcharges (`4 * 16` gas per address, `16` gas per key, `3 * 16` -/// gas for the top-level headers). Those framing constants are an -/// **approximation**, not the exact EIP-2930 RLP serialization cost: they assume -/// worst-case non-zero bytes and ignore real length-prefix sizing and -/// rollup-specific compression. Treat the result as a rough profitability gate. -/// /// # Errors /// -/// Returns `Err` only if the call wrapper itself surfaces a non-recoverable -/// error; in practice provider/pricing failures do **not** error. +/// Returns `Err` if the provider/pricing queries fail. /// /// Returns `Ok(None)` when: /// - the list is empty, -/// - the `ArbGasInfo` pricing or L1-base-fee query fails (the error is logged at -/// `debug` and swallowed), /// - either the L2 or L1 gas price reads as zero, or /// - the estimated L1 cost meets or exceeds the L2 savings (not profitable). -/// -/// A `None` returned because a provider query failed is **indistinguishable** -/// from a `None` returned because the list was genuinely unprofitable: both -/// surface as a skipped access list, not as an error. pub async fn access_list_if_profitable( access_list: AccessList, provider: &P, @@ -296,20 +224,16 @@ pub async fn access_list_if_profitable( // Query ArbGasInfo for current pricing let arb = ArbGasInfo::new(ARB_GAS_INFO, provider); - let prices = match arb.getPricesInWei().call().await { - Ok(p) => p, - Err(e) => { - debug!(error = %e, "Failed to query ArbGasInfo prices, skipping access list"); - return Ok(None); - } - }; - let l1_base_fee = match arb.getL1BaseFeeEstimate().call().await { - Ok(fee) => fee, - Err(e) => { - debug!(error = %e, "Failed to query L1 base fee, skipping access list"); - return Ok(None); - } - }; + let prices = arb + .getPricesInWei() + .call() + .await + .context("failed to query ArbGasInfo prices for access-list profitability")?; + let l1_base_fee = arb + .getL1BaseFeeEstimate() + .call() + .await + .context("failed to query ArbGasInfo L1 base fee for access-list profitability")?; let l2_gas_price = prices.perArbGas; @@ -318,45 +242,12 @@ pub async fn access_list_if_profitable( return Ok(None); } - // Calculate aggregate L2 savings and L1 cost - let mut total_entries: u64 = 0; - let mut total_l1_data_gas: u64 = 0; - - for item in &access_list.0 { - total_entries += 1; - total_l1_data_gas += l1_data_gas_for_bytes(item.address.as_slice()); - // RLP overhead per address entry (~3-4 bytes, assume non-zero) - total_l1_data_gas += 4 * 16; - - for key in &item.storage_keys { - total_entries += 1; - total_l1_data_gas += l1_data_gas_for_bytes(key.as_slice()); - // RLP length prefix (1 byte, non-zero) - total_l1_data_gas += 16; - } - } - // Top-level RLP list headers (~3 bytes) - total_l1_data_gas += 3 * 16; - - // L2 savings: 100 gas per entry × L2 gas price - let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price; - // L1 cost: serialized data gas × L1 base fee - let l1_cost_wei = U256::from(total_l1_data_gas) * l1_base_fee; - - let profitable = l2_savings_wei > l1_cost_wei; - - info!( - entries = total_entries, - items = access_list.0.len(), - l2_savings_wei = %l2_savings_wei, - l1_cost_wei = %l1_cost_wei, - l2_gas_price_gwei = %format_gwei(l2_gas_price), - l1_base_fee_gwei = %format_gwei(l1_base_fee), - profitable, - "Simulation access list profitability check" - ); - - if profitable { + if log_access_list_profitability( + &access_list, + l2_gas_price, + l1_base_fee, + "Simulation access list profitability check", + ) { Ok(Some(access_list)) } else { Ok(None) @@ -483,6 +374,49 @@ pub fn l1_data_gas_for_bytes(data: &[u8]) -> u64 { .sum() } +/// Exact L1 calldata gas for the EIP-2930 RLP encoding of an access list. +pub fn access_list_rlp_data_gas(access_list: &AccessList) -> u64 { + let mut encoded = Vec::with_capacity(access_list.length()); + access_list.encode(&mut encoded); + l1_data_gas_for_bytes(&encoded) +} + +fn access_list_entry_count(access_list: &AccessList) -> u64 { + access_list + .0 + .iter() + .map(|item| 1 + item.storage_keys.len() as u64) + .sum() +} + +fn log_access_list_profitability( + access_list: &AccessList, + l2_gas_price: U256, + l1_base_fee: U256, + message: &'static str, +) -> bool { + let total_entries = access_list_entry_count(access_list); + let total_l1_data_gas = access_list_rlp_data_gas(access_list); + let l2_savings_wei = U256::from(total_entries) * U256::from(100) * l2_gas_price; + let l1_cost_wei = U256::from(total_l1_data_gas) * l1_base_fee; + let profitable = l2_savings_wei > l1_cost_wei; + + info!( + entries = total_entries, + items = access_list.0.len(), + l1_data_gas = total_l1_data_gas, + l2_savings_wei = %l2_savings_wei, + l1_cost_wei = %l1_cost_wei, + l2_gas_price_gwei = %format_gwei(l2_gas_price), + l1_base_fee_gwei = %format_gwei(l1_base_fee), + profitable, + check = message, + "Access list profitability check" + ); + + profitable +} + /// Filter already-warm and excluded addresses from an access list, then apply /// it to the transaction request. /// @@ -571,4 +505,53 @@ mod tests { let addr = Address::repeat_byte(0xFF); assert_eq!(l1_data_gas_for_bytes(addr.as_slice()), 320); } + + #[test] + fn access_list_rlp_data_gas_uses_exact_eip2930_encoding() { + let access_list = AccessList(vec![AccessListItem { + address: Address::ZERO, + storage_keys: Vec::new(), + }]); + + // RLP([[zero_address, []]]) = d7 d6 94 <20 zero bytes> c0. + // Four non-zero framing bytes cost 64 gas; twenty zero address bytes cost + // 80 gas. The old fixed-overhead approximation returned 192. + assert_eq!(access_list_rlp_data_gas(&access_list), 144); + } + + #[tokio::test] + async fn access_list_profitability_provider_error_returns_err() { + use alloy_network::Ethereum; + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + let access_list = AccessList(vec![AccessListItem { + address: Address::repeat_byte(0xAA), + storage_keys: Vec::new(), + }]); + + let err = access_list_if_profitable(access_list, &provider) + .await + .expect_err("provider failures must be distinguishable from unprofitable lists"); + assert!( + err.to_string().contains("ArbGasInfo") || err.to_string().contains("provider"), + "unexpected error: {err:#}" + ); + } + + #[tokio::test] + async fn access_list_profitability_empty_list_still_returns_none() { + use alloy_network::Ethereum; + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + let result = access_list_if_profitable(AccessList::default(), &provider) + .await + .expect("empty list must not query provider"); + assert!(result.is_none()); + } } diff --git a/src/cache/binary_state.rs b/src/cache/binary_state.rs index 1deef61..ccfb3d0 100644 --- a/src/cache/binary_state.rs +++ b/src/cache/binary_state.rs @@ -5,20 +5,25 @@ //! and write a compact binary file. On load, we populate BlockchainDb directly, //! then seed bytecodes from the separate bytecodes.bin cache. //! -//! The file format is raw bincode with no version header or magic bytes, so it -//! is not migratable: a cache written by a build with a different struct layout -//! decodes as a failure (cache miss) rather than being upgraded in place. +//! The file format is a tiny crate-specific envelope (magic bytes + version) +//! followed by bincode payload. Unknown magic/version values are cache misses. use std::path::Path; use std::time::Instant; use alloy_primitives::map::HashMap; use alloy_primitives::{Address, B256, U256}; +use anyhow::{Context as _, Result}; use foundry_fork_db::BlockchainDb; use revm::state::AccountInfo; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; +use super::versioned; + +const BINARY_STATE_MAGIC: &[u8; 8] = b"EFCSTAT\0"; +const BINARY_STATE_VERSION: u32 = 1; + /// Binary-serializable EVM state. Stores accounts without bytecode (bytecodes /// are loaded separately from bytecodes.bin) and all storage slots. #[derive(Serialize, Deserialize)] @@ -42,16 +47,14 @@ struct BinaryAccountInfo { /// and persisted separately to `bytecodes.bin`; the saved account info keeps /// only the `code_hash`. /// -/// Errors are logged at `warn` level and otherwise swallowed: serialization -/// failures, parent-directory creation failures, and write failures all return -/// without signalling to the caller, so a failed save is indistinguishable from -/// a successful one at the call site. +/// Returns an error if serialization, parent-directory creation, or writing +/// fails, so explicit flush callers can distinguish a successful save from a +/// stale or missing on-disk cache. /// -/// The on-disk format is raw bincode with no version header, so it is not -/// forward/backward compatible: a file written by a build with a different -/// layout will fail to decode on load (treated as a cache miss) rather than +/// The on-disk format carries magic bytes and a version number before the +/// bincode payload. Unknown versions are treated as a cache miss rather than /// being migrated. -pub fn save_binary_state(blockchain_db: &BlockchainDb, path: &Path) { +pub fn save_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> Result<()> { let start = Instant::now(); let accounts: Vec<(Address, BinaryAccountInfo)> = blockchain_db @@ -79,27 +82,28 @@ pub fn save_binary_state(blockchain_db: &BlockchainDb, path: &Path) { let state = BinaryEvmState { accounts, storage }; - match bincode::serialize(&state) { - Ok(data) => { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(path, &data) { - Ok(()) => { - let ms = start.elapsed().as_millis(); - debug!( - accounts = state.accounts.len(), - storage_contracts = state.storage.len(), - bytes = data.len(), - save_ms = ms, - "Saved binary EVM state" - ); - } - Err(e) => warn!(error = %e, "Failed to write binary EVM state"), - } - } - Err(e) => warn!(error = %e, "Failed to serialize binary EVM state"), + let data = versioned::encode( + BINARY_STATE_MAGIC, + BINARY_STATE_VERSION, + &state, + "binary EVM state", + )?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create binary EVM state directory {parent:?}"))?; } + std::fs::write(path, &data) + .with_context(|| format!("failed to write binary EVM state to {path:?}"))?; + + let ms = start.elapsed().as_millis(); + debug!( + accounts = state.accounts.len(), + storage_contracts = state.storage.len(), + bytes = data.len(), + save_ms = ms, + "Saved binary EVM state" + ); + Ok(()) } /// Load binary EVM state and populate the BlockchainDb. @@ -109,9 +113,8 @@ pub fn save_binary_state(blockchain_db: &BlockchainDb, path: &Path) { /// Bytecodes should be seeded separately from bytecodes.bin. /// /// Returns `false` (rather than erroring) when `path` cannot be read or its -/// contents fail to decode as the expected bincode layout; a decode failure is -/// logged at `warn` level. Because the format carries no version header, a file -/// written by an incompatible build is reported as a decode failure here. +/// contents fail the magic/version check or fail to decode as the expected +/// bincode layout; failures are logged at `warn` level. pub fn load_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> bool { let start = Instant::now(); @@ -120,12 +123,14 @@ pub fn load_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> bool { Err(_) => return false, }; - let state: BinaryEvmState = match bincode::deserialize(&data) { - Ok(s) => s, - Err(e) => { - warn!(?e, "Failed to decode binary EVM state, starting fresh"); - return false; - } + let Some(state) = versioned::decode::( + &data, + BINARY_STATE_MAGIC, + BINARY_STATE_VERSION, + "binary EVM state", + ) else { + warn!("Failed to decode binary EVM state, starting fresh"); + return false; }; let account_count = state.accounts.len(); @@ -229,8 +234,18 @@ mod tests { } // Save - save_binary_state(&db, &path); + save_binary_state(&db, &path).expect("save binary state"); assert!(path.exists()); + let bytes = std::fs::read(&path).expect("read saved state"); + assert!( + bytes.starts_with(b"EFCSTAT\0"), + "binary state cache must carry a magic header" + ); + assert_eq!( + &bytes[8..12], + &1u32.to_le_bytes(), + "binary state cache must carry an explicit version" + ); // Load into a fresh db let meta2 = BlockchainDbMeta::default(); @@ -265,6 +280,24 @@ mod tests { let _ = std::fs::remove_dir(&dir); } + #[test] + fn save_binary_state_reports_write_failures() { + let dir = std::env::temp_dir().join("evm_fork_cache_test_binary_state_write_error"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_file(&dir); + std::fs::write(&dir, b"not a directory").expect("create file path conflict"); + + let db = BlockchainDb::new(BlockchainDbMeta::default(), None); + let path = dir.join("state.bin"); + let err = save_binary_state(&db, &path).expect_err("save must report write failure"); + assert!( + err.to_string().contains("directory") || err.to_string().contains("Not a directory"), + "unexpected error: {err:#}" + ); + + let _ = std::fs::remove_file(&dir); + } + #[test] fn test_load_missing_file_returns_false() { let meta = BlockchainDbMeta::default(); @@ -289,4 +322,25 @@ mod tests { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir); } + + #[test] + fn load_legacy_raw_bincode_returns_false() { + let dir = std::env::temp_dir().join("evm_fork_cache_test_binary_state_legacy"); + let path = dir.join("legacy.bin"); + let _ = std::fs::create_dir_all(&dir); + let legacy = BinaryEvmState { + accounts: Vec::new(), + storage: Vec::new(), + }; + std::fs::write(&path, bincode::serialize(&legacy).unwrap()).unwrap(); + + let db = BlockchainDb::new(BlockchainDbMeta::default(), None); + assert!( + !load_binary_state(&db, &path), + "unversioned legacy bincode must be treated as a cache miss" + ); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); + } } diff --git a/src/cache/bytecode.rs b/src/cache/bytecode.rs index a894a58..1ef00bc 100644 --- a/src/cache/bytecode.rs +++ b/src/cache/bytecode.rs @@ -6,10 +6,9 @@ //! entries are used to re-seed the `code` of accounts that were restored //! without it. //! -//! Each entry's bytes are hex-encoded for the serde representation, but the -//! file is written as raw bincode with no version header, so a cache written by -//! an incompatible build fails to decode (cache miss) rather than being -//! migrated. +//! Each entry's bytes are hex-encoded for the serde representation. The file is +//! written as a crate-specific versioned envelope followed by bincode payload, so +//! incompatible versions are detected as cache misses. use std::collections::HashMap; use std::path::Path; @@ -18,7 +17,11 @@ use alloy_primitives::Address; use anyhow::Result; use foundry_fork_db::BlockchainDb; use serde::{Deserialize, Serialize}; -use tracing::warn; + +use super::versioned; + +const BYTECODE_CACHE_MAGIC: &[u8; 8] = b"EFCBYTE\0"; +const BYTECODE_CACHE_VERSION: u32 = 1; /// Serializable bytecode cache entry. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -38,15 +41,16 @@ pub(crate) struct BytecodeCache { impl BytecodeCache { /// Load bytecode cache from disk (binary format). /// - /// Returns `None` if `path` cannot be read or its contents fail to decode as - /// bincode for this type; a decode failure is logged at `warn` level. The - /// format carries no version header, so a file from an incompatible build is - /// reported as `None`. + /// Returns `None` if `path` cannot be read, fails the magic/version check, or + /// fails to decode as bincode for this type. pub(crate) fn load(path: &Path) -> Option { let data = std::fs::read(path).ok()?; - bincode::deserialize(&data) - .inspect_err(|e| warn!("Failed to parse bytecode cache (bincode): {}", e)) - .ok() + versioned::decode( + &data, + BYTECODE_CACHE_MAGIC, + BYTECODE_CACHE_VERSION, + "bytecode cache", + ) } /// Save bytecode cache to disk (binary format). @@ -62,7 +66,12 @@ impl BytecodeCache { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let data = bincode::serialize(self)?; + let data = versioned::encode( + BYTECODE_CACHE_MAGIC, + BYTECODE_CACHE_VERSION, + self, + "bytecode cache", + )?; std::fs::write(path, data)?; Ok(()) } @@ -117,6 +126,16 @@ mod tests { }, ); cache.save(&path).expect("save bytecode cache"); + let bytes = std::fs::read(&path).expect("read saved bytecode cache"); + assert!( + bytes.starts_with(b"EFCBYTE\0"), + "bytecode cache must carry a magic header" + ); + assert_eq!( + &bytes[8..12], + &1u32.to_le_bytes(), + "bytecode cache must carry an explicit version" + ); let loaded = BytecodeCache::load(&path).expect("load bytecode cache"); assert_eq!( @@ -128,6 +147,26 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn load_legacy_raw_bincode_is_none() { + let path = temp_path("legacy"); + let mut cache = BytecodeCache::default(); + cache.contracts.insert( + Address::repeat_byte(0x42), + BytecodeCacheEntry { + bytecode: vec![0x60, 0x00], + }, + ); + std::fs::write(&path, bincode::serialize(&cache).unwrap()).expect("write legacy cache"); + + assert!( + BytecodeCache::load(&path).is_none(), + "unversioned legacy bincode must be treated as a cache miss" + ); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn load_missing_file_is_none() { assert!(BytecodeCache::load(std::path::Path::new("/nonexistent/bytecodes.bin")).is_none()); diff --git a/src/cache/metadata.rs b/src/cache/metadata.rs index 6ab9472..670de08 100644 --- a/src/cache/metadata.rs +++ b/src/cache/metadata.rs @@ -12,10 +12,14 @@ use std::path::{Path, PathBuf}; use alloy_primitives::{Address, B256, U256}; use anyhow::Result; use serde::{Deserialize, Serialize}; -use tracing::warn; use std::collections::HashSet; +use super::versioned; + +const IMMUTABLE_CACHE_MAGIC: &[u8; 8] = b"EFCMETA\0"; +const IMMUTABLE_CACHE_VERSION: u32 = 1; + /// Configuration for disk-based caching of EVM state. /// /// Enables on-disk persistence of fetched fork state. Cache files are laid out @@ -158,18 +162,17 @@ pub struct ImmutableDataCache { impl ImmutableDataCache { /// Load immutable data cache from disk (binary format). /// - /// Returns `None` if `path` cannot be read or the contents are not valid - /// bincode for this type (a parse failure is logged at `warn` level and - /// swallowed). Callers should treat `None` as "no cache yet" and start fresh. - /// - /// Note: the on-disk format is bincode with no version header, so a cache - /// written by an incompatible build deserializes as a parse failure (`None`) - /// rather than being migrated. + /// Returns `None` if `path` cannot be read, fails the magic/version check, or + /// the payload is not valid bincode for this type. Callers should treat + /// `None` as "no cache yet" and start fresh. pub fn load(path: &Path) -> Option { let data = std::fs::read(path).ok()?; - bincode::deserialize(&data) - .inspect_err(|e| warn!("Failed to parse immutable data cache (bincode): {}", e)) - .ok() + versioned::decode( + &data, + IMMUTABLE_CACHE_MAGIC, + IMMUTABLE_CACHE_VERSION, + "immutable data cache", + ) } /// Save immutable data cache to disk (binary format). @@ -185,7 +188,12 @@ impl ImmutableDataCache { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let data = bincode::serialize(self)?; + let data = versioned::encode( + IMMUTABLE_CACHE_MAGIC, + IMMUTABLE_CACHE_VERSION, + self, + "immutable data cache", + )?; std::fs::write(path, data)?; Ok(()) } diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 7387e82..5ae04a2 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -8,6 +8,7 @@ pub mod snapshot; mod storage_keys; #[cfg(feature = "protocols")] mod tick_snapshot; +mod versioned; pub use binary_state::{load_binary_state, save_binary_state}; pub use metadata::{ @@ -50,7 +51,7 @@ use alloy_primitives::{Address, B256, Bytes, I256, Log, TxKind, U256, keccak256} use alloy_provider::{Provider, network::AnyNetwork}; use alloy_rpc_types_eth::TransactionRequest; use alloy_sol_types::{SolCall, SolValue, sol}; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use foundry_fork_db::{BlockchainDb, SharedBackend, cache::BlockchainDbMeta}; use revm::{ Context, ExecuteCommitEvm, ExecuteEvm, InspectEvm, MainBuilder, MainContext, @@ -67,8 +68,8 @@ use crate::errors::{SimError, SimulationError, SimulationResult}; use crate::freshness::SlotChange; use crate::inspector::TransferInspector; use crate::state_update::{ - AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedBalanceDelta, SkippedDelta, - SkippedMask, SlotDelta, StateDiff, StateUpdate, + AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedAccountPatch, SkippedBalanceDelta, + SkippedDelta, SkippedMask, SlotDelta, StateDiff, StateUpdate, }; use bytecode::BytecodeCache; @@ -183,6 +184,10 @@ fn write_slot_into( } } +fn account_patch_is_empty(patch: &AccountPatch) -> bool { + patch.balance.is_none() && patch.nonce.is_none() && patch.code.is_none() +} + static CACHE_SPEED_MODE: AtomicU8 = AtomicU8::new(CacheSpeedMode::Slow as u8); /// Runtime tuning profile for cache-side batch storage fetches. @@ -300,6 +305,7 @@ pub struct EvmCacheBuilder

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

EvmCacheBuilder

@@ -313,6 +319,7 @@ where block: None, cache_config: None, spec_id: SpecId::CANCUN, + shared_memory_capacity: SharedMemoryCapacity::default(), } } @@ -354,9 +361,29 @@ where self } + /// Set how much EVM shared memory to pre-allocate per simulation context. + /// + /// Defaults to [`SharedMemoryCapacity::Fixed`] with `64 * 1024` bytes + /// (65,536 bytes). + /// Use `Fixed(n)` to pin a size, or [`SharedMemoryCapacity::Auto`] to size it + /// from the chain state loaded at [`build`](Self::build) time (e.g. a bincode + /// state file supplied via [`cache_config`](Self::cache_config)). See + /// [`SharedMemoryCapacity`] for the trade-offs. + pub fn shared_memory_capacity(mut self, capacity: SharedMemoryCapacity) -> Self { + self.shared_memory_capacity = capacity; + self + } + /// Build the [`EvmCache`], fetching the pinned block's header for context. pub async fn build(self) -> EvmCache { - EvmCache::with_cache(self.provider, self.block, self.cache_config, self.spec_id).await + EvmCache::with_cache_capacity( + self.provider, + self.block, + self.cache_config, + self.spec_id, + self.shared_memory_capacity, + ) + .await } } @@ -368,11 +395,70 @@ type InspectorCacheEvm<'a, INSP> = revm::MainnetEvm< INSP, >; -/// Default initial capacity for shared memory buffer. -/// Set to 64KB based on profiling (16x the REVM default of 4KB). -/// This eliminates reallocation during typical simulations with headroom. +/// Default initial capacity for the EVM shared-memory (working-memory) buffer. +/// 64 KiB (65,536 bytes), chosen from profiling a state-heavy workload (16x the +/// revm default of 4 KiB) so simulations rarely reallocate. Exposed for tuning via +/// [`SharedMemoryCapacity`]. const DEFAULT_SHARED_MEMORY_CAPACITY: usize = 64 * 1024; +/// How much EVM shared memory (per-context working memory) to pre-allocate for +/// simulations. +/// +/// revm grows its shared memory on demand during execution; pre-allocating just +/// avoids repeated reallocations when simulations touch a lot of memory — the +/// original motivation was a state-heavy workload where resizing was hot. The +/// trade-off cuts both ways: a wide parallel fan-out of *small* simulations pays +/// this much memory per overlay, so general users may want a smaller `Fixed` size, +/// while state-heavy users can raise it or let it auto-size from the loaded state. +/// +/// The default is `Fixed(64 * 1024)` (65,536 bytes). Configure it on +/// [`EvmCacheBuilder::shared_memory_capacity`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SharedMemoryCapacity { + /// Pre-allocate exactly this many bytes. The [`Default`] is + /// `Fixed(64 * 1024)`. + Fixed(usize), + /// Size the buffer from the amount of chain state loaded into the cache at + /// construction (e.g. from a bincode state file via + /// [`CacheConfig`]/[`EvmCacheBuilder::cache_config`]), clamped to a sane + /// floor/ceiling. Falls back to the floor when nothing is loaded. + /// + /// This is a heuristic proxy — persisted state size loosely correlates with the + /// working-set size of simulations over it, not an exact peak-memory model. Use + /// `Fixed` when you have profiled your workload. + Auto, +} + +impl Default for SharedMemoryCapacity { + fn default() -> Self { + Self::Fixed(DEFAULT_SHARED_MEMORY_CAPACITY) + } +} + +impl SharedMemoryCapacity { + /// Floor for [`Auto`](Self::Auto) (and the default fixed size): 64 KiB + /// (65,536 bytes). + pub const MIN_AUTO: usize = DEFAULT_SHARED_MEMORY_CAPACITY; + /// Ceiling for [`Auto`](Self::Auto): 4 MiB. A simulation that needs more than + /// this still works — revm grows the buffer past it on demand. + pub const MAX_AUTO: usize = 4 * 1024 * 1024; + /// Heuristic proxy: bytes of pre-allocated working memory per loaded storage + /// slot. Tune if profiling warrants. + const AUTO_BYTES_PER_SLOT: usize = 16; + + /// Resolve to a concrete byte capacity. `loaded_slots` is the number of layer-2 + /// storage slots present in the cache at construction (0 when nothing is + /// loaded); it is consulted only for [`Auto`](Self::Auto). + pub(crate) fn resolve(self, loaded_slots: usize) -> usize { + match self { + Self::Fixed(bytes) => bytes, + Self::Auto => loaded_slots + .saturating_mul(Self::AUTO_BYTES_PER_SLOT) + .clamp(Self::MIN_AUTO, Self::MAX_AUTO), + } + } +} + /// EVM cache with lazy-loading RPC backend. /// /// Uses `foundry-fork-db` for intelligent caching and request deduplication. @@ -432,6 +518,32 @@ pub struct EvmCache { /// layer hardfork for accurate gas accounting. Configured per-chain via `evm_spec` /// in `chains.toml`. spec_id: SpecId, + /// Memoized, `Arc`-shared flatten of the cold layer-2 index, reused across + /// successive [`create_snapshot`](Self::create_snapshot) calls (Pillar A). + /// `None` until the first snapshot. Rebuilt copy-on-write by + /// [`refresh_base`](Self::refresh_base); never mutated in place once shared. + /// Not part of any public API and not serialized. + base: Option>, + /// Layer-2 addresses changed since `base` was built, folded into the next base + /// rebuild. Populated by the base-invalidation sites (write-through, batch + /// injects, layer-2 seeding, purges). Not serialized. + base_dirty: HashSet

, + /// When set, the next [`refresh_base`](Self::refresh_base) rebuilds the base + /// from scratch. Set by [`set_block`](Self::set_block) / + /// [`repin_to_block`](Self::repin_to_block), which replace layer 2 wholesale. + /// Not serialized. + base_full_rebuild: bool, + /// Per-account layer-2 slot count at the last base build, used by + /// [`refresh_base`](Self::refresh_base)'s `O(accounts)` length-scan to detect + /// uncontrolled lazy-fetch growth that bypasses the write funnel. Not + /// serialized. + base_storage_lens: HashMap, + /// Resolved per-context EVM shared-memory pre-allocation (bytes), from the + /// [`SharedMemoryCapacity`] at construction (resolving `Auto` against the loaded + /// state). Propagated to each [`EvmSnapshot`] so snapshot-backed overlays + /// pre-allocate the same amount. See + /// [`shared_memory_capacity`](Self::shared_memory_capacity). + shared_memory_capacity: usize, } /// Outcome of a balance-delta-tracking simulation. @@ -566,6 +678,31 @@ impl EvmCache { cache_config: Option, spec_id: SpecId, ) -> Self + where + P: Provider + 'static, + { + Self::with_cache_capacity( + provider, + block, + cache_config, + spec_id, + SharedMemoryCapacity::default(), + ) + .await + } + + /// Like [`with_cache`](Self::with_cache) but takes an explicit + /// [`SharedMemoryCapacity`] controlling per-context EVM working-memory + /// pre-allocation. This is what [`EvmCacheBuilder::build`] calls; prefer the + /// builder. With [`SharedMemoryCapacity::Auto`] the buffer is sized from the + /// layer-2 storage loaded at construction (e.g. a bincode state file). + pub async fn with_cache_capacity

( + provider: Arc

, + block: Option, + cache_config: Option, + spec_id: SpecId, + shared_memory_capacity: SharedMemoryCapacity, + ) -> Self where P: Provider + 'static, { @@ -884,6 +1021,20 @@ impl EvmCache { // 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. + let loaded_slots = match shared_memory_capacity { + SharedMemoryCapacity::Auto => blockchain_db + .storage() + .read() + .values() + .map(|s| s.len()) + .sum(), + SharedMemoryCapacity::Fixed(_) => 0, + }; + let shared_memory_capacity = shared_memory_capacity.resolve(loaded_slots); + Self { backend, blockchain_db, @@ -901,14 +1052,17 @@ impl EvmCache { coinbase, prevrandao, block_gas_limit, - shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity( - DEFAULT_SHARED_MEMORY_CAPACITY, - ))), + shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity(shared_memory_capacity))), rpc_caller: Some(rpc_caller), storage_batch_fetcher: Some(storage_batch_fetcher), batch_block_id, erc20_balance_slots: HashMap::new(), spec_id, + base: None, + base_dirty: HashSet::new(), + base_full_rebuild: false, + base_storage_lens: HashMap::new(), + shared_memory_capacity, } } @@ -983,6 +1137,11 @@ impl EvmCache { batch_block_id: Arc::new(Mutex::new(block.unwrap_or_default())), erc20_balance_slots: HashMap::new(), spec_id, + base: None, + base_dirty: HashSet::new(), + base_full_rebuild: false, + base_storage_lens: HashMap::new(), + shared_memory_capacity: DEFAULT_SHARED_MEMORY_CAPACITY, } } @@ -996,56 +1155,59 @@ impl EvmCache { /// /// Call this after loading AMMs and running simulations to speed up subsequent runs. /// The cache is also automatically flushed when the EvmCache is dropped. - pub fn flush(&self) { + pub fn flush(&self) -> Result<()> { if let Some(cfg) = &self.cache_config { // Save EVM state to binary cache (bincode format) let binary_path = cfg.binary_state_cache_path(); - binary_state::save_binary_state(&self.blockchain_db, &binary_path); + binary_state::save_binary_state(&self.blockchain_db, &binary_path) + .with_context(|| format!("failed to save binary state cache to {binary_path:?}"))?; // Save bytecode cache let bytecode_path = cfg.bytecode_cache_path(); let mut bytecode_cache = BytecodeCache::load(&bytecode_path).unwrap_or_default(); bytecode_cache.merge_from_db(&self.blockchain_db); - if let Err(e) = bytecode_cache.save(&bytecode_path) { - warn!(error = %e, "Failed to save bytecode cache"); - } else { - debug!( - count = bytecode_cache.contracts.len(), - path = ?bytecode_path, - "Updated bytecode cache (binary format)" - ); - } + bytecode_cache + .save(&bytecode_path) + .with_context(|| format!("failed to save bytecode cache to {bytecode_path:?}"))?; + debug!( + count = bytecode_cache.contracts.len(), + path = ?bytecode_path, + "Updated bytecode cache (binary format)" + ); // Save the immutable data cache let immutable_path = cfg.immutable_cache_path(); - if let Err(e) = self.immutable_cache.save(&immutable_path) { - warn!(error = %e, "Failed to save immutable data cache"); - } else { - debug!( - token_decimals = self.immutable_cache.token_decimals.len(), - v2_pools = self.immutable_cache.v2_pools.len(), - v3_pools = self.immutable_cache.v3_pools.len(), - balancer_pools = self.immutable_cache.balancer_pools.len(), - path = ?immutable_path, - "Updated immutable data cache" - ); - } + self.immutable_cache + .save(&immutable_path) + .with_context(|| { + format!("failed to save immutable data cache to {immutable_path:?}") + })?; + debug!( + token_decimals = self.immutable_cache.token_decimals.len(), + v2_pools = self.immutable_cache.v2_pools.len(), + v3_pools = self.immutable_cache.v3_pools.len(), + balancer_pools = self.immutable_cache.balancer_pools.len(), + path = ?immutable_path, + "Updated immutable data cache" + ); // Save the V3 tick snapshot cache (needed for liquidity validation) #[cfg(feature = "protocols")] { let tick_snapshot_path = cfg.tick_snapshot_cache_path(); - if let Err(e) = self.tick_snapshot_cache.save(&tick_snapshot_path) { - warn!(error = %e, "Failed to save V3 tick snapshot cache"); - } else { - debug!( - snapshots = self.tick_snapshot_cache.len(), - path = ?tick_snapshot_path, - "Updated V3 tick snapshot cache" - ); - } + self.tick_snapshot_cache + .save(&tick_snapshot_path) + .with_context(|| { + format!("failed to save V3 tick snapshot cache to {tick_snapshot_path:?}") + })?; + debug!( + snapshots = self.tick_snapshot_cache.len(), + path = ?tick_snapshot_path, + "Updated V3 tick snapshot cache" + ); } } + Ok(()) } /// Get the cache configuration, if any. @@ -1057,24 +1219,73 @@ impl EvmCache { self.cache_config.as_ref() } - /// Get a reference to the underlying [`BlockchainDb`] (the layer-2 backend - /// store of accounts, storage, and bytecodes). + /// Run a synchronous direct mutation against the underlying [`BlockchainDb`] + /// and invalidate the memoized snapshot base afterwards. + /// + /// This is the preferred escape hatch for unavoidable layer-2 map writes such + /// as `accounts().write().insert(...)` or `storage().write().insert(...)`. + /// The closure still bypasses the CacheDB overlay and the normal write funnel, + /// so use higher-level mutators when they can express the change. Unlike + /// [`unchecked_blockchain_db`](Self::unchecked_blockchain_db), this wrapper + /// keeps the copy-on-write snapshot base honest automatically after in-place + /// overwrites whose map cardinality does not change. + pub fn with_blockchain_db_mut(&mut self, f: impl FnOnce(&BlockchainDb) -> R) -> R { + let result = f(&self.blockchain_db); + self.invalidate_base(); + result + } + + /// Get an unchecked reference to the underlying [`BlockchainDb`] (the layer-2 + /// backend store of accounts, storage, and bytecodes). /// /// This exposes an internal store and bypasses the cache's two-layer - /// consistency model: reads here see only the backend layer, not the - /// CacheDB overlay, and any writes performed through it skip the overlay. - /// Prefer the higher-level accessors; use with care. - pub fn blockchain_db(&self) -> &BlockchainDb { + /// consistency model: reads here see only the backend layer, not the CacheDB + /// overlay, and any writes performed through it skip the overlay. Prefer + /// higher-level accessors or [`with_blockchain_db_mut`](Self::with_blockchain_db_mut) + /// for direct synchronous writes. + /// + /// # Snapshot base + /// Writing layer 2 directly through this unchecked handle also bypasses the + /// memoized copy-on-write snapshot base (Pillar A). The next + /// [`create_snapshot`](Self::create_snapshot) only performs a count/absence + /// growth scan over layer 2, which catches lazy RPC-populated accounts/slots + /// because that path only appends at a fixed block. It does **not** catch + /// direct in-place changes where cardinality is unchanged: overwriting an + /// existing storage slot, or changing an existing account's info/code/balance + /// without adding a new account, can leave a stale snapshot base. After such a + /// direct write, call + /// [`invalidate_snapshot_base`](Self::invalidate_snapshot_base) (or re-pin via + /// [`set_block`](Self::set_block)) before the next snapshot. Writes via the + /// crate's own mutators (`inject_storage_batch`, `apply_update`, the `inject_*` + /// helpers, the purges) keep the base honest automatically. + pub fn unchecked_blockchain_db(&self) -> &BlockchainDb { &self.blockchain_db } - /// Get a reference to the underlying [`SharedBackend`] (the lazy RPC-backed - /// fetcher shared across clones). + /// Get an unchecked reference to the underlying [`SharedBackend`] (the lazy + /// RPC-backed fetcher shared across clones). /// - /// This exposes an internal and bypasses the cache's two-layer consistency + /// This exposes an internal handle and bypasses the cache's two-layer consistency /// model: it reads/fetches directly without consulting the CacheDB overlay. /// Prefer the higher-level accessors; use with care. - pub fn backend(&self) -> &SharedBackend { + /// + /// # Snapshot base + /// Lazy RPC fetches through this backend only append missing accounts/slots at + /// the pinned block, so the snapshot growth scan catches them without an + /// explicit invalidation. Direct `SharedBackend::insert_or_update_storage` / + /// `insert_or_update_address` calls are different: they enqueue a background + /// handler request that can rewrite layer-2 entries **in place**, leaving the + /// memoized copy-on-write base stale at an unchanged slot/account count. + /// + /// If you use those helpers directly, first synchronize with the backend + /// handler by reading back the updated account/slot through `SharedBackend` + /// (for example via `basic_ref` / `storage_ref`), then call + /// [`invalidate_snapshot_base`](Self::invalidate_snapshot_base) before the next + /// [`create_snapshot`](Self::create_snapshot). Calling + /// `invalidate_snapshot_base` immediately after `insert_or_update_*` is not, by + /// itself, a guarantee that the queued update has been applied before the next + /// snapshot. + pub fn unchecked_backend(&self) -> &SharedBackend { &self.backend } @@ -1124,15 +1335,26 @@ impl EvmCache { self.storage_batch_fetcher.as_ref() } - /// Inject batch-fetched storage values directly into BlockchainDb. + /// Inject batch-fetched storage values directly into BlockchainDb (layer 2). /// /// This bypasses SharedBackend and makes values available for subsequent /// `storage_ref()` calls and EVM SLOADs. Used after `StorageBatchFetchFn` /// returns results to populate the cache in bulk. - pub fn inject_storage_batch(&self, results: &[(Address, U256, U256)]) { - let mut storage = self.blockchain_db.storage().write(); - for &(addr, slot, value) in results { - storage.entry(addr).or_default().insert(slot, value); + /// + /// Takes `&mut self` (as of Pillar A) so it can mark each touched address dirty + /// for the memoized copy-on-write base; the write itself is still a direct + /// layer-2 backend write. Overwriting an existing slot at an unchanged slot + /// count is invalidated here too, since the `refresh_base` growth scan only + /// catches length changes. + pub fn inject_storage_batch(&mut self, results: &[(Address, U256, U256)]) { + { + let mut storage = self.blockchain_db.storage().write(); + for &(addr, slot, value) in results { + storage.entry(addr).or_default().insert(slot, value); + } + } + for &(addr, _, _) in results { + self.mark_base_dirty(addr); } } @@ -1192,28 +1414,23 @@ impl EvmCache { /// layers (no RPC), apply each `Some` patch field (recomputing the code hash /// when `code` is set), then write through with the same layer policy. /// Records an [`AccountChange`] with `Some((old, new))` only for fields - /// that changed. + /// that changed. If the account is cold (absent from both layers), apply + /// nothing and surface a [`SkippedAccountPatch`] in + /// `diff.skipped_accounts`. + /// - [`StateUpdate::AccountUpsert`] — same patch semantics, but intentionally + /// materializes a cold/default account when absent from both layers. /// - [`StateUpdate::Purge`] — dispatch to the matching purge layer logic and /// record a [`PurgeRecord`]. /// /// # Warning — relative updates can be skipped /// - /// A relative [`SlotDelta`](StateUpdate::SlotDelta) / - /// [`BalanceDelta`](StateUpdate::BalanceDelta) targeting a **cold** address is - /// *dropped, not applied* (applying it against an unknown base would corrupt - /// state). Because a skip produces no change, it is invisible to the - /// changes-only [`StateDiff::is_empty`] / [`StateDiff::len`] success check, so - /// after applying relative updates the caller **must** inspect - /// [`StateDiff::has_skipped`] (or `diff.skipped` / `diff.skipped_balances`) and - /// fetch+seed the cold target — a silently-dropped balance update can break - /// conservation. - /// - /// # Warning — cold absolute `Account` patches - /// - /// A partial absolute [`StateUpdate::Account`] patch on an address absent from - /// both layers writes default nonce/code through the backend as authoritative, - /// masking a real RPC fetch. Fetch+seed the account first, or use - /// [`StateUpdate::BalanceDelta`] for relative native-balance tracking. + /// A cold-aware update targeting a **cold** address is *dropped, not applied* + /// unless it is an explicit [`StateUpdate::AccountUpsert`]. Because a skip + /// produces no change, it is invisible to the changes-only + /// [`StateDiff::is_empty`] / [`StateDiff::len`] success check, so after + /// applying cold-aware updates the caller **must** inspect + /// [`StateDiff::has_skipped`] (or the `skipped_*` fields) and fetch+seed the + /// cold target. /// /// ```no_run /// # use alloy_primitives::{Address, U256}; @@ -1305,7 +1522,17 @@ impl EvmCache { } } StateUpdate::Account { address, patch } => { - if let Some(change) = self.apply_account_patch(*address, patch) { + match self.apply_account_patch(*address, patch, false) { + Ok(Some(change)) => diff.accounts.push(change), + Ok(None) => {} + Err(skipped) => diff.skipped_accounts.push(skipped), + } + } + StateUpdate::AccountUpsert { address, patch } => { + if let Some(change) = self + .apply_account_patch(*address, patch, true) + .expect("AccountUpsert never skips cold account patches") + { diff.accounts.push(change); } } @@ -1384,7 +1611,10 @@ impl EvmCache { fn apply_slot_run(&mut self, run: &[StateUpdate], diff: &mut StateDiff) { // Borrow the two layers as disjoint fields: the backend storage guard // (layer 2) held for the whole run, and the overlay accounts map (layer 1, - // lock-free). + // lock-free). Base invalidation is deferred until after the guard is + // dropped (it needs `&mut self`): collect the layer-2 addresses written + // here and mark them dirty below. + let mut dirtied: Vec

= Vec::new(); let overlay = &mut self.db.cache.accounts; let mut storage = self.blockchain_db.storage().write(); @@ -1425,6 +1655,9 @@ impl EvmCache { }; write_slot_into(overlay, &mut storage, address, slot, new); + // Layer 2 was written for this address → it must be re-folded into the + // memoized base. Mirrors `write_slot_through`'s `mark_base_dirty`. + dirtied.push(address); if old != new { diff.slots.push(SlotChange { address, @@ -1434,6 +1667,12 @@ impl EvmCache { }); } } + + // Drop the storage write-guard before taking `&mut self` for invalidation. + drop(storage); + for address in dirtied { + self.mark_base_dirty(address); + } } /// Write-through a single storage slot (§5.1). Returns a [`SlotChange`] iff @@ -1475,6 +1714,10 @@ impl EvmCache { if let Some(db_account) = self.db.cache.accounts.get_mut(&address) { db_account.storage.insert(slot, value); } + + // Layer 2 changed → invalidate the memoized base for this address (D2: + // over-invalidation when also shadowed by layer 1 is safe). + self.mark_base_dirty(address); } /// Read-modify-write one storage slot through a caller-supplied transform. @@ -1661,6 +1904,9 @@ impl EvmCache { if overlay_present { self.db.insert_account_info(address, info); } + // Layer-2 account info changed → invalidate the memoized base for this + // address (D2: over-invalidation when also in layer 1 is safe). + self.mark_base_dirty(address); } /// Apply a partial [`AccountPatch`] write-through (§5.2). Returns an @@ -1669,10 +1915,22 @@ impl EvmCache { &mut self, address: Address, patch: &AccountPatch, - ) -> Option { - // 1. Current info from the cached layers only (overlay ▸ backend ▸ - // default). No RPC: apply is a write, not a fetch. - let mut info = self.loaded_account_info(address).unwrap_or_default(); + allow_cold_upsert: bool, + ) -> std::result::Result, SkippedAccountPatch> { + // 1. Current info from the cached layers only (overlay ▸ backend). No RPC: + // apply is a write, not a fetch. A partial patch on a cold account is + // skipped unless the caller explicitly chose AccountUpsert. + let mut info = match self.loaded_account_info(address) { + Some(info) => info, + None if account_patch_is_empty(patch) => return Ok(None), + None if allow_cold_upsert => AccountInfo::default(), + None => { + return Err(SkippedAccountPatch { + address, + patch: patch.clone(), + }); + } + }; let old_balance = info.balance; let old_nonce = info.nonce; @@ -1703,14 +1961,14 @@ impl EvmCache { code_hash: (old_code_hash != info.code_hash).then_some((old_code_hash, info.code_hash)), }; if change.balance.is_none() && change.nonce.is_none() && change.code_hash.is_none() { - return None; + return Ok(None); } // 4. Write-through, mirroring the slot policy: backend always; overlay // only if an overlay account already exists (do not materialize one). self.write_account_info_through(address, info); - Some(change) + Ok(Some(change)) } /// Dispatch a [`PurgeScope`] to the matching layer logic (§5.3), returning a @@ -1938,6 +2196,8 @@ impl EvmCache { "purged account from both cache layers" ); } + // Layer 2 (account + storage) changed for this address → invalidate base. + self.mark_base_dirty(addr); (slots_removed, account_removed) } @@ -1985,24 +2245,304 @@ impl EvmCache { } } - /// Create an immutable snapshot of the current EVM state for cross-thread - /// fan-out. - /// - /// Merges both layers (CacheDB overlay + BlockchainDb backend) into a - /// single flat HashMap. The snapshot is `Send + Sync` and can be shared - /// across threads via `Arc`. + /// Create an immutable, `Send + Sync` snapshot of the current EVM state for + /// cross-thread fan-out (the copy-on-write two-tier view, Pillar A). /// - /// CacheDB overlay values take precedence over BlockchainDb values. - /// Use with [`EvmOverlay`] for parallel simulation. + /// Rather than deep-copying both layers, this memoizes the cold layer-2 + /// (`BlockchainDb`) index as an `Arc`-shared base — reused as a cheap + /// `Arc::clone` when layer 2 is unchanged, rebuilt copy-on-write only for the + /// addresses that changed — and folds the hot layer-1 (`CacheDB` overlay) + /// delta over it. Layer-1 values shadow the base on reads, reproducing the + /// live cache's layered semantics; the resulting [`EvmSnapshot`] is shared + /// across threads via `Arc`. Its cost tracks *changed* state, not *total* + /// state. (The retained [`create_snapshot_deep_clone`](Self::create_snapshot_deep_clone) + /// is the read-equivalent O(total) reference, kept for benchmarking/testing.) /// - /// For cheap same-thread save/restore of just the overlay, prefer + /// Takes `&mut self` because it refreshes and memoizes the base. For cheap + /// same-thread save/restore of just the overlay, prefer /// [`snapshot`](Self::snapshot) / [`restore`](Self::restore) instead. - pub fn create_snapshot(&self) -> Arc { + pub fn create_snapshot(&mut self) -> Arc { + // 1. Refresh / memoize the cold layer-2 base, then take a cheap Arc handle + // (O(1) when layer 2 is unchanged since the last snapshot). + self.refresh_base(); + let base = Arc::clone(self.base.as_ref().expect("refresh_base sets base")); + + // 2. Fold layer 1 (the hot CacheDB overlay) into the snapshot's overlay + // maps + cleared/not-existing sets, applying the same classification as + // the legacy flatten (O(layer-1)). + let mut overlay_accounts = HashMap::new(); + let mut overlay_storage = HashMap::new(); + let mut overlay_code_by_hash = HashMap::new(); + let mut storage_cleared = std::collections::HashSet::new(); + let mut accounts_not_existing = std::collections::HashSet::new(); + for (addr, db_account) in &self.db.cache.accounts { + let not_existing = matches!(db_account.account_state, AccountState::NotExisting); + let cleared = + not_existing || matches!(db_account.account_state, AccountState::StorageCleared); + + // Account info. Mirror revm `DbAccount::info()` / `loaded_account_info`: + // a NotExisting overlay account is absent to the EVM (`basic` returns + // None), so it must NOT contribute info/code to the overlay — and + // `accounts_not_existing` makes the read short-circuit to None before + // ever consulting the base. + if not_existing { + accounts_not_existing.insert(*addr); + } else { + if let Some(code) = &db_account.info.code { + overlay_code_by_hash.insert(db_account.info.code_hash, code.clone()); + } + overlay_accounts.insert(*addr, db_account.info.clone()); + } + + // Storage. A StorageCleared/NotExisting account's storage is locally + // complete: the overlay holds ONLY its own slots (so a cleared account + // ALWAYS gets an `overlay_storage` entry, possibly empty), an absent + // slot reads ZERO via `storage_cleared`, and the base is never consulted + // for it. A non-cleared overlay account contributes its slots; absent + // slots fall through to the base on a read. + if cleared { + storage_cleared.insert(*addr); + let account_storage: HashMap = + db_account.storage.iter().map(|(k, v)| (*k, *v)).collect(); + overlay_storage.insert(*addr, account_storage); + } else if !db_account.storage.is_empty() { + let account_storage = overlay_storage.entry(*addr).or_default(); + for (slot, value) in &db_account.storage { + account_storage.insert(*slot, *value); + } + } + } + + Arc::new(snapshot::EvmSnapshot { + base, + overlay_accounts, + overlay_storage, + overlay_code_by_hash, + storage_cleared, + accounts_not_existing, + block_hashes: HashMap::new(), + block_number: self.block_number, + basefee: self.basefee, + coinbase: self.coinbase, + prevrandao: self.prevrandao, + gas_limit: self.block_gas_limit, + chain_id: self.chain_id, + timestamp: self.timestamp_override, + spec_id: self.spec_id, + shared_memory_capacity: self.shared_memory_capacity, + }) + } + + /// Force the next [`create_snapshot`](Self::create_snapshot) to rebuild the + /// memoized copy-on-write base from scratch (Pillar A). + /// + /// The crate's own mutators keep the base honest automatically. This is the + /// **escape-hatch re-honest hook**: call it after writing layer 2 directly + /// through [`unchecked_blockchain_db`](Self::unchecked_blockchain_db) or + /// [`unchecked_backend`](Self::unchecked_backend) — those bypass the write + /// funnel, and in-place changes at unchanged cardinality are invisible to the + /// snapshot growth scan. + /// That includes overwriting an existing storage slot and changing an existing + /// account's info/code/balance without adding a new account. Lazy RPC-populated + /// data does not need this call because it only appends accounts/slots, which + /// the growth scan catches. + /// + /// When using `SharedBackend::insert_or_update_*` through + /// [`unchecked_backend`](Self::unchecked_backend), remember those helpers only + /// enqueue a background update. Synchronize/read back the update through + /// `SharedBackend` before the next snapshot; `invalidate_snapshot_base` alone + /// is not a backend-handler synchronization point. Once the direct write is + /// present, calling this before the next snapshot guarantees it reflects that + /// write rather than a stale memoized value. Over-invalidation is always safe + /// (Decision D2); the only cost is one full base rebuild on the next snapshot. + pub fn invalidate_snapshot_base(&mut self) { + self.invalidate_base(); + } + + /// Refresh the memoized cold layer-2 [`BaseState`](snapshot::BaseState), + /// reusing the previous `Arc` wherever layer 2 is unchanged (Pillar A). + /// + /// Called at the top of [`create_snapshot`](Self::create_snapshot). It never + /// mutates an `Arc` that may already be shared with a live + /// snapshot: on any change it builds a *new* `BaseState` that shares the `Arc` + /// handles of unchanged accounts and rebuilds only the changed ones + /// (copy-on-write). + /// + /// Algorithm (see `docs/phase-5-spec.md` §2.3): + /// 1. **Full rebuild** when there is no base yet or `base_full_rebuild` is set + /// (`set_block` / re-pin replaced layer 2): flatten all of layer 2. + /// 2. **Detect uncontrolled growth**: a lazy RPC fetch / prefetch can write + /// layer 2 from inside `foundry-fork-db`, bypassing our write funnel. An + /// `O(accounts)` length-scan over the current layer-2 storage/accounts marks + /// any address whose slot count differs from the recorded length, or any + /// account absent from the base, as dirty. + /// 3. **Nothing dirty** → reuse the existing `Arc` unchanged (the + /// common hot-loop case; the base side of `create_snapshot` is then O(1)). + /// 4. **Some addresses dirty** → build a new `BaseState` sharing the `Arc`s of + /// unchanged accounts and rebuilding only the dirty ones. + fn refresh_base(&mut self) { + // Case 1: full rebuild. + if self.base.is_none() || self.base_full_rebuild { + self.base = Some(Arc::new(self.build_base_full())); + self.base_dirty.clear(); + self.base_full_rebuild = false; + return; + } + + // Case 2: detect uncontrolled layer-2 growth via an O(accounts) length scan + // (NOT an O(slots) value scan). Any address whose slot count changed, or any + // account that newly appeared in layer 2, is folded into `base_dirty`. + // + // LOAD-BEARING INVARIANT: the count/absence scan is sufficient *only* because + // the one uncontrolled layer-2 writer — the foundry-fork-db `SharedBackend` + // lazy fetch — is append-only at a fixed block (its request handler answers an + // already-cached account/slot from the store and only inserts on a miss; it + // never overwrites an existing entry in place). So an uncontrolled fetch can + // only add a new account (caught by the absence check) or a new slot (caught + // by the count check). An in-place value overwrite at unchanged length is + // invisible here; the controlled writers therefore call `mark_base_dirty` + // explicitly, and a direct out-of-band write via `unchecked_blockchain_db()`/`unchecked_backend()` + // must call `invalidate_snapshot_base`. If a future foundry-fork-db bump makes + // the lazy path overwrite-in-place, this scan must gain a value/version check. + { + let db_storage = self.blockchain_db.storage().read(); + for (addr, slots) in db_storage.iter() { + if self.base_storage_lens.get(addr).copied() != Some(slots.len()) { + self.base_dirty.insert(*addr); + } + } + let db_accounts = self.blockchain_db.accounts().read(); + let base = self.base.as_ref().expect("base present in case 2/3/4"); + for addr in db_accounts.keys() { + if !base.accounts.contains_key(addr) { + self.base_dirty.insert(*addr); + } + } + } + + // Case 3: nothing changed → reuse the existing Arc unchanged. + if self.base_dirty.is_empty() { + return; + } + + // Case 4: rebuild copy-on-write — clone the outer maps (Arc handles + + // AccountInfo, no per-slot copy) and rebuild only the dirty addresses. + let prev = self.base.as_ref().expect("base present in case 4"); + let mut accounts = prev.accounts.clone(); + let mut storage = prev.storage.clone(); + + let db_accounts = self.blockchain_db.accounts().read(); + let db_storage = self.blockchain_db.storage().read(); + for addr in self.base_dirty.iter().copied() { + // Account info: refresh from the current layer-2 account, or drop it if + // the account no longer exists in layer 2 (e.g. after a purge). + match db_accounts.get(&addr) { + Some(info) => { + accounts.insert(addr, info.clone()); + } + None => { + accounts.remove(&addr); + } + } + + // Storage: rebuild this account's Arc from the current layer-2 + // storage, or drop it if the account has no layer-2 storage anymore. + match db_storage.get(&addr) { + Some(slots) => { + let rebuilt: HashMap = + slots.iter().map(|(k, v)| (*k, *v)).collect(); + self.base_storage_lens.insert(addr, rebuilt.len()); + storage.insert(addr, Arc::new(rebuilt)); + } + None => { + storage.remove(&addr); + self.base_storage_lens.remove(&addr); + } + } + } + drop(db_accounts); + drop(db_storage); + + // Rebuild the code index from the refreshed accounts (NOT cloned from the + // previous base): a purged or recoded dirty account must not leave a stale + // `code_by_hash` entry, which would diverge from `create_snapshot_deep_clone` + // on a direct `code_by_hash(old_hash)` lookup. Rebuilding from scratch also + // handles shared code hashes correctly (a hash survives iff some present + // account still carries it). + let code_by_hash = Self::code_index(&accounts); + + self.base = Some(Arc::new(snapshot::BaseState { + accounts, + storage, + code_by_hash, + })); + self.base_dirty.clear(); + } + + /// Build the bytecode-by-hash index from a set of (layer-2) accounts, matching + /// the deep-clone reference: a hash is present iff some account carries that + /// code inline. Rebuilt from scratch on every base (re)build so a purged or + /// recoded account never leaves a stale entry — preserving read-equivalence + /// with [`create_snapshot_deep_clone`](Self::create_snapshot_deep_clone). + fn code_index(accounts: &HashMap) -> HashMap { + accounts + .values() + .filter_map(|info| { + info.code + .as_ref() + .map(|code| (info.code_hash, code.clone())) + }) + .collect() + } + + /// Build a fresh [`BaseState`](snapshot::BaseState) by flattening all of layer + /// 2, recording `base_storage_lens`. Shared by `refresh_base`'s full-rebuild + /// path and [`create_snapshot_deep_clone`](Self::create_snapshot_deep_clone). + fn build_base_full(&mut self) -> snapshot::BaseState { let mut accounts = HashMap::new(); + { + let db_accounts = self.blockchain_db.accounts().read(); + for (addr, info) in db_accounts.iter() { + accounts.insert(*addr, info.clone()); + } + } + let code_by_hash = Self::code_index(&accounts); let mut storage = HashMap::new(); + self.base_storage_lens.clear(); + { + let db_storage = self.blockchain_db.storage().read(); + for (addr, slots) in db_storage.iter() { + let converted: HashMap = slots.iter().map(|(k, v)| (*k, *v)).collect(); + self.base_storage_lens.insert(*addr, converted.len()); + storage.insert(*addr, Arc::new(converted)); + } + } + snapshot::BaseState { + accounts, + storage, + code_by_hash, + } + } + + /// The retained deep-clone snapshot — today's full flatten, kept reachable for + /// A/B benchmarking and as the read-equivalence reference (Decision D3). + /// + /// Produces the same two-tier [`EvmSnapshot`](snapshot::EvmSnapshot) shape as + /// [`create_snapshot`](Self::create_snapshot), but with `base` set to the + /// fully-merged flatten of **both** layers and **empty** overlay maps (the + /// cleared / not-existing sets still in place). It is read-indistinguishable + /// from `create_snapshot` by construction (the `tests/cow_snapshot.rs` + /// differential gate pins this), at the cost of an O(total state) deep copy + /// every call — exactly the cost `create_snapshot` now amortizes away. + /// + /// Stays `&self`: it does not touch the memoized base. + #[doc(hidden)] + pub fn create_snapshot_deep_clone(&self) -> Arc { + let mut accounts = HashMap::new(); + let mut storage: HashMap> = HashMap::new(); let mut code_by_hash = HashMap::new(); - // 1. Load from BlockchainDb (persistent cache / Layer 2) + // 1. Load from BlockchainDb (persistent cache / Layer 2). { let db_accounts = self.blockchain_db.accounts().read(); for (addr, info) in db_accounts.iter() { @@ -2015,13 +2555,19 @@ impl EvmCache { { let db_storage = self.blockchain_db.storage().read(); for (addr, slots) in db_storage.iter() { - // Convert from DefaultHashBuilder to RandomState HashMap let converted: HashMap = slots.iter().map(|(k, v)| (*k, *v)).collect(); storage.insert(*addr, converted); } } - // 2. Overlay from CacheDB (Layer 1, takes precedence) + // 2. Overlay from CacheDB (Layer 1, takes precedence). Merge into the same + // flat maps, dropping shadowed entries, exactly as the original + // `create_snapshot` did. A cleared account's storage is routed into + // `overlay_storage` (not the base), because `EvmSnapshot::storage_value` + // only applies the cleared-as-ZERO rule for an address with an + // `overlay_storage` entry — so the cleared semantics must be expressed + // there for both snapshot constructors to read identically. + let mut overlay_storage: HashMap> = HashMap::new(); let mut storage_cleared = std::collections::HashSet::new(); let mut accounts_not_existing = std::collections::HashSet::new(); for (addr, db_account) in &self.db.cache.accounts { @@ -2029,11 +2575,6 @@ impl EvmCache { let cleared = not_existing || matches!(db_account.account_state, AccountState::StorageCleared); - // Account info. Mirror revm `DbAccount::info()` / `loaded_account_info`: - // a NotExisting overlay account is absent to the EVM (`basic` returns - // None), so it must NOT contribute info/code to the snapshot — and any - // backend-merged entry from step 1 is dropped, since loaded_account_info - // short-circuits to None before consulting the backend. if not_existing { accounts_not_existing.insert(*addr); accounts.remove(addr); @@ -2044,17 +2585,16 @@ impl EvmCache { accounts.insert(*addr, db_account.info.clone()); } - // Storage. A StorageCleared/NotExisting account's storage is locally - // complete: the snapshot holds ONLY its overlay slots (any shadowed - // backend slots are dropped) and an absent slot reads ZERO via - // `storage_cleared`, rather than falling through to the (shadowed) - // backend or an ext_db. if cleared { + // Cleared: storage is locally complete. Drop any shadowed base + // slots and keep ONLY the overlay slots, in `overlay_storage`. storage_cleared.insert(*addr); + storage.remove(addr); let account_storage: HashMap = db_account.storage.iter().map(|(k, v)| (*k, *v)).collect(); - storage.insert(*addr, account_storage); + overlay_storage.insert(*addr, account_storage); } else { + // Non-cleared: overlay slots win over base; fold them into base. let account_storage = storage.entry(*addr).or_default(); for (slot, value) in &db_account.storage { account_storage.insert(*slot, *value); @@ -2062,13 +2602,23 @@ impl EvmCache { } } - Arc::new(snapshot::EvmSnapshot { + let base = snapshot::BaseState { accounts, - storage, + storage: storage + .into_iter() + .map(|(addr, slots)| (addr, Arc::new(slots))) + .collect(), + code_by_hash, + }; + + Arc::new(snapshot::EvmSnapshot { + base: Arc::new(base), + overlay_accounts: HashMap::new(), + overlay_storage, + overlay_code_by_hash: HashMap::new(), storage_cleared, accounts_not_existing, block_hashes: HashMap::new(), - code_by_hash, block_number: self.block_number, basefee: self.basefee, coinbase: self.coinbase, @@ -2077,9 +2627,32 @@ impl EvmCache { chain_id: self.chain_id, timestamp: self.timestamp_override, spec_id: self.spec_id, + shared_memory_capacity: self.shared_memory_capacity, }) } + /// Mark a layer-2 address dirty so the next [`refresh_base`](Self::refresh_base) + /// re-folds it into the memoized base (Pillar A invalidation; see + /// `docs/phase-5-spec.md` §3). + /// + /// Called from every site that can change a layer-2 value a snapshot read + /// would surface (write-through, batch injects, layer-2 seeding, purges). + /// Over-invalidation is safe (Decision D2): marking an address that is also + /// shadowed by layer 1 just re-folds that one account. + fn mark_base_dirty(&mut self, address: Address) { + self.base_dirty.insert(address); + } + + /// Force a full rebuild of the memoized base on the next + /// [`refresh_base`](Self::refresh_base) (Pillar A invalidation). + /// + /// Used by layer-2 changes too broad to enumerate per-address efficiently + /// (multi-contract / full-storage purges, block re-pins). Coarser than + /// [`mark_base_dirty`](Self::mark_base_dirty) but always correct. + fn invalidate_base(&mut self) { + self.base_full_rebuild = true; + } + /// Update the block that RPC fetches are pinned to. /// /// This re-pins the SharedBackend and the batch storage fetcher to `block`, @@ -2089,30 +2662,41 @@ impl EvmCache { /// To prevent the EVM block context from silently diverging from the pinned /// block, when `block` is a concrete `BlockId::Number(Number(n))` this also /// updates `block_number` (the `NUMBER` opcode) to `n`. For tag-based block - /// ids (`latest`, `pending`, hashes, etc.) the height is not statically known, - /// so `block_number` is left unchanged. - /// - /// `basefee` (the `BASEFEE` opcode) is **not** refreshed here because deriving - /// it requires fetching the block header, which this synchronous method cannot - /// do. Callers that change blocks should refresh it via - /// [`set_block_context`](Self::set_block_context) (e.g. after fetching the new - /// header). Prefer [`repin_to_block`](Self::repin_to_block) when re-pinning to + /// ids (`latest`, `pending`, hashes, etc.) and `None`, the height is not + /// statically known, so `block_number` is cleared. + /// + /// `basefee` (the `BASEFEE` opcode) is **cleared on every block change** and + /// on every non-concrete tag/hash/`None` pin call because deriving it requires + /// fetching the block header, which this synchronous method cannot do. Callers + /// that change blocks should refresh it via + /// [`set_block_context`](Self::set_block_context) after fetching the new + /// header. Prefer [`repin_to_block`](Self::repin_to_block) when re-pinning to /// a concrete height, since it keeps `block_number` and the pinned block in /// lockstep. pub fn set_block(&mut self, block: Option) { - if self.block != block { + let changed = self.block != block; + let concrete_number = match block { + Some(BlockId::Number(BlockNumberOrTag::Number(n))) => Some(n), + _ => None, + }; + if changed { self.block = block; + // Re-pinning replaces layer 2 wholesale (state at a new block): the + // memoized base must be rebuilt from scratch on the next snapshot. + self.invalidate_base(); if let Some(block_id) = block { let _ = self.backend.set_pinned_block(block_id); *self.batch_block_id.lock().unwrap() = block_id; - // Keep the EVM `NUMBER` opcode aligned with the pinned block so the - // two cannot silently diverge. Only a concrete height is meaningful; - // tags (latest/pending/hash) leave `block_number` untouched. - if let BlockId::Number(BlockNumberOrTag::Number(n)) = block_id { - self.block_number = Some(n); - } } } + if changed || concrete_number.is_none() { + self.basefee = None; + } + + // Keep the EVM `NUMBER` opcode aligned with the pin. Only a concrete + // height is meaningful; tags, hashes, and no explicit pin clear it so a + // stale number from an earlier concrete block cannot leak into simulation. + self.block_number = concrete_number; } /// Get the block that RPC fetches are currently pinned to. @@ -2144,9 +2728,10 @@ impl EvmCache { /// Get the block number used for EVM simulations (the `NUMBER` opcode). /// - /// Fetched from the pinned block's header at construction and kept in - /// lockstep with the pin by [`set_block`](Self::set_block) / - /// [`repin_to_block`](Self::repin_to_block). `None` means revm falls back + /// Fetched from the pinned block's header at construction. Concrete-number + /// pins set it via [`set_block`](Self::set_block) / + /// [`repin_to_block`](Self::repin_to_block); tag/hash/`None` pins clear it + /// because their height is not statically known. `None` means revm falls back /// to `0`, which can steer contracts that branch on `block.number` down a /// different code path. Override directly via /// [`set_block_context`](Self::set_block_context). @@ -2157,10 +2742,12 @@ impl EvmCache { /// Get the base fee per gas used for EVM simulations (the `BASEFEE` opcode). /// /// Fetched from the pinned block's header at construction. `None` means - /// revm falls back to `0`. Unlike `block_number` this is **not** refreshed - /// by [`set_block`](Self::set_block); refresh it with - /// [`set_block_context`](Self::set_block_context) after fetching a new - /// header if `BASEFEE` accuracy matters. + /// revm falls back to `0`. This is cleared by [`set_block`](Self::set_block) + /// / [`repin_to_block`](Self::repin_to_block) when the pin changes, and by + /// non-concrete tag/hash/`None` pin calls because those can drift without a + /// concrete number in the API. Refresh it with + /// [`set_block_context`](Self::set_block_context) after fetching a new header + /// if `BASEFEE` accuracy matters. pub fn basefee(&self) -> Option { self.basefee } @@ -2205,16 +2792,12 @@ impl EvmCache { /// /// Updates the SharedBackend pinned block, the batch fetcher block, and the /// EVM block context (`NUMBER` opcode) in lockstep. The current `basefee` is - /// preserved; callers should refresh it via - /// [`set_block_context`](Self::set_block_context) after fetching the new + /// cleared because it cannot be refreshed synchronously; callers should set it + /// via [`set_block_context`](Self::set_block_context) after fetching the new /// block header if `BASEFEE` accuracy matters. pub fn repin_to_block(&mut self, block_number: u64) { let old_block = self.block; - // `set_block` already updates `block_number` for a concrete height; the - // explicit `set_block_context` below preserves `basefee` and keeps the - // re-pin atomic and self-documenting. self.set_block(Some(BlockId::Number(block_number.into()))); - self.set_block_context(Some(block_number), self.basefee); if let Some(BlockId::Number(BlockNumberOrTag::Number(old_num))) = old_block { let drift = block_number.saturating_sub(old_num); @@ -2911,6 +3494,22 @@ impl EvmCache { "Reserved shared memory buffer capacity" ); } + drop(buffer); + // Record the high-water mark so snapshots taken afterwards propagate it to + // their overlays (snapshots copy the capacity at creation time). + self.shared_memory_capacity = self.shared_memory_capacity.max(capacity); + } + + /// The resolved per-context EVM shared-memory pre-allocation, in bytes. + /// + /// This is the [`SharedMemoryCapacity`] configured on the + /// [`EvmCacheBuilder`] resolved to a concrete size (with + /// [`SharedMemoryCapacity::Auto`] resolved against the state loaded at + /// construction), raised by any later [`reserve_shared_memory`](Self::reserve_shared_memory). + /// Each [`create_snapshot`](Self::create_snapshot) copies it onto the snapshot + /// so snapshot-backed [`EvmOverlay`]s pre-allocate the same amount. + pub fn shared_memory_capacity(&self) -> usize { + self.shared_memory_capacity } /// Purge all storage slots for a specific pool from both cache layers. @@ -2949,11 +3548,13 @@ impl EvmCache { }; // Layer 2: Clear BlockchainDb backend - let mut storage = self.blockchain_db.storage().write(); - let backend_cleared = if let Some(slots) = storage.remove(&address) { - slots.len() - } else { - 0 + let backend_cleared = { + let mut storage = self.blockchain_db.storage().write(); + if let Some(slots) = storage.remove(&address) { + slots.len() + } else { + 0 + } }; if cache_db_cleared > 0 || backend_cleared > 0 { @@ -2965,6 +3566,8 @@ impl EvmCache { ); } + // Layer-2 storage for this address was removed → invalidate base. + self.mark_base_dirty(address); backend_cleared } @@ -3006,11 +3609,13 @@ impl EvmCache { } // Layer 2: Remove specific slots from BlockchainDb backend - let mut storage = self.blockchain_db.storage().write(); - if let Some(address_storage) = storage.get_mut(&address) { - for slot in slots { - if address_storage.remove(slot).is_some() { - backend_removed += 1; + { + let mut storage = self.blockchain_db.storage().write(); + if let Some(address_storage) = storage.get_mut(&address) { + for slot in slots { + if address_storage.remove(slot).is_some() { + backend_removed += 1; + } } } } @@ -3025,6 +3630,9 @@ impl EvmCache { ); } + // Layer-2 storage for this address changed (slots dropped) → invalidate + // base. The growth scan only catches length changes; mark explicitly. + self.mark_base_dirty(address); backend_removed } @@ -3064,6 +3672,9 @@ impl EvmCache { "purged contract storage from both cache layers" ); } + // Multiple layer-2 contracts changed → full base rebuild (coarse but + // correct; cheaper than enumerating each touched address here). + self.invalidate_base(); total_purged } @@ -3092,10 +3703,13 @@ impl EvmCache { } // Layer 2: Clear BlockchainDb backend - let mut storage = self.blockchain_db.storage().write(); - let total_slots: usize = storage.values().map(|s| s.len()).sum(); - let contract_count = storage.len(); - storage.clear(); + let (total_slots, contract_count) = { + let mut storage = self.blockchain_db.storage().write(); + let total_slots: usize = storage.values().map(|s| s.len()).sum(); + let contract_count = storage.len(); + storage.clear(); + (total_slots, contract_count) + }; if total_slots > 0 || cache_db_cleared > 0 { warn!( @@ -3105,6 +3719,8 @@ impl EvmCache { "purged ALL storage from both cache layers (full refresh)" ); } + // All layer-2 storage was cleared → full base rebuild. + self.invalidate_base(); total_slots } @@ -3174,8 +3790,9 @@ impl EvmCache { /// reverted. Unlike /// [`simulate_with_transfer_tracking`](Self::simulate_with_transfer_tracking), /// this measures deltas via pre/post balance reads (not transfer-event - /// inspection) and the returned - /// [`access_list`](CallSimulationResult::access_list) is always empty. + /// inspection). The returned [`access_list`](CallSimulationResult::access_list) + /// includes the accounts and slots touched by the pre/post `balanceOf` reads + /// and the simulated call. /// /// # Errors /// Returns an error if building the tx env fails, if a pre/post @@ -3223,11 +3840,13 @@ impl EvmCache { token_deltas.insert(*token, I256::from_raw(post) - I256::from_raw(pre)); } - Ok((gas_used, token_deltas, logs, output)) + let access_list = extract_access_list(&evm.journaled_state.state); + + Ok((gas_used, token_deltas, logs, output, access_list)) })(); match result { - Ok((gas_used, token_deltas, logs, output)) => { + Ok((gas_used, token_deltas, logs, output, access_list)) => { if commit { evm.commit_inner(); } else { @@ -3238,7 +3857,7 @@ impl EvmCache { gas_used, token_deltas, logs, - access_list: AccessList::default(), + access_list, output, }) } @@ -3484,6 +4103,12 @@ impl EvmCache { accounts.insert(target, target_info); } + // Layer 2 changed → invalidate the memoized base for `target`. The layer-1 + // `insert_account_info` above currently shadows it on every snapshot read, + // but we dirty unconditionally for uniformity with every other layer-2 write + // site (D2), so base correctness never relies on that shadowing invariant. + self.mark_base_dirty(target); + Ok(()) } @@ -3769,7 +4394,9 @@ impl Drop for EvmCache { fn drop(&mut self) { if self.cache_config.is_some() { debug!("Flushing EVM cache on drop"); - self.flush(); + if let Err(e) = self.flush() { + warn!(error = %e, "Failed to flush EVM cache on drop"); + } } } } @@ -3795,6 +4422,35 @@ fn extract_access_list(state: &revm::state::EvmState) -> AccessList { AccessList(items) } +#[cfg(test)] +mod shared_memory_capacity_tests { + use super::SharedMemoryCapacity as Cap; + + #[test] + fn default_is_fixed_64k() { + assert_eq!(Cap::default(), Cap::Fixed(64 * 1024)); + } + + #[test] + fn fixed_ignores_loaded_slots() { + assert_eq!(Cap::Fixed(8_192).resolve(10_000_000), 8_192); + assert_eq!(Cap::Fixed(0).resolve(123), 0); + } + + #[test] + fn auto_floors_clamps_and_scales() { + // Nothing / little loaded → floor. + assert_eq!(Cap::Auto.resolve(0), Cap::MIN_AUTO); + assert_eq!(Cap::Auto.resolve(1_000), Cap::MIN_AUTO); // 16 KiB < 64 KiB floor + // Linear region (16 bytes/slot). + assert_eq!(Cap::Auto.resolve(10_000), 160_000); + assert_eq!(Cap::Auto.resolve(100_000), 1_600_000); + // Ceiling. + assert_eq!(Cap::Auto.resolve(usize::MAX), Cap::MAX_AUTO); + assert_eq!(Cap::Auto.resolve(262_144), Cap::MAX_AUTO); // 262_144 * 16 == 4 MiB + } +} + #[cfg(all(test, feature = "protocols"))] mod tests { use super::*; @@ -4283,6 +4939,126 @@ mod core_tests { assert_eq!(cache.basefee(), None); } + #[test] + fn set_block_latest_clears_stale_block_context() { + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter); + let provider = RootProvider::::new(client); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider), None)); + cache.set_block_context(Some(148_252_680), Some(50)); + + cache.set_block(Some(BlockId::latest())); + + assert_eq!( + cache.block_number(), + None, + "tag pins must not retain a stale NUMBER context" + ); + assert_eq!( + cache.basefee(), + None, + "set_block cannot refresh BASEFEE synchronously, so it must clear stale values" + ); + } + + #[test] + fn set_block_none_clears_stale_context_even_when_pin_unchanged() { + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter); + let provider = RootProvider::::new(client); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider), None)); + cache.set_block_context(Some(148_252_680), Some(50)); + + cache.set_block(None); + + assert_eq!( + cache.block_number(), + None, + "None pins must not retain a stale NUMBER context" + ); + assert_eq!( + cache.basefee(), + None, + "None pins can drift like tags, so stale BASEFEE must be cleared" + ); + } + + #[test] + fn set_block_number_sets_number_and_clears_stale_basefee() { + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter); + let provider = RootProvider::::new(client); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider), None)); + cache.set_block_context(Some(100), Some(50)); + + cache.set_block(Some(BlockId::Number(BlockNumberOrTag::Number(200)))); + + assert_eq!(cache.block_number(), Some(200)); + assert_eq!( + cache.basefee(), + None, + "set_block cannot refresh BASEFEE synchronously, so it must clear stale values" + ); + } + + #[test] + fn repin_to_block_clears_stale_basefee() { + use alloy_provider::RootProvider; + use alloy_rpc_client::RpcClient; + use alloy_transport::mock::Asserter; + + let asserter = Asserter::new(); + let client = RpcClient::mocked(asserter); + let provider = RootProvider::::new(client); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider), None)); + cache.set_block_context(Some(100), Some(50)); + + cache.repin_to_block(200); + + assert_eq!(cache.block_number(), Some(200)); + assert_eq!( + cache.basefee(), + None, + "repin_to_block must not carry stale BASEFEE across blocks" + ); + } + #[test] fn test_build_evm_applies_block_context() { use alloy_provider::RootProvider; @@ -4338,8 +5114,8 @@ mod core_tests { let block_num = Some(148_252_680u64); let basefee_val = Some(50u64); let child = EvmCache::from_backend( - parent.backend().clone(), - parent.blockchain_db().clone(), + parent.unchecked_backend().clone(), + parent.unchecked_blockchain_db().clone(), parent.block(), 42161, block_num, diff --git a/src/cache/overlay.rs b/src/cache/overlay.rs index 7656e22..2d84fe2 100644 --- a/src/cache/overlay.rs +++ b/src/cache/overlay.rs @@ -21,9 +21,6 @@ use crate::access_set::StorageAccessList; use crate::errors::{SimError, SimulationError, SimulationResult}; use crate::inspector::TransferInspector; -/// Default initial capacity for shared memory buffer (64KB). -const OVERLAY_SHARED_MEMORY_CAPACITY: usize = 64 * 1024; - type OverlayEvm<'a> = revm::MainnetEvm< Context, ()>, >; @@ -40,6 +37,13 @@ type InspectorOverlayEvm<'a, INSP> = revm::MainnetEvm< /// This type is `Send` (unlike `EvmCache`) because it uses no `Rc`/`RefCell`. /// Each simulation task gets its own `EvmOverlay` with a cheap `Arc::clone` /// of the shared `EvmSnapshot`. +/// +/// # Reuse across simulations (Pillar A.2) +/// +/// A worker doing many sims against the same snapshot can call [`Self::new`] +/// once and [`Self::reset`] between sims instead of allocating a fresh overlay +/// each time. The reusable shared-memory buffer is also recycled across calls — +/// see [`Self::call_raw`] — without making the overlay `!Send`. pub struct EvmOverlay { snapshot: Arc, /// Per-simulation mutations (accounts fetched from ext_db, committed changes). @@ -48,19 +52,56 @@ pub struct EvmOverlay { dirty_storage: HashMap>, /// Optional RPC fallback for data not in snapshot. ext_db: Option, + /// Reusable shared-memory buffer, recycled across the build→transact→revert + /// call methods to avoid reallocating a 64 KB `Vec` per call. + /// + /// Stored as a plain `Vec` (not an `Rc`) so the overlay stays `Send`. A + /// call method `mem::take`s it, wraps it in a method-local `Rc>` + /// for revm's [`LocalContext`], runs, then reclaims and clears it after the + /// EVM is dropped (see [`Self::build_evm_with_local`]). + reusable_buffer: Vec, + /// Target pre-allocation (bytes) for [`Self::reusable_buffer`] and each + /// per-call buffer, taken from the snapshot's configured + /// [`SharedMemoryCapacity`](super::SharedMemoryCapacity) so overlays honor the + /// capacity set on the originating [`EvmCache`]. + buffer_capacity: usize, } impl EvmOverlay { /// Create a new overlay on the given snapshot. + /// + /// The reusable shared-memory buffer is pre-allocated to the snapshot's + /// configured shared-memory capacity (see + /// [`SharedMemoryCapacity`](super::SharedMemoryCapacity)). pub fn new(snapshot: Arc, ext_db: Option) -> Self { + let buffer_capacity = snapshot.shared_memory_capacity; Self { snapshot, dirty_accounts: HashMap::new(), dirty_storage: HashMap::new(), ext_db, + reusable_buffer: Vec::with_capacity(buffer_capacity), + buffer_capacity, } } + /// Clear the per-simulation dirty layer so this overlay can be reused for the + /// next simulation against the same snapshot, without reallocating (Pillar + /// A.2). + /// + /// A worker doing K sims calls [`Self::new`] once and `reset()` between sims + /// instead of allocating a fresh overlay (plus dirty maps plus an `Arc` + /// clone) each time. After `reset()` the overlay reads the pristine snapshot + /// again — it is exactly equivalent to a freshly-built overlay on the same + /// snapshot. The snapshot `Arc`, the optional `ext_db`, and the reusable + /// shared-memory buffer (kept at capacity) are retained. + pub fn reset(&mut self) { + self.dirty_accounts.clear(); + self.dirty_storage.clear(); + // Keep: snapshot Arc, ext_db, and the reusable buffer. The buffer is + // already cleared after each call, so nothing to do for it here. + } + /// Chain ID of the block context captured by the underlying snapshot. /// /// This is the value installed into `cfg.chain_id` by [`Self::build_evm`]. @@ -95,17 +136,29 @@ impl EvmOverlay { self.snapshot.timestamp } - /// Build a revm EVM instance backed by this overlay. + /// A fresh [`LocalContext`] with a newly-allocated 64 KB shared-memory buffer. /// - /// Note: The returned EVM is `!Send` (due to `LocalContext`'s `Rc`), - /// but this is fine because it's created and used within a single task. - pub fn build_evm(&mut self) -> OverlayEvm<'_> { - let local = LocalContext { - shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity( - OVERLAY_SHARED_MEMORY_CAPACITY, - ))), + /// Used by the public [`Self::build_evm`], which hands out the EVM and cannot + /// reclaim its buffer afterwards. The internal call methods instead recycle + /// [`Self::reusable_buffer`] via [`Self::build_evm_with_local`]. + fn fresh_local(&self) -> LocalContext { + LocalContext { + shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity(self.buffer_capacity))), precompile_error_message: None, - }; + } + } + + /// Build a revm EVM instance backed by this overlay, using a caller-supplied + /// [`LocalContext`]. + /// + /// This is the shared body behind [`Self::build_evm`] and the internal call + /// methods. The call methods pass a `local` wrapping the recycled + /// [`Self::reusable_buffer`] (Pillar A.2) and reclaim it after the EVM is + /// dropped; [`Self::build_evm`] passes a fresh one. + /// + /// Note: the returned EVM is `!Send` (due to `LocalContext`'s `Rc`), + /// but this is fine because it's created and used within a single task. + fn build_evm_with_local(&mut self, local: LocalContext) -> OverlayEvm<'_> { // Read snapshot values before the mutable borrow of self let chain_id = self.snapshot.chain_id; let spec_id = self.snapshot.spec_id; @@ -155,6 +208,20 @@ impl EvmOverlay { evm } + /// Build a revm EVM instance backed by this overlay. + /// + /// This allocates a fresh 64 KB shared-memory buffer each call: it hands the + /// EVM out to the caller and cannot reclaim the buffer afterwards, so it + /// cannot recycle the overlay's reusable buffer. The internal call methods + /// ([`Self::call_raw`], etc.) recycle the buffer instead (Pillar A.2). + /// + /// Note: The returned EVM is `!Send` (due to `LocalContext`'s `Rc`), + /// but this is fine because it's created and used within a single task. + pub fn build_evm(&mut self) -> OverlayEvm<'_> { + let local = self.fresh_local(); + self.build_evm_with_local(local) + } + /// Execute a non-committing call and return the raw [`ExecutionResult`]. /// /// The EVM state is reverted to a checkpoint after execution on *both* @@ -201,24 +268,59 @@ impl EvmOverlay { .build() .map_err(|e| anyhow!("Failed to build tx env: {:?}", e))?; - let mut evm = self.build_evm(); - use revm::context_interface::JournalTr; - let checkpoint = evm.journaled_state.checkpoint(); - let result = evm - .transact_one(tx) - .map_err(|e| anyhow!("Failed to transact: {:?}", e)); - evm.journaled_state.checkpoint_revert(checkpoint); - result - } - - /// Build a revm EVM instance with an inspector, backed by this overlay. - fn build_evm_with_inspector(&mut self, inspector: INSP) -> InspectorOverlayEvm<'_, INSP> { + // Recycle the reusable buffer (Pillar A.2): take it out as a plain Vec + // (keeping the overlay Send), lend it to a method-local Rc for + // revm's LocalContext, then reclaim and clear it after the EVM is dropped. + let buffer = Rc::new(RefCell::new(std::mem::take(&mut self.reusable_buffer))); let local = LocalContext { - shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity( - OVERLAY_SHARED_MEMORY_CAPACITY, - ))), + shared_memory_buffer: Rc::clone(&buffer), precompile_error_message: None, }; + + let result = { + let mut evm = self.build_evm_with_local(local); + use revm::context_interface::JournalTr; + let checkpoint = evm.journaled_state.checkpoint(); + let result = evm + .transact_one(tx) + .map_err(|e| anyhow!("Failed to transact: {:?}", e)); + evm.journaled_state.checkpoint_revert(checkpoint); + result + }; + + self.reclaim_buffer(buffer); + result + } + + /// Reclaim the recycled shared-memory buffer after the EVM (and its + /// `LocalContext` clone of the `Rc`) has been dropped, clearing it for the + /// next call. + /// + /// The `Rc` was only ever held by the dropped EVM and this method's local, so + /// `try_unwrap` succeeds in the normal path. If a panic somewhere left an + /// extra strong reference the buffer is simply re-allocated next call — no + /// correctness impact. + fn reclaim_buffer(&mut self, buffer: Rc>>) { + if let Ok(cell) = Rc::try_unwrap(buffer) { + let mut buf = cell.into_inner(); + buf.clear(); + self.reusable_buffer = buf; + } else { + self.reusable_buffer = Vec::with_capacity(self.buffer_capacity); + } + } + + /// Build a revm EVM instance with an inspector, backed by this overlay, using + /// a caller-supplied [`LocalContext`]. + /// + /// Like [`Self::build_evm_with_local`] but attaches `inspector`. The call + /// methods pass a `local` wrapping the recycled [`Self::reusable_buffer`] + /// (Pillar A.2) and reclaim it after the EVM is dropped. + fn build_evm_with_inspector_local( + &mut self, + inspector: INSP, + local: LocalContext, + ) -> InspectorOverlayEvm<'_, INSP> { let chain_id = self.snapshot.chain_id; let spec_id = self.snapshot.spec_id; let timestamp = self.snapshot.timestamp.unwrap_or_else(|| { @@ -326,62 +428,75 @@ impl EvmOverlay { .map_err(|e| SimError::Other(anyhow!("Failed to build tx env: {:?}", e)))?; let inspector = TransferInspector::new(); - let mut evm = self.build_evm_with_inspector(inspector); - - use revm::context_interface::JournalTr; - let checkpoint = evm.journaled_state.checkpoint(); - - let result = evm - .inspect_one_tx(tx) - .map_err(|e| SimError::Other(anyhow!("Failed to transact: {:?}", e))); - - match result { - Ok(ExecutionResult::Success { - logs, - gas_used, - output, - .. - }) => { - let token_deltas = if let Some(token_list) = tokens { - evm.inspector.balance_deltas_for_tokens(owner, token_list) - } else { - evm.inspector.balance_deltas(owner) - }; - - // Extract EIP-2930 access list from journaled state - let access_list = extract_access_list(&evm.journaled_state.state); - - if commit { - evm.commit_inner(); - } else { - evm.journaled_state.checkpoint_revert(checkpoint); - } - Ok(CallSimulationResult { - status: SimStatus::Success, - gas_used, - token_deltas, + // Recycle the reusable buffer (Pillar A.2); reclaimed after the EVM drops. + let buffer = Rc::new(RefCell::new(std::mem::take(&mut self.reusable_buffer))); + let local = LocalContext { + shared_memory_buffer: Rc::clone(&buffer), + precompile_error_message: None, + }; + + let outcome = { + let mut evm = self.build_evm_with_inspector_local(inspector, local); + + use revm::context_interface::JournalTr; + let checkpoint = evm.journaled_state.checkpoint(); + + let result = evm + .inspect_one_tx(tx) + .map_err(|e| SimError::Other(anyhow!("Failed to transact: {:?}", e))); + + match result { + Ok(ExecutionResult::Success { logs, - access_list, - output: output.into_data(), - }) - } - Ok(ExecutionResult::Revert { gas_used, output }) => { - evm.journaled_state.checkpoint_revert(checkpoint); - Err(SimulationError::from_revert(gas_used, output).into()) - } - Ok(ExecutionResult::Halt { reason, gas_used }) => { - evm.journaled_state.checkpoint_revert(checkpoint); - Err(SimError::Halt { - reason: format!("{reason:?}"), gas_used, - }) - } - Err(err) => { - evm.journaled_state.checkpoint_revert(checkpoint); - Err(err) + output, + .. + }) => { + let token_deltas = if let Some(token_list) = tokens { + evm.inspector.balance_deltas_for_tokens(owner, token_list) + } else { + evm.inspector.balance_deltas(owner) + }; + + // Extract EIP-2930 access list from journaled state + let access_list = extract_access_list(&evm.journaled_state.state); + + if commit { + evm.commit_inner(); + } else { + evm.journaled_state.checkpoint_revert(checkpoint); + } + + Ok(CallSimulationResult { + status: SimStatus::Success, + gas_used, + token_deltas, + logs, + access_list, + output: output.into_data(), + }) + } + Ok(ExecutionResult::Revert { gas_used, output }) => { + evm.journaled_state.checkpoint_revert(checkpoint); + Err(SimulationError::from_revert(gas_used, output).into()) + } + Ok(ExecutionResult::Halt { reason, gas_used }) => { + evm.journaled_state.checkpoint_revert(checkpoint); + Err(SimError::Halt { + reason: format!("{reason:?}"), + gas_used, + }) + } + Err(err) => { + evm.journaled_state.checkpoint_revert(checkpoint); + Err(err) + } } - } + }; + + self.reclaim_buffer(buffer); + outcome } /// Execute a non-committing call and return the result plus the touched @@ -464,30 +579,42 @@ impl EvmOverlay { .build() .map_err(|e| anyhow!("Failed to build tx env: {:?}", e))?; - let mut evm = self.build_evm(); - use revm::context_interface::JournalTr; - let checkpoint = evm.journaled_state.checkpoint(); - match evm.transact_one(tx_env) { - Ok(result) => { - let mut access_list = StorageAccessList::default(); - for (address, account) in evm.journaled_state.state.iter() { - if account.is_touched() { - access_list.accounts.insert(*address); - for (slot_key, _) in account.storage.iter() { - access_list.slots.insert((*address, *slot_key)); + // Recycle the reusable buffer (Pillar A.2); reclaimed after the EVM drops. + let buffer = Rc::new(RefCell::new(std::mem::take(&mut self.reusable_buffer))); + let local = LocalContext { + shared_memory_buffer: Rc::clone(&buffer), + precompile_error_message: None, + }; + + let outcome = { + let mut evm = self.build_evm_with_local(local); + use revm::context_interface::JournalTr; + let checkpoint = evm.journaled_state.checkpoint(); + match evm.transact_one(tx_env) { + Ok(result) => { + let mut access_list = StorageAccessList::default(); + for (address, account) in evm.journaled_state.state.iter() { + if account.is_touched() { + access_list.accounts.insert(*address); + for (slot_key, _) in account.storage.iter() { + access_list.slots.insert((*address, *slot_key)); + } } } + evm.journaled_state.checkpoint_revert(checkpoint); + Ok((result, access_list)) + } + Err(e) => { + // Revert the checkpoint even on a host/transact error so the EVM + // journal is not left dirty (mirrors `call_raw`). + evm.journaled_state.checkpoint_revert(checkpoint); + Err(anyhow!("Failed to transact: {:?}", e)) } - evm.journaled_state.checkpoint_revert(checkpoint); - Ok((result, access_list)) - } - Err(e) => { - // Revert the checkpoint even on a host/transact error so the EVM - // journal is not left dirty (mirrors `call_raw`). - evm.journaled_state.checkpoint_revert(checkpoint); - Err(anyhow!("Failed to transact: {:?}", e)) } - } + }; + + self.reclaim_buffer(buffer); + outcome } /// Write a storage value into this overlay's dirty layer. @@ -549,16 +676,16 @@ impl Database for EvmOverlay { if let Some(info) = self.dirty_accounts.get(&address) { return Ok(Some(info.clone())); } - // 2. Check snapshot (O(1) HashMap lookup, no locks) - if let Some(info) = self.snapshot.accounts.get(&address) { - return Ok(Some(info.clone())); - } - // 2b. A NotExisting account is absent to the EVM: return None and do NOT - // fall through to the ext_db, mirroring revm `DbAccount::info()` and the - // live `EvmCache` account read (symmetric with `storage_cleared`). + // 2. Check snapshot (O(1) HashMap lookup, no locks). `account_info` folds + // the two snapshot tiers (overlay ▸ base) and already short-circuits a + // NotExisting account to None — it must NOT fall through to the ext_db, + // mirroring revm `DbAccount::info()` and the live `EvmCache` read. if self.snapshot.accounts_not_existing.contains(&address) { return Ok(None); } + if let Some(info) = self.snapshot.account_info(address) { + return Ok(Some(info.clone())); + } // 3. RPC fallback if let Some(ref ext_db) = self.ext_db { let info = ext_db.basic_ref(address)?; @@ -579,8 +706,8 @@ impl Database for EvmOverlay { return Ok(code.clone()); } } - // Check snapshot's code_by_hash index - if let Some(code) = self.snapshot.code_by_hash.get(&code_hash) { + // Check the snapshot's code index (overlay ▸ base). + if let Some(code) = self.snapshot.code(code_hash) { return Ok(code.clone()); } // RPC fallback @@ -597,17 +724,12 @@ impl Database for EvmOverlay { { return Ok(*value); } - // 2. Check snapshot (O(1)) - if let Some(account_storage) = self.snapshot.storage.get(&address) - && let Some(value) = account_storage.get(&index) - { - return Ok(*value); - } - // 2b. A cleared account's storage is locally complete: an absent slot reads - // ZERO and must NOT fall through to the ext_db, mirroring the live EVM - // SLOAD for a StorageCleared/NotExisting account. - if self.snapshot.storage_cleared.contains(&address) { - return Ok(U256::ZERO); + // 2. Check snapshot (O(1)). `storage_value` folds the two tiers (overlay ▸ + // cleared-as-ZERO ▸ base); a cleared account's absent slot reads ZERO + // and must NOT fall through to the ext_db, mirroring the live EVM SLOAD + // for a StorageCleared/NotExisting account. + if let Some(value) = self.snapshot.storage_value(address, index) { + return Ok(value); } // 3. RPC fallback if let Some(ref ext_db) = self.ext_db { @@ -651,7 +773,47 @@ fn extract_access_list(state: &revm::state::EvmState) -> AccessList { #[cfg(test)] mod tests { use super::*; + use crate::cache::snapshot::BaseState; use revm::primitives::hardfork::SpecId; + use std::collections::HashSet; + + /// Build a two-tier `EvmSnapshot` whose cold base holds the given accounts, + /// storage, and code, with an empty hot overlay — the shape + /// `create_snapshot_deep_clone` produces. The `Arc`-per-account storage of the + /// base is built from the plain per-account maps. + fn snap( + accounts: HashMap, + storage: HashMap>, + code_by_hash: HashMap, + block_hashes: HashMap, + ) -> Arc { + let base = BaseState { + accounts, + storage: storage + .into_iter() + .map(|(addr, slots)| (addr, Arc::new(slots))) + .collect(), + code_by_hash, + }; + Arc::new(EvmSnapshot { + base: Arc::new(base), + overlay_accounts: HashMap::new(), + overlay_storage: HashMap::new(), + overlay_code_by_hash: HashMap::new(), + storage_cleared: HashSet::new(), + accounts_not_existing: HashSet::new(), + block_hashes, + block_number: None, + basefee: None, + coinbase: None, + prevrandao: None, + gas_limit: None, + chain_id: 42161, + timestamp: None, + spec_id: SpecId::CANCUN, + shared_memory_capacity: 64_000, + }) + } #[test] fn test_overlay_is_send() { @@ -672,22 +834,7 @@ mod tests { let addr = Address::repeat_byte(0x01); accounts.insert(addr, info); - let snapshot = Arc::new(EvmSnapshot { - accounts, - storage: HashMap::new(), - block_hashes: HashMap::new(), - storage_cleared: std::collections::HashSet::new(), - accounts_not_existing: std::collections::HashSet::new(), - code_by_hash: HashMap::new(), - block_number: None, - basefee: None, - coinbase: None, - prevrandao: None, - gas_limit: None, - chain_id: 42161, - timestamp: None, - spec_id: SpecId::CANCUN, - }); + let snapshot = snap(accounts, HashMap::new(), HashMap::new(), HashMap::new()); let mut overlay = EvmOverlay::new(snapshot, None); let result = overlay.basic(addr).unwrap(); @@ -706,22 +853,7 @@ mod tests { account_storage.insert(slot, value); storage.insert(addr, account_storage); - let snapshot = Arc::new(EvmSnapshot { - accounts: HashMap::new(), - storage, - block_hashes: HashMap::new(), - storage_cleared: std::collections::HashSet::new(), - accounts_not_existing: std::collections::HashSet::new(), - code_by_hash: HashMap::new(), - block_number: None, - basefee: None, - coinbase: None, - prevrandao: None, - gas_limit: None, - chain_id: 42161, - timestamp: None, - spec_id: SpecId::CANCUN, - }); + let snapshot = snap(HashMap::new(), storage, HashMap::new(), HashMap::new()); let mut overlay = EvmOverlay::new(snapshot, None); let result = overlay.storage(addr, slot).unwrap(); @@ -738,22 +870,7 @@ mod tests { account_storage.insert(slot, U256::from(100)); storage.insert(addr, account_storage); - let snapshot = Arc::new(EvmSnapshot { - accounts: HashMap::new(), - storage, - block_hashes: HashMap::new(), - storage_cleared: std::collections::HashSet::new(), - accounts_not_existing: std::collections::HashSet::new(), - code_by_hash: HashMap::new(), - block_number: None, - basefee: None, - coinbase: None, - prevrandao: None, - gas_limit: None, - chain_id: 42161, - timestamp: None, - spec_id: SpecId::CANCUN, - }); + let snapshot = snap(HashMap::new(), storage, HashMap::new(), HashMap::new()); let mut overlay = EvmOverlay::new(snapshot, None); @@ -771,22 +888,12 @@ mod tests { #[test] fn test_overlay_missing_returns_zero() { - let snapshot = Arc::new(EvmSnapshot { - accounts: HashMap::new(), - storage: HashMap::new(), - block_hashes: HashMap::new(), - storage_cleared: std::collections::HashSet::new(), - accounts_not_existing: std::collections::HashSet::new(), - code_by_hash: HashMap::new(), - block_number: None, - basefee: None, - coinbase: None, - prevrandao: None, - gas_limit: None, - chain_id: 42161, - timestamp: None, - spec_id: SpecId::CANCUN, - }); + let snapshot = snap( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); let mut overlay = EvmOverlay::new(snapshot, None); let addr = Address::repeat_byte(0x99); @@ -805,22 +912,7 @@ mod tests { let mut code_by_hash = HashMap::new(); code_by_hash.insert(hash, code.clone()); - let snapshot = Arc::new(EvmSnapshot { - accounts: HashMap::new(), - storage: HashMap::new(), - block_hashes: HashMap::new(), - storage_cleared: std::collections::HashSet::new(), - accounts_not_existing: std::collections::HashSet::new(), - code_by_hash, - block_number: None, - basefee: None, - coinbase: None, - prevrandao: None, - gas_limit: None, - chain_id: 42161, - timestamp: None, - spec_id: SpecId::CANCUN, - }); + let snapshot = snap(HashMap::new(), HashMap::new(), code_by_hash, HashMap::new()); let mut overlay = EvmOverlay::new(snapshot, None); let result = overlay.code_by_hash(hash).unwrap(); @@ -833,22 +925,7 @@ mod tests { let hash = B256::repeat_byte(0xAB); block_hashes.insert(42u64, hash); - let snapshot = Arc::new(EvmSnapshot { - accounts: HashMap::new(), - storage: HashMap::new(), - storage_cleared: std::collections::HashSet::new(), - accounts_not_existing: std::collections::HashSet::new(), - block_hashes, - code_by_hash: HashMap::new(), - block_number: None, - basefee: None, - coinbase: None, - prevrandao: None, - gas_limit: None, - chain_id: 42161, - timestamp: None, - spec_id: SpecId::CANCUN, - }); + let snapshot = snap(HashMap::new(), HashMap::new(), HashMap::new(), block_hashes); let mut overlay = EvmOverlay::new(snapshot, None); assert_eq!(overlay.block_hash(42).unwrap(), hash); diff --git a/src/cache/snapshot.rs b/src/cache/snapshot.rs index c6e8011..0b6c5ec 100644 --- a/src/cache/snapshot.rs +++ b/src/cache/snapshot.rs @@ -1,19 +1,28 @@ //! Immutable, shareable EVM state snapshots. //! -//! # Flattening model +//! # Two-tier copy-on-write model (Pillar A) //! -//! A snapshot flattens the live cache (CacheDB overlay plus the BlockchainDb -//! backend) into a single immutable, `Send + Sync` view of accounts and -//! storage. The layered lookups of the live cache are collapsed into flat -//! `HashMap`s at creation time, so every read against the snapshot is an O(1) -//! lookup with no locks and no fallback chain. +//! A snapshot is split into two tiers: //! -//! # `Arc` sharing +//! - a **memoized immutable base** (`BaseState`) flattening the *cold* layer-2 +//! `BlockchainDb` index, shared across successive snapshots by `Arc` — both the +//! base as a whole and each account's storage map (`Arc>`) — +//! so taking a snapshot when the cold index is unchanged is an `Arc` handle +//! copy, never a per-slot deep copy; +//! - a small per-snapshot **overlay** folding the *hot* layer-1 CacheDB delta +//! (committed sim changes, write-throughs, freshness corrections), which always +//! shadows the base on a read. //! -//! Because the snapshot is read-only it can be wrapped in an `Arc` and shared -//! across threads, letting many parallel simulations read from one consistent -//! state. Handing a new simulation task its state is a cheap `Arc::clone` -//! rather than a deep copy of the accounts/storage maps. +//! [`super::EvmCache::create_snapshot`] memoizes the base (via the internal +//! `refresh_base`) and folds only layer 1 fresh, so its cost tracks *changed* +//! state, not *total* state. The retained +//! [`super::EvmCache::create_snapshot_deep_clone`] produces the same two-tier +//! shape with everything flattened into the base and empty overlay maps; it is the +//! A/B benchmark baseline and the read-equivalence reference. +//! +//! Reads stay O(1) `HashMap` lookups with no locks (Decision D1: `Arc` sharing, +//! not a persistent/HAMT map), so the snapshot is `Send + Sync` and an +//! [`EvmOverlay`] built from it is `Send`. //! //! # Per-simulation dirty layer //! @@ -29,35 +38,71 @@ //! [`EvmOverlay`]: super::EvmOverlay use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use alloy_primitives::{Address, B256, U256}; use revm::primitives::hardfork::SpecId; use revm::state::{AccountInfo, Bytecode}; +/// Memoized, immutable flatten of the **cold layer-2** index (Pillar A). +/// +/// Holds layer-2 (`BlockchainDb`) account info and storage only; the layer-1 +/// `StorageCleared` / `NotExisting` classification is purely a layer-1 property +/// and lives on [`EvmSnapshot`], not here (see the read rules on +/// [`EvmSnapshot::storage_value`]). Each account's storage is wrapped in an `Arc` +/// so that rebuilding the base on a partial change (copy-on-write) shares the +/// `Arc` handles of unchanged accounts instead of deep-copying their slots. +/// +/// Built and memoized by [`EvmCache::refresh_base`](super::EvmCache::refresh_base); +/// shared across snapshots and across threads via `Arc`. +pub(crate) struct BaseState { + /// Layer-2 account info, by address. (Layer 2 has no `NotExisting` concept; + /// that classification is purely a layer-1 property — see [`EvmSnapshot`].) + pub(crate) accounts: HashMap, + /// Layer-2 storage, per account, **shared by `Arc`** so cloning a base — or + /// rebuilding it for an unchanged account — is a handle copy, never a per-slot + /// copy. + pub(crate) storage: HashMap>>, + /// Bytecode by `code_hash`, derived from `accounts` at build time. + pub(crate) code_by_hash: HashMap, +} + /// Immutable EVM state snapshot — `Send + Sync`, shared via `Arc` across threads. /// -/// Contains merged account info + storage from both CacheDB overlay and -/// BlockchainDb backend, providing a single flat `HashMap` view for O(1) lookups. +/// A two-tier copy-on-write view (see the [module docs](self)): an `Arc`-shared, +/// memoized cold base (layer 2) plus a small per-snapshot overlay folding the hot +/// layer-1 CacheDB delta, which shadows the base on reads. Lookups (including the +/// public [`storage_value`](Self::storage_value)) are O(1) and lock-free, and +/// reproduce the live cache's layered semantics bit-for-bit. /// /// Created via [`super::EvmCache::create_snapshot()`]. Each parallel simulation /// task gets its own [`super::EvmOverlay`] backed by a cheap `Arc::clone` of /// the snapshot. pub struct EvmSnapshot { - pub(crate) accounts: HashMap, - pub(crate) storage: HashMap>, + /// Memoized, `Arc`-shared cold layer-2 base. + pub(crate) base: Arc, + /// Layer-1 accounts that are present to the EVM (`NotExisting` excluded). + /// Shadows [`BaseState::accounts`] on a read. + pub(crate) overlay_accounts: HashMap, + /// Layer-1 storage delta, per account. A cleared account (revm + /// `StorageCleared` / `NotExisting`) ALWAYS has an entry here (possibly empty) + /// so the cleared rule is decided without consulting the base. + pub(crate) overlay_storage: HashMap>, + /// Bytecode introduced by layer 1 (checked before [`BaseState::code_by_hash`]). + pub(crate) overlay_code_by_hash: HashMap, /// Accounts whose storage is locally complete (revm `StorageCleared` / - /// `NotExisting`): a slot absent from `storage` for such an account reads as - /// ZERO and must NOT fall through to an `ext_db`, mirroring the live EVM SLOAD - /// and [`EvmCache::cached_storage_value`](super::EvmCache::cached_storage_value). + /// `NotExisting`): a slot absent from `overlay_storage` for such an account + /// reads as ZERO and must NOT fall through to the base or an `ext_db`, + /// mirroring the live EVM SLOAD and + /// [`EvmCache::cached_storage_value`](super::EvmCache::cached_storage_value). pub(crate) storage_cleared: HashSet
, - /// Accounts that are absent to the EVM (revm `NotExisting`): `basic` returns - /// `None` for them and must NOT fall through to an `ext_db`, mirroring revm - /// `DbAccount::info()` and [`EvmCache`](super::EvmCache)'s live account read. - /// These addresses are excluded from `accounts` / `code_by_hash`. + /// Accounts that are absent to the EVM (revm `NotExisting`): + /// [`account_info`](Self::account_info) returns `None` for them and must NOT + /// fall through to the base or an `ext_db`, mirroring revm `DbAccount::info()` + /// and [`EvmCache`](super::EvmCache)'s live account read. These addresses are + /// excluded from `overlay_accounts` / `overlay_code_by_hash`. pub(crate) accounts_not_existing: HashSet
, pub(crate) block_hashes: HashMap, - /// Bytecode lookup by code_hash (derived from accounts at creation time). - pub(crate) code_by_hash: HashMap, // Block context pub(crate) block_number: Option, pub(crate) basefee: Option, @@ -67,36 +112,76 @@ pub struct EvmSnapshot { pub(crate) chain_id: u64, pub(crate) timestamp: Option, pub(crate) spec_id: SpecId, + /// Per-context EVM shared-memory pre-allocation (bytes) copied from the + /// [`EvmCache`](super::EvmCache) at snapshot time, so an [`EvmOverlay`] built + /// from this snapshot pre-allocates the same working-memory size the live cache + /// was configured with (see + /// [`SharedMemoryCapacity`](super::SharedMemoryCapacity)). + pub(crate) shared_memory_capacity: usize, } impl EvmSnapshot { + /// Account info as the EVM sees it: overlay (layer 1) wins, else the base + /// (layer 2), else `None`. + /// + /// Returns `None` for a `NotExisting` account without consulting the base, + /// mirroring revm `DbAccount::info()` and the live `EvmCache` account read. + pub(crate) fn account_info(&self, address: Address) -> Option<&AccountInfo> { + if self.accounts_not_existing.contains(&address) { + return None; + } + self.overlay_accounts + .get(&address) + .or_else(|| self.base.accounts.get(&address)) + } + /// Return the snapshot's value for a storage slot, mirroring the live read. /// /// Used by the freshness validator to compare a freshly-fetched value against /// the value the snapshot was built from. Resolution matches - /// [`EvmCache::cached_storage_value`](super::EvmCache::cached_storage_value): - /// a captured slot returns its value; a slot absent from a cleared account - /// (revm `StorageCleared`/`NotExisting`) returns `Some(ZERO)` (its storage is - /// locally complete); any other absent slot returns `None`. + /// [`EvmCache::cached_storage_value`](super::EvmCache::cached_storage_value) + /// over the two tiers: an overlay (layer-1) slot wins; for a cleared account + /// an absent overlay slot returns `Some(ZERO)` (its storage is locally + /// complete — the base is never consulted); otherwise the base (layer-2) slot + /// is returned, or `None` if neither tier has seen the slot. pub fn storage_value(&self, address: Address, slot: U256) -> Option { - if let Some(value) = self + if let Some(account_storage) = self.overlay_storage.get(&address) { + if let Some(value) = account_storage.get(&slot) { + return Some(*value); + } + // A StorageCleared / NotExisting account's storage is locally complete: + // an absent slot reads ZERO and never falls through to the base. + if self.storage_cleared.contains(&address) { + return Some(U256::ZERO); + } + // Non-cleared overlay account: fall through to the base below. + } + self.base .storage .get(&address) .and_then(|s| s.get(&slot).copied()) - { - return Some(value); - } - if self.storage_cleared.contains(&address) { - return Some(U256::ZERO); - } - None + } + + /// Bytecode by `code_hash`: overlay (layer 1) wins, else the base (layer 2). + pub(crate) fn code(&self, code_hash: B256) -> Option<&Bytecode> { + self.overlay_code_by_hash + .get(&code_hash) + .or_else(|| self.base.code_by_hash.get(&code_hash)) } } #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; + + /// Build an empty `Arc` for snapshot literals in tests. + fn empty_base() -> Arc { + Arc::new(BaseState { + accounts: HashMap::new(), + storage: HashMap::new(), + code_by_hash: HashMap::new(), + }) + } #[test] fn test_snapshot_is_send_sync() { @@ -108,12 +193,13 @@ mod tests { #[test] fn test_empty_snapshot() { let snap = EvmSnapshot { - accounts: HashMap::new(), - storage: HashMap::new(), + base: empty_base(), + overlay_accounts: HashMap::new(), + overlay_storage: HashMap::new(), + overlay_code_by_hash: HashMap::new(), storage_cleared: HashSet::new(), accounts_not_existing: HashSet::new(), block_hashes: HashMap::new(), - code_by_hash: HashMap::new(), block_number: Some(100), basefee: Some(1000), coinbase: None, @@ -122,6 +208,7 @@ mod tests { chain_id: 42161, timestamp: None, spec_id: SpecId::CANCUN, + shared_memory_capacity: 64_000, }; assert_eq!(snap.chain_id, 42161); assert_eq!(snap.block_number, Some(100)); diff --git a/src/cache/tick_snapshot.rs b/src/cache/tick_snapshot.rs index e5f6f5a..145f7b2 100644 --- a/src/cache/tick_snapshot.rs +++ b/src/cache/tick_snapshot.rs @@ -12,7 +12,11 @@ use std::path::Path; use alloy_primitives::{Address, U256}; use anyhow::Result; use serde::{Deserialize, Serialize}; -use tracing::warn; + +use super::versioned; + +const TICK_SNAPSHOT_CACHE_MAGIC: &[u8; 8] = b"EFCTICK\0"; +const TICK_SNAPSHOT_CACHE_VERSION: u32 = 1; /// Per-tick liquidity state for a UniswapV3-style concentrated-liquidity pool. /// @@ -151,15 +155,16 @@ pub struct V3TickSnapshotCache { impl V3TickSnapshotCache { /// Load tick snapshot cache from disk (binary format). /// - /// Returns `None` if `path` cannot be read or its contents fail to decode as - /// bincode for this type; a decode failure is logged at `warn` level and - /// treated as a cache miss. The format has no version header, so a file from - /// an incompatible build also yields `None`. + /// Returns `None` if `path` cannot be read, fails the magic/version check, or + /// fails to decode as bincode for this type. pub fn load(path: &Path) -> Option { let data = std::fs::read(path).ok()?; - bincode::deserialize(&data) - .inspect_err(|e| warn!("Failed to parse V3 tick snapshot cache (bincode): {}", e)) - .ok() + versioned::decode( + &data, + TICK_SNAPSHOT_CACHE_MAGIC, + TICK_SNAPSHOT_CACHE_VERSION, + "V3 tick snapshot cache", + ) } /// Save tick snapshot cache to disk (binary format). @@ -175,7 +180,12 @@ impl V3TickSnapshotCache { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let data = bincode::serialize(self)?; + let data = versioned::encode( + TICK_SNAPSHOT_CACHE_MAGIC, + TICK_SNAPSHOT_CACHE_VERSION, + self, + "V3 tick snapshot cache", + )?; std::fs::write(path, data)?; Ok(()) } diff --git a/src/cache/versioned.rs b/src/cache/versioned.rs new file mode 100644 index 0000000..0ed9681 --- /dev/null +++ b/src/cache/versioned.rs @@ -0,0 +1,66 @@ +use serde::{Serialize, de::DeserializeOwned}; +use tracing::warn; + +use anyhow::{Context as _, Result}; + +const VERSION_BYTES: usize = 4; + +pub(crate) fn encode( + magic: &[u8; 8], + version: u32, + value: &T, + label: &'static str, +) -> Result> { + let payload = + bincode::serialize(value).with_context(|| format!("failed to serialize {label}"))?; + let mut data = Vec::with_capacity(magic.len() + VERSION_BYTES + payload.len()); + data.extend_from_slice(magic); + data.extend_from_slice(&version.to_le_bytes()); + data.extend_from_slice(&payload); + Ok(data) +} + +pub(crate) fn decode( + data: &[u8], + magic: &[u8; 8], + version: u32, + label: &'static str, +) -> Option { + let header_len = magic.len() + VERSION_BYTES; + if data.len() < header_len { + warn!( + cache = label, + bytes = data.len(), + "Cache file is missing version header; treating as cache miss" + ); + return None; + } + + if &data[..magic.len()] != magic { + warn!( + cache = label, + "Cache file has unrecognized magic header; treating as cache miss" + ); + return None; + } + + let version_start = magic.len(); + let found_version = u32::from_le_bytes( + data[version_start..header_len] + .try_into() + .expect("version slice length is fixed"), + ); + if found_version != version { + warn!( + cache = label, + expected = version, + found = found_version, + "Cache file version mismatch; treating as cache miss" + ); + return None; + } + + bincode::deserialize(&data[header_len..]) + .inspect_err(|e| warn!(cache = label, error = %e, "Failed to parse cache payload")) + .ok() +} diff --git a/src/lib.rs b/src/lib.rs index 15406b8..f138977 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,6 +128,6 @@ pub use freshness::{ SpeculativeSim, Validation, Validity, WallClock, }; pub use state_update::{ - AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedBalanceDelta, SkippedDelta, - SkippedMask, SlotDelta, StateDiff, StateUpdate, + AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedAccountPatch, SkippedBalanceDelta, + SkippedDelta, SkippedMask, SlotDelta, StateDiff, StateUpdate, }; diff --git a/src/prefetch_registry.rs b/src/prefetch_registry.rs index ba5ab86..04fa1ab 100644 --- a/src/prefetch_registry.rs +++ b/src/prefetch_registry.rs @@ -16,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use alloy_primitives::{Address, U256}; +use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; @@ -80,36 +81,27 @@ impl PrefetchRegistry { /// Persist the registry to `path` in bincode format, creating parent /// directories as needed. /// - /// This is best-effort: I/O and serialization failures (unwritable parent - /// directory, failed write, or a serialization error) are logged at `warn` - /// and swallowed rather than returned, so a save failure leaves stale or - /// missing on-disk data that [`load`](Self::load) will silently treat as an - /// empty registry on the next cycle. - pub fn save(&self, path: &Path) { - if let Some(parent) = path.parent() - && let Err(e) = std::fs::create_dir_all(parent) - { - warn!(error = %e, "Failed to create prefetch registry directory"); - return; - } - match bincode::serialize(self) { - Ok(data) => { - if let Err(e) = std::fs::write(path, data) { - warn!(error = %e, "Failed to persist prefetch registry"); - } else { - let total_slots: usize = - self.phases.values().map(|al| al.slots.len()).sum::() - + self - .keyed_phases - .values() - .flat_map(|m| m.values()) - .map(|al| al.slots.len()) - .sum::(); - debug!(total_slots, "Saved prefetch registry"); - } - } - Err(e) => warn!(error = %e, "Failed to serialize prefetch registry"), + /// Returns an error if the parent directory cannot be created, serialization + /// fails, or the write fails. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create prefetch registry directory {parent:?}") + })?; } + let data = bincode::serialize(self).context("failed to serialize prefetch registry")?; + std::fs::write(path, data) + .with_context(|| format!("failed to persist prefetch registry to {path:?}"))?; + + let total_slots: usize = self.phases.values().map(|al| al.slots.len()).sum::() + + self + .keyed_phases + .values() + .flat_map(|m| m.values()) + .map(|al| al.slots.len()) + .sum::(); + debug!(total_slots, "Saved prefetch registry"); + Ok(()) } /// Record the aggregated access list for `phase`, **overwriting** any access @@ -352,7 +344,7 @@ mod tests { sal.slots.insert((key, U256::from(99))); registry.record_keyed("per_target", key, sal); - registry.save(&path); + registry.save(&path).expect("save registry"); let loaded = PrefetchRegistry::load(&path); assert_eq!(loaded.phases.len(), 1); @@ -374,6 +366,26 @@ mod tests { let _ = std::fs::remove_dir(&dir); } + #[test] + fn save_reports_write_failures() { + let dir = std::env::temp_dir().join("evm_fork_cache_test_prefetch_registry_write_error"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_file(&dir); + std::fs::write(&dir, b"not a directory").expect("create file path conflict"); + + let registry = PrefetchRegistry::default(); + let path = dir.join("registry.bin"); + let err = registry + .save(&path) + .expect_err("save must report write failure"); + assert!( + err.to_string().contains("directory") || err.to_string().contains("Not a directory"), + "unexpected error: {err:#}" + ); + + let _ = std::fs::remove_file(&dir); + } + #[test] fn test_load_missing_file_returns_default() { let path = std::path::Path::new("/tmp/nonexistent_prefetch_registry.bin"); diff --git a/src/state_update.rs b/src/state_update.rs index 165bff5..2641023 100644 --- a/src/state_update.rs +++ b/src/state_update.rs @@ -15,23 +15,31 @@ //! - [`StateUpdate::Slot`] — set a single storage slot, authoritative across //! both cache layers. //! - [`StateUpdate::Account`] — apply a partial [`AccountPatch`] -//! (`balance`/`nonce`/`code`, each optional). +//! (`balance`/`nonce`/`code`, each optional) to an already-known account. +//! - [`StateUpdate::AccountUpsert`] — intentionally materialize a cold account +//! from a partial [`AccountPatch`]. //! - [`StateUpdate::Purge`] — drop cached state at a [`PurgeScope`] so the next //! read re-fetches. //! //! # The dual-layer write-through policy //! -//! [`apply_update`](crate::cache::EvmCache::apply_update) applies a `Slot` or -//! `Account` write-through with one consistent rule: the BlockchainDb backend -//! (layer 2) is written **always**; the CacheDB overlay (layer 1) is written -//! **only if an overlay account already exists** for the address. A new overlay -//! account is never materialized for a slot/account write — the read path falls -//! through to the backend for an absent overlay entry, so a backend-only write -//! is authoritative, and materializing an overlay entry would pollute layer 1 -//! and could shadow later RPC reads. (This mirrors the established +//! [`apply_update`](crate::cache::EvmCache::apply_update) applies `Slot` writes +//! through with one consistent rule: the BlockchainDb backend (layer 2) is +//! written **always**; the CacheDB overlay (layer 1) is written **only if an +//! overlay account already exists** for the address. A new overlay account is +//! never materialized for a slot write — the read path falls through to the +//! backend for an absent overlay entry, so a backend-only write is authoritative, +//! and materializing an overlay entry would pollute layer 1 and could shadow +//! later RPC reads. (This mirrors the established //! [`inject_storage_batch_fresh`](crate::cache::EvmCache::inject_storage_batch_fresh) //! semantics.) //! +//! `Account` patches follow the same overlay-if-present write-through policy +//! once the account is already present in either layer. If the account is absent +//! from **both** layers, the patch is skipped and surfaced in +//! [`StateDiff::skipped_accounts`]. Use [`StateUpdate::AccountUpsert`] when the +//! caller intentionally wants to materialize a cold/default account. +//! //! # The output //! //! Every apply returns a [`StateDiff`] of the changes it actually made: the @@ -86,21 +94,13 @@ //! Because a cold-skipped relative update produces **no** change, it is invisible //! to the natural [`StateDiff::is_empty`] / [`StateDiff::len`] success check (those //! are changes-only). A caller applying relative updates **must** therefore check -//! [`StateDiff::has_skipped`] (or inspect [`skipped`](StateDiff::skipped) / -//! [`skipped_balances`](StateDiff::skipped_balances)) — a cold target was dropped, -//! not applied, and a silently-dropped balance update can break conservation. -//! [`StateDiff::is_fully_applied`] and [`StateDiff::skipped_len`] are the -//! companions. -//! -//! # Warning — cold absolute `Account` patches -//! -//! A *partial* absolute [`StateUpdate::Account`] patch (e.g. balance-only) on an -//! address absent from **both** cache layers writes default nonce/code through the -//! shared backend as authoritative, pre-empting a real RPC fetch. Fetch+seed the -//! account first, or prefer [`StateUpdate::BalanceDelta`] for relative -//! native-balance tracking. See the warnings on -//! [`apply_update`](crate::cache::EvmCache::apply_update), -//! [`StateUpdate::Account`], and [`AccountPatch`]. +//! [`StateDiff::has_skipped`] (or inspect [`skipped`](StateDiff::skipped), +//! [`skipped_balances`](StateDiff::skipped_balances), +//! [`skipped_masks`](StateDiff::skipped_masks), or +//! [`skipped_accounts`](StateDiff::skipped_accounts)) — a cold target was +//! dropped, not applied, and a silently-dropped balance/account update can break +//! conservation. [`StateDiff::is_fully_applied`] and +//! [`StateDiff::skipped_len`] are the companions. //! //! # Boundary — events are Phase 4 //! @@ -246,21 +246,32 @@ pub enum StateUpdate { /// The bits to write (only the bits selected by `mask` are applied). value: U256, }, - /// Patch an account's balance/nonce/code (partial — see [`AccountPatch`]). + /// Patch an already-known account's balance/nonce/code (partial — see + /// [`AccountPatch`]). /// - /// # Warning - /// - /// A partial absolute patch (e.g. balance-only) on an address absent from - /// **both** cache layers writes default nonce/code through the shared backend - /// as authoritative, pre-empting a real RPC fetch. Fetch+seed the account - /// first, or use [`StateUpdate::BalanceDelta`] for relative native-balance - /// tracking. + /// Cold-aware: if the account is absent from **both** layers, 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 { /// Account to patch. address: Address, /// The partial mutation: each `Some` field overwrites, `None` leaves it. patch: AccountPatch, }, + /// Apply an [`AccountPatch`], materializing a cold account when needed. + /// + /// This is the explicit escape hatch for callers that really do want a + /// default account to become authoritative in the backend (for example a + /// synthetic test account). Normal event-derived account patches should use + /// [`StateUpdate::Account`] so a cold account is skipped instead of masking a + /// future RPC fetch. + AccountUpsert { + /// Account to patch or create. + address: Address, + /// The partial mutation: each `Some` field overwrites, `None` leaves it. + patch: AccountPatch, + }, /// Purge cached state for `address` at `scope`; the next read re-fetches. Purge { /// Account whose cached state is purged. @@ -337,6 +348,16 @@ impl StateUpdate { Self::Account { address, patch } } + /// Construct a [`StateUpdate::AccountUpsert`] from a prebuilt + /// [`AccountPatch`]. + /// + /// Use this only when materializing an account absent from both layers is the + /// desired behavior. For normal patches to known accounts, use + /// [`account`](Self::account). + pub fn account_upsert(address: Address, patch: AccountPatch) -> Self { + Self::AccountUpsert { address, patch } + } + /// Construct a [`StateUpdate::Purge`] for `address` at `scope`. pub fn purge(address: Address, scope: PurgeScope) -> Self { Self::Purge { address, scope } @@ -360,11 +381,10 @@ impl StateUpdate { /// # Warning /// /// Applying an absolute patch with [`StateUpdate::Account`] on an address absent -/// from **both** cache layers writes default values for the un-patched fields -/// (e.g. nonce `0`, empty code) through the shared backend as authoritative, -/// masking a later RPC fetch of the real on-chain account. Fetch+seed the account -/// first, or use [`StateUpdate::BalanceDelta`] for relative native-balance -/// tracking. +/// from **both** cache layers is skipped and surfaced in +/// [`StateDiff::skipped_accounts`]. Use [`StateUpdate::AccountUpsert`] only when +/// default values for un-patched fields (e.g. nonce `0`, empty code) should become +/// authoritative in the backend. /// /// ``` /// use alloy_primitives::{Bytes, U256}; @@ -448,18 +468,19 @@ pub enum PurgeScope { /// changes are recorded, so a no-op write yields a [`Default`] (empty) diff. /// /// The struct is `#[non_exhaustive]`: it has grown fields pre-1.0 -/// ([`skipped`](Self::skipped), [`skipped_balances`](Self::skipped_balances)) and -/// may grow more. Construct it via [`Default`] + field assignment, never an -/// exhaustive struct literal. +/// ([`skipped`](Self::skipped), [`skipped_balances`](Self::skipped_balances), +/// [`skipped_masks`](Self::skipped_masks), and +/// [`skipped_accounts`](Self::skipped_accounts)) and may grow more. Construct it +/// via [`Default`] + field assignment, never an exhaustive struct literal. /// /// # Checking for skips /// /// [`is_empty`](Self::is_empty) / [`len`](Self::len) are **changes-only**, so a -/// cold-skipped relative update ([`SlotDelta`](StateUpdate::SlotDelta) / -/// [`BalanceDelta`](StateUpdate::BalanceDelta)) is invisible to them. After -/// applying relative updates, check [`has_skipped`](Self::has_skipped) (or -/// inspect [`skipped`](Self::skipped) / [`skipped_balances`](Self::skipped_balances)) -/// — a cold target was dropped, not applied. +/// cold-skipped update ([`SlotDelta`](StateUpdate::SlotDelta) / +/// [`BalanceDelta`](StateUpdate::BalanceDelta) / [`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. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[non_exhaustive] pub struct StateDiff { @@ -484,6 +505,10 @@ pub struct StateDiff { /// bits could not be preserved. Like [`skipped`](Self::skipped) this is /// informational metadata, not a change. pub skipped_masks: Vec, + /// Account patches ([`StateUpdate::Account`]) that were **not** applied + /// because the account was absent from both layers. Like + /// [`skipped`](Self::skipped) this is informational metadata, not a change. + pub skipped_accounts: Vec, } impl StateDiff { @@ -515,12 +540,16 @@ impl StateDiff { !self.skipped.is_empty() || !self.skipped_balances.is_empty() || !self.skipped_masks.is_empty() + || !self.skipped_accounts.is_empty() } /// Total number of skipped relative/masked updates (`skipped` + - /// `skipped_balances` + `skipped_masks`). + /// `skipped_balances` + `skipped_masks` + `skipped_accounts`). pub fn skipped_len(&self) -> usize { - self.skipped.len() + self.skipped_balances.len() + self.skipped_masks.len() + self.skipped.len() + + self.skipped_balances.len() + + self.skipped_masks.len() + + self.skipped_accounts.len() } /// Whether every relative update in the apply was applied (none skipped). @@ -535,7 +564,8 @@ impl StateDiff { /// Used by [`apply_updates`](crate::cache::EvmCache::apply_updates) to merge /// per-update diffs; the concatenation preserves order, so two writes to the /// same slot record their `old → new` history in sequence. The `skipped`, - /// `skipped_balances`, and `skipped_masks` metadata are concatenated too. + /// `skipped_balances`, `skipped_masks`, and `skipped_accounts` metadata are + /// concatenated too. pub fn merge(&mut self, other: StateDiff) { self.slots.extend(other.slots); self.accounts.extend(other.accounts); @@ -543,6 +573,7 @@ impl StateDiff { self.skipped.extend(other.skipped); self.skipped_balances.extend(other.skipped_balances); self.skipped_masks.extend(other.skipped_masks); + self.skipped_accounts.extend(other.skipped_accounts); } } @@ -642,6 +673,22 @@ pub struct SkippedMask { pub value: U256, } +/// An account patch ([`StateUpdate::Account`]) that could not be applied because +/// the account is absent from **both** cache layers. +/// +/// A partial patch against a cold account is skipped rather than applied against +/// [`AccountInfo::default`](revm::state::AccountInfo::default), because default +/// nonce/code would become authoritative and mask a later RPC fetch. It is +/// surfaced here so the caller can fetch+seed the account and retry, or opt in to +/// materialization with [`StateUpdate::AccountUpsert`]. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SkippedAccountPatch { + /// Account whose patch was skipped. + pub address: Address, + /// The patch that was not applied. + pub patch: AccountPatch, +} + #[cfg(test)] mod tests { use super::*; @@ -688,6 +735,13 @@ mod tests { patch: AccountPatch::default().balance(U256::from(9)), } ); + assert_eq!( + StateUpdate::account_upsert(a, AccountPatch::default().balance(U256::from(9))), + StateUpdate::AccountUpsert { + address: a, + patch: AccountPatch::default().balance(U256::from(9)), + } + ); assert_eq!( StateUpdate::purge(a, PurgeScope::Account), StateUpdate::Purge { diff --git a/tests/cache_state.rs b/tests/cache_state.rs index cdbb636..8c55b88 100644 --- a/tests/cache_state.rs +++ b/tests/cache_state.rs @@ -8,13 +8,13 @@ mod common; -use alloy_primitives::{Address, Bytes, I256, U256}; -use alloy_sol_types::SolValue; +use alloy_primitives::{Address, B256, Bytes, I256, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; use anyhow::{Context, Result}; use revm::state::{AccountInfo, Bytecode}; use common::{ - MOCK_ERC20_BALANCE_SLOT, balance_of, install_default_account, install_mock_erc20, + MOCK_ERC20_BALANCE_SLOT, MockERC20, balance_of, install_default_account, install_mock_erc20, mock_erc20_creation_code, mock_erc20_runtime, setup_cache, transfer, }; use evm_fork_cache::cache::TxConfig; @@ -123,6 +123,75 @@ async fn simulation_reports_balance_deltas() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn balance_delta_simulation_reports_access_list() -> Result<()> { + let mut cache = setup_cache().await?; + let token = Address::repeat_byte(0x44); + let owner = Address::repeat_byte(0x55); + let recipient = Address::repeat_byte(0x66); + + install_default_account(&mut cache, Address::ZERO); + install_default_account(&mut cache, owner); + install_default_account(&mut cache, recipient); + install_mock_erc20(&mut cache, token); + + let balance_slot = U256::from(MOCK_ERC20_BALANCE_SLOT); + cache.insert_mapping_storage_slot(token, balance_slot, owner, U256::from(1_000u64))?; + cache.insert_mapping_storage_slot(token, balance_slot, recipient, U256::ZERO)?; + + let transfer_call = MockERC20::transferCall { + to: recipient, + amount: U256::from(250u64), + }; + let result = cache.simulate_call_with_balance_deltas( + owner, + token, + Bytes::from(transfer_call.abi_encode()), + owner, + [token], + false, + )?; + + assert_eq!( + result.token_deltas.get(&token), + Some(&-I256::from_raw(U256::from(250u64))) + ); + + let owner_balance_slot = B256::from(U256::from_be_bytes( + keccak256((owner, balance_slot).abi_encode()).0, + )); + let recipient_balance_slot = B256::from(U256::from_be_bytes( + keccak256((recipient, balance_slot).abi_encode()).0, + )); + let token_item = result + .access_list + .0 + .iter() + .find(|item| item.address == token) + .expect("access list includes the token account"); + assert!( + token_item.storage_keys.contains(&owner_balance_slot), + "access list includes owner's balance slot" + ); + assert!( + token_item.storage_keys.contains(&recipient_balance_slot), + "access list includes recipient's balance slot" + ); + + assert_eq!( + balance_of(&mut cache, token, owner)?, + U256::from(1_000u64), + "non-committing simulation must not change owner balance" + ); + assert_eq!( + balance_of(&mut cache, token, recipient)?, + U256::ZERO, + "non-committing simulation must not change recipient balance" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn set_erc20_balance_with_slot_scan_finds_balance_slot() -> Result<()> { let mut cache = setup_cache().await?; @@ -235,7 +304,7 @@ async fn two_layer_cache_staleness_requires_full_purge() -> Result<()> { // Clearing ONLY the backend leaves the overlay serving stale data. { - let mut storage = cache.blockchain_db().storage().write(); + let mut storage = cache.unchecked_blockchain_db().storage().write(); storage.remove(&token); } assert_eq!( diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 509a1f5..196f3d3 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -6,7 +6,7 @@ #![allow(dead_code)] use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Condvar, Mutex}; use alloy_eips::BlockId; use alloy_primitives::{Address, Bytes, U256, hex}; @@ -128,19 +128,64 @@ pub fn failing_fetcher() -> StorageBatchFetchFn { }) } -/// Build a stub [`StorageBatchFetchFn`] that reports chosen values *and* flips a -/// shared flag the first time it is called. +/// A one-shot synchronous gate: a holder blocks in [`wait`](Gate::wait) until +/// some other thread calls [`release`](Gate::release). Cloning shares the same +/// underlying state, and `release` is sticky — once released, every present and +/// future `wait` returns immediately. /// -/// Used by the Drop-abort test to prove the background validator was cancelled -/// before it ever fetched (so it could not have queued a correction). The -/// returned values otherwise behave exactly like [`stub_fetcher`]. -pub fn tracking_fetcher( +/// Used by the Drop-abort test to make the background validator's fetch +/// deterministically ordered *after* the drop. The fetcher (running on a worker +/// thread) cannot return — and therefore the validator cannot reach its +/// post-fetch checkpoint — until the test has dropped the `SpeculativeSim` and +/// released the gate, eliminating the spawn/poll race regardless of how the +/// multi-thread scheduler interleaves the two threads. +/// +/// Built on a `Mutex` + `Condvar` so the whole thing is `Send + Sync`, +/// which a [`StorageBatchFetchFn`] closure must be. +#[derive(Clone, Default)] +pub struct Gate { + inner: Arc<(Mutex, Condvar)>, +} + +impl Gate { + pub fn new() -> Self { + Self::default() + } + + /// Block until [`release`](Gate::release) has been called (returns + /// immediately if it already has). + pub fn wait(&self) { + let (lock, cv) = &*self.inner; + let mut released = lock.lock().unwrap_or_else(|e| e.into_inner()); + while !*released { + released = cv.wait(released).unwrap_or_else(|e| e.into_inner()); + } + } + + /// Wake any current waiter and let all future waiters pass. + pub fn release(&self) { + let (lock, cv) = &*self.inner; + *lock.lock().unwrap_or_else(|e| e.into_inner()) = true; + cv.notify_all(); + } +} + +/// Build a stub [`StorageBatchFetchFn`] that reports chosen values but blocks on +/// `gate` before returning. +/// +/// Used by the Drop-abort test: by releasing the gate only *after* dropping the +/// `SpeculativeSim`, the test guarantees the validator's fetch completes (and so +/// its post-fetch, correction-queuing checkpoint runs) strictly after the +/// cancel flag is set — so the dropped speculation can never queue a correction, +/// no matter how the scheduler races the two threads. The returned values +/// otherwise behave exactly like [`stub_fetcher`]. +pub fn gated_tracking_fetcher( values: HashMap<(Address, U256), U256>, - called: Arc, + gate: Gate, ) -> StorageBatchFetchFn { Arc::new( move |requests: Vec<(Address, U256)>, _block: Option| { - called.store(true, std::sync::atomic::Ordering::SeqCst); + gate.wait(); requests .into_iter() .map(|(addr, slot)| { diff --git a/tests/cow_snapshot.rs b/tests/cow_snapshot.rs new file mode 100644 index 0000000..892e154 --- /dev/null +++ b/tests/cow_snapshot.rs @@ -0,0 +1,719 @@ +//! Phase 5 (Pillar A) acceptance tests — the **red contract** for copy-on-write +//! snapshots, authored before implementation. +//! +//! The gate is a *differential-equivalence* property: the new, memoized +//! [`EvmCache::create_snapshot`] must be **read-indistinguishable** from the +//! retained reference [`EvmCache::create_snapshot_deep_clone`] after every kind of +//! cache mutation. Because the two use different internal representations (the COW +//! snapshot shares an `Arc`-ed cold base; the reference is a full flatten), they are +//! compared *through reads only* — `storage_value`, overlay `basic`/`storage`, and a +//! `MockERC20` `balanceOf`. Any base-invalidation miss surfaces here as a failed +//! assertion, never as a silent stale read. +//! +//! Also pins the Pillar A.2 overlay-reuse contract: [`EvmOverlay::reset`] recycles +//! an overlay equivalently to a fresh one, and buffer reuse does not change results. +//! +//! All state is injected over a mocked provider — no test touches the network. +//! +//! These reference `create_snapshot_deep_clone` and `EvmOverlay::reset`, which do +//! not exist until the Phase 5 implementation lands; until then this file fails to +//! compile (red), exactly as intended. + +mod common; + +use std::sync::Arc; + +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use anyhow::{Result, anyhow}; +use revm::database::AccountState; +use revm::database_interface::Database; +use revm::state::{AccountInfo, Bytecode}; + +use common::{ + MOCK_ERC20_BALANCE_SLOT, MockERC20, install_default_account, install_mock_erc20, setup_cache, + transfer, +}; +use evm_fork_cache::cache::{EvmCache, EvmOverlay, EvmSnapshot}; +use evm_fork_cache::{SlotDelta, StateUpdate}; + +/// `keccak256(abi.encode(owner, slot))` — the hashed mapping slot of +/// `balanceOf[owner]`. +fn mapping_slot(owner: Address, slot: u64) -> U256 { + let key = keccak256((owner, U256::from(slot)).abi_encode()); + U256::from_be_bytes(key.0) +} + +/// Two `AccountInfo`s are equal as the EVM sees them (code identity via code_hash). +fn account_eq(a: &Option, b: &Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(x), Some(y)) => { + x.balance == y.balance + && x.nonce == y.nonce + && x.code_hash == y.code_hash + && x.code.is_some() == y.code.is_some() + } + _ => false, + } +} + +/// Read `balanceOf(owner)` through an overlay (non-committing). +fn overlay_balance_of(overlay: &mut EvmOverlay, token: Address, owner: Address) -> Result { + let call = MockERC20::balanceOfCall { account: owner }; + match overlay.call_raw(owner, token, call.abi_encode().into())? { + revm::context::result::ExecutionResult::Success { output, .. } => Ok( + MockERC20::balanceOfCall::abi_decode_returns(&output.into_data())?, + ), + other => Err(anyhow!("overlay balanceOf failed: {other:?}")), + } +} + +/// Assert the COW snapshot and the deep-clone reference are read-indistinguishable +/// across a probe set of addresses and slots. `label` identifies the mutation step. +fn assert_equivalent(cache: &mut EvmCache, addrs: &[Address], slots: &[U256], label: &str) { + let cow = cache.create_snapshot(); + let deep = cache.create_snapshot_deep_clone(); + + let mut ov_cow = EvmOverlay::new(Arc::clone(&cow), None); + let mut ov_deep = EvmOverlay::new(Arc::clone(&deep), None); + + // Block context must match. + assert_eq!(ov_cow.chain_id(), ov_deep.chain_id(), "{label}: chain_id"); + assert_eq!( + ov_cow.block_number(), + ov_deep.block_number(), + "{label}: block_number" + ); + assert_eq!(ov_cow.basefee(), ov_deep.basefee(), "{label}: basefee"); + assert_eq!( + ov_cow.timestamp(), + ov_deep.timestamp(), + "{label}: timestamp" + ); + + for &a in addrs { + let bc = ov_cow.basic(a).expect("cow basic"); + let bd = ov_deep.basic(a).expect("deep basic"); + assert!( + account_eq(&bc, &bd), + "{label}: basic mismatch at {a}: cow={bc:?} deep={bd:?}" + ); + // Code lookup for the account's code hash must agree (spec §8.1). + if let Some(info) = &bc { + let h = info.code_hash; + assert_eq!( + ov_cow.code_by_hash(h).expect("cow code").original_bytes(), + ov_deep.code_by_hash(h).expect("deep code").original_bytes(), + "{label}: code_by_hash mismatch at {a} (hash {h})" + ); + } + for &s in slots { + assert_eq!( + cow.storage_value(a, s), + deep.storage_value(a, s), + "{label}: snapshot.storage_value mismatch at {a} / {s}" + ); + let scow = ov_cow.storage(a, s).expect("cow storage"); + let sdeep = ov_deep.storage(a, s).expect("deep storage"); + assert_eq!( + scow, sdeep, + "{label}: overlay storage mismatch at {a} / {s}" + ); + } + } +} + +/// The core gate: drive one cache through every mutation kind and assert the COW +/// snapshot stays read-identical to the deep-clone reference after each step. +#[tokio::test(flavor = "multi_thread")] +async fn cow_snapshot_matches_deep_clone_through_mutations() -> Result<()> { + let mut cache = setup_cache().await?; + + let token = Address::repeat_byte(0x11); // cleared layer-1 account (MockERC20) + let owner = Address::repeat_byte(0x22); + let recipient = Address::repeat_byte(0x33); + let pool = Address::repeat_byte(0x77); // layer-2-only, non-cleared + let pool2 = Address::repeat_byte(0x88); // write-through target absent from layer 1 + let pool3 = Address::repeat_byte(0x99); // appears via simulated lazy fetch + let ghost = Address::repeat_byte(0xEE); // becomes NotExisting + + let balance_slot = U256::from(MOCK_ERC20_BALANCE_SLOT); + let owner_bal = mapping_slot(owner, MOCK_ERC20_BALANCE_SLOT); + let recip_bal = mapping_slot(recipient, MOCK_ERC20_BALANCE_SLOT); + + let addrs = [token, owner, recipient, pool, pool2, pool3, ghost]; + // Probe real slots plus an always-absent slot (both must agree on None). + let slots = [ + balance_slot, + owner_bal, + recip_bal, + U256::from(0u64), + U256::from(1u64), + U256::from(7u64), + U256::from(424_242u64), // never set anywhere + ]; + + // 1. Empty cache. + assert_equivalent(&mut cache, &addrs, &slots, "empty"); + + // 2. Layer-1 account inserts. + install_default_account(&mut cache, Address::ZERO); + install_default_account(&mut cache, owner); + install_default_account(&mut cache, recipient); + install_mock_erc20(&mut cache, token); + assert_equivalent(&mut cache, &addrs, &slots, "after account inserts"); + + // 3. Layer-1 storage inserts (mapping balances on the cleared token). + cache.insert_mapping_storage_slot(token, balance_slot, owner, U256::from(1_000u64))?; + cache.insert_mapping_storage_slot(token, balance_slot, recipient, U256::ZERO)?; + assert_equivalent(&mut cache, &addrs, &slots, "after layer-1 storage"); + + // 4. write-through to an address PRESENT in layer 1 (shadowed there). + cache.apply_updates(&[StateUpdate::slot(token, owner_bal, U256::from(2_000u64))]); + assert_equivalent( + &mut cache, + &addrs, + &slots, + "after write-through (in layer 1)", + ); + + // 5. write-through to an address ABSENT from layer 1 (layer-2-only — the §3 + // footgun: the base must capture it). + cache.apply_updates(&[StateUpdate::slot( + pool2, + U256::from(7u64), + U256::from(55u64), + )]); + assert_equivalent( + &mut cache, + &addrs, + &slots, + "after write-through (layer-2-only)", + ); + + // 6. relative native-balance delta. + cache.apply_updates(&[StateUpdate::balance_delta( + owner, + SlotDelta::Add(U256::from(500)), + )]); + assert_equivalent(&mut cache, &addrs, &slots, "after balance delta"); + + // 7. committing revm call (mutates layer 1 only — never stales the base). + transfer(&mut cache, token, owner, recipient, U256::from(250u64))?; + assert_equivalent(&mut cache, &addrs, &slots, "after committed transfer"); + + // 8. layer-2-only cold backfill, including OVERWRITING an existing slot at an + // unchanged length (must still invalidate the base). + cache.inject_storage_batch(&[(pool, U256::from(0u64), U256::from(111u64))]); + assert_equivalent(&mut cache, &addrs, &slots, "after inject (new)"); + cache.inject_storage_batch(&[(pool, U256::from(0u64), U256::from(222u64))]); + assert_equivalent( + &mut cache, + &addrs, + &slots, + "after inject (overwrite, same len)", + ); + + // 9. simulated UNCONTROLLED layer-2 growth (a lazy RPC fetch / prefetch writes + // `BlockchainDb` from inside foundry-fork-db, bypassing our write funnel): + // a brand-new account+slot, and a NEW slot on the existing `pool`. + { + let bdb = cache.unchecked_blockchain_db(); + bdb.storage() + .write() + .entry(pool3) + .or_default() + .insert(U256::from(1u64), U256::from(909u64)); + bdb.accounts().write().insert( + pool3, + AccountInfo { + balance: U256::from(5u64), + ..Default::default() + }, + ); + // New slot on an existing base account (len changes → growth scan must catch). + bdb.storage() + .write() + .entry(pool) + .or_default() + .insert(U256::from(1u64), U256::from(333u64)); + } + assert_equivalent( + &mut cache, + &addrs, + &slots, + "after uncontrolled layer-2 growth", + ); + + // 10. purge. + cache.purge_account(owner); + assert_equivalent(&mut cache, &addrs, &slots, "after purge_account"); + + // 11. NotExisting account (absent to the EVM; storage reads ZERO). + cache.db_mut().insert_account_info( + ghost, + AccountInfo { + balance: U256::from(1u64), + ..Default::default() + }, + ); + cache + .db_mut() + .cache + .accounts + .get_mut(&ghost) + .expect("ghost present") + .account_state = AccountState::NotExisting; + assert_equivalent(&mut cache, &addrs, &slots, "after NotExisting"); + + // 12. set_block (re-pin → full base rebuild path). + cache.set_block(None); + assert_equivalent(&mut cache, &addrs, &slots, "after set_block"); + + Ok(()) +} + +/// Escape-hatch re-honest hook (adversarial-review finding). A direct, out-of-band +/// layer-2 write through `unchecked_blockchain_db()` that overwrites an existing slot at an +/// unchanged slot count is the one mutation the count-based growth scan cannot see, +/// so the memoized base can go stale. `invalidate_snapshot_base()` must restore +/// read-equivalence with the deep-clone reference. +#[tokio::test(flavor = "multi_thread")] +async fn invalidate_snapshot_base_rehonest_after_escape_hatch_write() -> Result<()> { + let mut cache = setup_cache().await?; + let pool = Address::repeat_byte(0x77); // layer-2-only, non-shadowed + let slot = U256::from(0u64); + + cache.inject_storage_batch(&[(pool, slot, U256::from(111u64))]); + let _warm = cache.create_snapshot(); // memoize the base at 111 + + // Out-of-band overwrite at unchanged length (bypasses the write funnel). + { + let bdb = cache.unchecked_blockchain_db(); + bdb.storage() + .write() + .entry(pool) + .or_default() + .insert(slot, U256::from(222u64)); + } + + // The documented re-honest hook must make the next snapshot reflect the write. + cache.invalidate_snapshot_base(); + let cow = cache.create_snapshot(); + let deep = cache.create_snapshot_deep_clone(); + assert_eq!( + cow.storage_value(pool, slot), + deep.storage_value(pool, slot), + "invalidate_snapshot_base must re-honest the base after an out-of-band write" + ); + assert_eq!(cow.storage_value(pool, slot), Some(U256::from(222u64))); + Ok(()) +} + +/// Escape-hatch re-honest hook for account-map overwrites. A direct update of an +/// existing layer-2 account's balance/code at an unchanged account count is also +/// invisible to the count/absence growth scan, so callers must invalidate the +/// memoized base after the direct write lands. +#[tokio::test(flavor = "multi_thread")] +async fn invalidate_snapshot_base_rehonest_after_existing_account_write() -> Result<()> { + let mut cache = setup_cache().await?; + let account = Address::repeat_byte(0xA1); + let code_v1 = Bytecode::new_raw(Bytes::from(vec![0x60u8, 0x01])); + let code_v2 = Bytecode::new_raw(Bytes::from(vec![0x60u8, 0x02, 0x60, 0x03])); + let h1 = code_v1.hash_slow(); + let h2 = code_v2.hash_slow(); + assert_ne!(h1, h2); + + let original = AccountInfo { + balance: U256::from(111u64), + nonce: 1, + code_hash: h1, + code: Some(code_v1.clone()), + account_id: None, + }; + let updated = AccountInfo { + balance: U256::from(222u64), + nonce: 2, + code_hash: h2, + code: Some(code_v2.clone()), + account_id: None, + }; + + { + let bdb = cache.unchecked_blockchain_db(); + bdb.accounts().write().insert(account, original.clone()); + } + let warm = cache.create_snapshot(); // memoize the base with `original`. + let mut ov_warm = EvmOverlay::new(Arc::clone(&warm), None); + let warm_info = ov_warm + .basic(account) + .expect("warm basic") + .expect("warm account"); + assert_eq!(warm_info.balance, original.balance); + assert_eq!(warm_info.nonce, original.nonce); + assert_eq!(warm_info.code_hash, h1); + assert_eq!( + ov_warm + .code_by_hash(h1) + .expect("warm code") + .original_bytes(), + code_v1.original_bytes() + ); + + // Out-of-band account overwrite at unchanged account count (bypasses the + // write funnel and is not detectable by the growth scan). + { + let bdb = cache.unchecked_blockchain_db(); + let mut accounts = bdb.accounts().write(); + assert!( + accounts.contains_key(&account), + "test must update an existing account" + ); + let len_before = accounts.len(); + accounts.insert(account, updated.clone()); + assert_eq!( + accounts.len(), + len_before, + "test must keep the account count unchanged" + ); + } + + cache.invalidate_snapshot_base(); + let cow = cache.create_snapshot(); + let deep = cache.create_snapshot_deep_clone(); + let mut ov_cow = EvmOverlay::new(Arc::clone(&cow), None); + let mut ov_deep = EvmOverlay::new(Arc::clone(&deep), None); + + let cow_basic = ov_cow.basic(account).expect("cow basic"); + let deep_basic = ov_deep.basic(account).expect("deep basic"); + assert!( + account_eq(&cow_basic, &deep_basic), + "invalidate_snapshot_base must re-honest the base after an out-of-band account write: cow={cow_basic:?} deep={deep_basic:?}" + ); + let cow_info = cow_basic.expect("updated cow account"); + assert_eq!(cow_info.balance, updated.balance); + assert_eq!(cow_info.nonce, updated.nonce); + assert_eq!(cow_info.code_hash, h2); + assert_eq!( + ov_cow.code_by_hash(h2).expect("cow h2").original_bytes(), + ov_deep.code_by_hash(h2).expect("deep h2").original_bytes(), + "updated code hash must match the deep clone" + ); + assert_eq!( + ov_cow.code_by_hash(h2).expect("cow h2").original_bytes(), + code_v2.original_bytes() + ); + assert!( + ov_deep.code_by_hash(h1).expect("deep h1").is_empty(), + "sanity: deep clone drops the unreferenced old hash" + ); + assert_eq!( + ov_cow.code_by_hash(h1).expect("cow h1").original_bytes(), + ov_deep.code_by_hash(h1).expect("deep h1").original_bytes(), + "the old code hash must not linger after invalidation" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn with_blockchain_db_mut_rehonest_after_storage_overwrite() -> Result<()> { + let mut cache = setup_cache().await?; + let pool = Address::repeat_byte(0x78); + let slot = U256::from(0u64); + + cache.inject_storage_batch(&[(pool, slot, U256::from(111u64))]); + let _warm = cache.create_snapshot(); + + cache.with_blockchain_db_mut(|bdb| { + bdb.storage() + .write() + .entry(pool) + .or_default() + .insert(slot, U256::from(222u64)); + }); + + let cow = cache.create_snapshot(); + let deep = cache.create_snapshot_deep_clone(); + assert_eq!( + cow.storage_value(pool, slot), + deep.storage_value(pool, slot), + "with_blockchain_db_mut must invalidate the COW base after storage writes" + ); + assert_eq!(cow.storage_value(pool, slot), Some(U256::from(222u64))); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn with_blockchain_db_mut_rehonest_after_account_overwrite() -> Result<()> { + let mut cache = setup_cache().await?; + let account = Address::repeat_byte(0xA2); + let code_v1 = Bytecode::new_raw(Bytes::from(vec![0x60u8, 0x01])); + let code_v2 = Bytecode::new_raw(Bytes::from(vec![0x60u8, 0x02])); + let h1 = code_v1.hash_slow(); + let h2 = code_v2.hash_slow(); + let original = AccountInfo { + balance: U256::from(111u64), + nonce: 1, + code_hash: h1, + code: Some(code_v1), + account_id: None, + }; + let updated = AccountInfo { + balance: U256::from(222u64), + nonce: 2, + code_hash: h2, + code: Some(code_v2.clone()), + account_id: None, + }; + + cache.with_blockchain_db_mut(|bdb| { + bdb.accounts().write().insert(account, original); + }); + let _warm = cache.create_snapshot(); + + cache.with_blockchain_db_mut(|bdb| { + let mut accounts = bdb.accounts().write(); + let len_before = accounts.len(); + accounts.insert(account, updated.clone()); + assert_eq!(accounts.len(), len_before); + }); + + let cow = cache.create_snapshot(); + let deep = cache.create_snapshot_deep_clone(); + let mut ov_cow = EvmOverlay::new(Arc::clone(&cow), None); + let mut ov_deep = EvmOverlay::new(Arc::clone(&deep), None); + let cow_basic = ov_cow.basic(account).expect("cow basic"); + let deep_basic = ov_deep.basic(account).expect("deep basic"); + assert!( + account_eq(&cow_basic, &deep_basic), + "with_blockchain_db_mut must invalidate the COW base after account writes: cow={cow_basic:?} deep={deep_basic:?}" + ); + assert_eq!( + cow_basic.expect("updated cow account").balance, + updated.balance + ); + assert_eq!( + ov_cow.code_by_hash(h2).expect("cow h2").original_bytes(), + code_v2.original_bytes() + ); + Ok(()) +} + +/// Regression (review finding P2): the COW partial rebuild must not leave a stale +/// `code_by_hash` entry when a base account is recoded or purged. Warm the base +/// with a code-bearing account, recode it in layer 2, dirty it via a controlled +/// per-address write (so `refresh_base` takes the Case-4 *partial* rebuild path, +/// not a full rebuild), and assert the old hash no longer resolves — matching the +/// deep-clone reference, which rebuilds its code index from current accounts. +#[tokio::test(flavor = "multi_thread")] +async fn cow_code_index_matches_deep_clone_after_base_account_recoded() -> Result<()> { + let mut cache = setup_cache().await?; + let contract = Address::repeat_byte(0xc0); + let code_v1 = Bytecode::new_raw(Bytes::from(vec![0x60u8, 0x01])); + let code_v2 = Bytecode::new_raw(Bytes::from(vec![0x60u8, 0x02, 0x60, 0x03])); + let h1 = code_v1.hash_slow(); + let h2 = code_v2.hash_slow(); + assert_ne!(h1, h2); + + // Seed a code-bearing account (code_v1) directly into the cold base (layer 2), + // then re-honest the memoized base. + let put_account = |cache: &EvmCache, code: &Bytecode, hash| { + cache.unchecked_blockchain_db().accounts().write().insert( + contract, + AccountInfo { + balance: U256::from(1u64), + nonce: 1, + code_hash: hash, + code: Some(code.clone()), + account_id: None, + }, + ); + }; + put_account(&cache, &code_v1, h1); + cache.invalidate_snapshot_base(); + let warm = cache.create_snapshot(); // base now indexes h1 -> code_v1 + let mut ov_warm = EvmOverlay::new(Arc::clone(&warm), None); + assert_eq!( + ov_warm + .code_by_hash(h1) + .expect("warm code") + .original_bytes(), + code_v1.original_bytes(), + "warm snapshot must resolve the seeded code" + ); + + // Recode the base account to code_v2 (out-of-band), then dirty `contract` via a + // controlled per-address write so the next snapshot takes the Case-4 partial + // rebuild — exactly the path that previously failed to prune the old hash. + put_account(&cache, &code_v2, h2); + cache.apply_updates(&[StateUpdate::slot( + contract, + U256::from(0u64), + U256::from(9u64), + )]); + + let cow = cache.create_snapshot(); + let deep = cache.create_snapshot_deep_clone(); + let mut ov_cow = EvmOverlay::new(Arc::clone(&cow), None); + let mut ov_deep = EvmOverlay::new(Arc::clone(&deep), None); + + // The new hash resolves identically... + assert_eq!( + ov_cow.code_by_hash(h2).expect("cow h2").original_bytes(), + ov_deep.code_by_hash(h2).expect("deep h2").original_bytes(), + "new code hash must match the deep clone" + ); + // ...and the now-unreferenced old hash must NOT linger in the COW base: both + // resolve to empty (the deep clone never had it after the recode). + assert!( + ov_deep.code_by_hash(h1).expect("deep h1").is_empty(), + "sanity: deep clone drops the unreferenced old hash" + ); + assert_eq!( + ov_cow.code_by_hash(h1).expect("cow h1").original_bytes(), + ov_deep.code_by_hash(h1).expect("deep h1").original_bytes(), + "COW must not return stale bytecode for the recoded account's old hash" + ); + Ok(()) +} + +/// COW must not alias: a snapshot taken earlier is unaffected by a later mutation +/// of the same address (the memoized base is rebuilt copy-on-write, not mutated). +#[tokio::test(flavor = "multi_thread")] +async fn earlier_snapshot_unaffected_by_later_base_mutation() -> Result<()> { + let mut cache = setup_cache().await?; + let pool = Address::repeat_byte(0x77); + let slot = U256::from(3u64); + + cache.inject_storage_batch(&[(pool, slot, U256::from(100u64))]); + let early = cache.create_snapshot(); + assert_eq!(early.storage_value(pool, slot), Some(U256::from(100u64))); + + // Mutate the same base slot, then take a second snapshot. + cache.inject_storage_batch(&[(pool, slot, U256::from(200u64))]); + let late = cache.create_snapshot(); + + assert_eq!( + early.storage_value(pool, slot), + Some(U256::from(100u64)), + "the earlier snapshot must still read the pre-mutation value" + ); + assert_eq!( + late.storage_value(pool, slot), + Some(U256::from(200u64)), + "the later snapshot reflects the mutation" + ); + Ok(()) +} + +/// `EvmOverlay::reset` clears the dirty layer so the overlay reads the pristine +/// snapshot again — equivalent to a fresh overlay. +#[tokio::test(flavor = "multi_thread")] +async fn overlay_reset_restores_pristine_snapshot_reads() -> Result<()> { + let mut cache = setup_cache().await?; + let token = Address::repeat_byte(0x44); + let owner = Address::repeat_byte(0x55); + let recipient = Address::repeat_byte(0x66); + + install_default_account(&mut cache, Address::ZERO); + install_default_account(&mut cache, owner); + install_default_account(&mut cache, recipient); + install_mock_erc20(&mut cache, token); + cache.insert_mapping_storage_slot( + token, + U256::from(MOCK_ERC20_BALANCE_SLOT), + owner, + U256::from(10_000u64), + )?; + cache.insert_mapping_storage_slot( + token, + U256::from(MOCK_ERC20_BALANCE_SLOT), + recipient, + U256::ZERO, + )?; + + let snapshot = cache.create_snapshot(); + let mut overlay = EvmOverlay::new(Arc::clone(&snapshot), None); + + // Mutate the dirty layer with a committing transfer through the overlay. + overlay.simulate_with_transfer_tracking( + owner, + token, + MockERC20::transferCall { + to: recipient, + amount: U256::from(4_000u64), + } + .abi_encode() + .into(), + owner, + Some([token]), + true, // commit into the overlay's dirty layer + )?; + assert_eq!( + overlay_balance_of(&mut overlay, token, owner)?, + U256::from(6_000u64), + "post-transfer dirty-layer balance" + ); + + // reset() drops the dirty layer; reads see the pristine snapshot again. + overlay.reset(); + assert_eq!( + overlay_balance_of(&mut overlay, token, owner)?, + U256::from(10_000u64), + "after reset the overlay reads the pristine snapshot" + ); + + // A reset-recycled overlay matches a brand-new overlay across two sims. + let mut fresh = EvmOverlay::new(Arc::clone(&snapshot), None); + assert_eq!( + overlay_balance_of(&mut overlay, token, owner)?, + overlay_balance_of(&mut fresh, token, owner)?, + "recycled overlay == fresh overlay" + ); + Ok(()) +} + +/// Buffer reuse must not change results: repeated calls on one overlay return the +/// same value as the first (the reusable shared-memory buffer is cleared, not +/// corrupted, between builds). +#[tokio::test(flavor = "multi_thread")] +async fn overlay_buffer_reuse_is_result_stable() -> Result<()> { + let mut cache = setup_cache().await?; + let token = Address::repeat_byte(0x44); + let owner = Address::repeat_byte(0x55); + + install_default_account(&mut cache, Address::ZERO); + install_default_account(&mut cache, owner); + install_mock_erc20(&mut cache, token); + cache.insert_mapping_storage_slot( + token, + U256::from(MOCK_ERC20_BALANCE_SLOT), + owner, + U256::from(7_777u64), + )?; + + let snapshot = cache.create_snapshot(); + let mut overlay = EvmOverlay::new(snapshot, None); + + let first = overlay_balance_of(&mut overlay, token, owner)?; + for _ in 0..16 { + assert_eq!( + overlay_balance_of(&mut overlay, token, owner)?, + first, + "repeated calls reusing the buffer must be stable" + ); + } + assert_eq!(first, U256::from(7_777u64)); + Ok(()) +} + +/// Compile-time guards: the COW representation keeps the thread-safety contract. +#[test] +fn snapshot_send_sync_overlay_send() { + fn assert_send_sync() {} + fn assert_send() {} + assert_send_sync::(); + assert_send_sync::>(); + assert_send::(); +} diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index d18d224..bc6b2be 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -9,7 +9,7 @@ //! //! Layering vocabulary mirrors `tests/state_update.rs`: //! - **layer 1 / overlay** = the CacheDB overlay (`db_mut().cache.accounts`). -//! - **layer 2 / backend** = the BlockchainDb backend (`blockchain_db()`). +//! - **layer 2 / backend** = the BlockchainDb backend (`unchecked_blockchain_db()`). mod common; @@ -41,7 +41,7 @@ fn mapping_slot(owner: Address, mapping_slot: u64) -> U256 { /// Value of a slot in the BlockchainDb backend (layer 2) only. fn backend_slot(cache: &EvmCache, addr: Address, slot: U256) -> Option { cache - .blockchain_db() + .unchecked_blockchain_db() .storage() .read() .get(&addr) diff --git a/tests/freshness.rs b/tests/freshness.rs index 353f827..aa2455a 100644 --- a/tests/freshness.rs +++ b/tests/freshness.rs @@ -16,8 +16,8 @@ use alloy_sol_types::SolCall; use anyhow::Result; use common::{ - MOCK_ERC20_BALANCE_SLOT, MockERC20, failing_fetcher, install_default_account, - install_mock_erc20, panicking_fetcher, setup_cache, stub_fetcher, tracking_fetcher, + Gate, MOCK_ERC20_BALANCE_SLOT, MockERC20, failing_fetcher, gated_tracking_fetcher, + install_default_account, install_mock_erc20, panicking_fetcher, setup_cache, stub_fetcher, }; use evm_fork_cache::cache::{ EvmCache, EvmOverlay, SimStatus, SlotObservationTracker, StorageBatchFetchFn, @@ -248,7 +248,7 @@ async fn purge_account_drops_account_and_storage_from_both_layers() -> Result<() ); // Account gone from the backend accounts map. { - let accounts = cache.blockchain_db().accounts().read(); + let accounts = cache.unchecked_blockchain_db().accounts().read(); assert!(!accounts.contains_key(&token), "backend account removed"); } @@ -1185,6 +1185,12 @@ async fn run_unverified_on_fetcher_error() -> Result<()> { // T3 (part 2): into_optimistic aborts the validation task. The fetcher WOULD // queue a correction (it reports a changed value), so if the abort failed we // would observe a non-zero pending queue. We assert it stays 0. +// +// Determinism mirrors the Drop-abort test below: the validator is allowed to +// reach the synchronous fetch, but the gated fetch cannot return until after +// `into_optimistic()` has set the cancel flag. That makes the product guarantee +// precise: a cancel observed at the post-fetch checkpoint suppresses all +// side-effects, including pending corrections and re-run accounting. #[tokio::test(flavor = "multi_thread")] async fn run_into_optimistic_aborts_validation() -> Result<()> { let token = Address::repeat_byte(0x44); @@ -1192,11 +1198,12 @@ async fn run_into_optimistic_aborts_validation() -> Result<()> { let recipient = Address::repeat_byte(0x66); let mut cache = cache_with_balance(token, owner, U256::from(1000)).await?; + let gate = Gate::new(); // A CHANGED value: if the validator ran, it would queue a correction. - cache.set_storage_batch_fetcher(stub_fetcher(HashMap::from([( - (token, balance_slot_for(owner)), - U256::from(50), - )]))); + cache.set_storage_batch_fetcher(gated_tracking_fetcher( + HashMap::from([((token, balance_slot_for(owner)), U256::from(50))]), + gate.clone(), + )); let mut controller = FreshnessController::new(FreshnessRegistry::new(), AlwaysVerify); let sim = controller.run( @@ -1208,6 +1215,7 @@ async fn run_into_optimistic_aborts_validation() -> Result<()> { )], )?; let results = sim.into_optimistic(); // aborts the background validation + gate.release(); assert_eq!(results.len(), 1); // Give any (incorrectly) surviving task a chance to run, then assert no @@ -1224,9 +1232,20 @@ async fn run_into_optimistic_aborts_validation() -> Result<()> { // T3 (part 1): dropping the SpeculativeSim (no validate/into_optimistic) aborts // the validation task before it can push a correction. The fetcher reports a -// CHANGED value and flips a "called" flag; after the drop + settle we assert the -// pending queue is empty (and, robustly, that the fetcher was never even -// reached) — proving the abort beat the push. +// CHANGED value, so an *uncancelled* validator would queue a correction and bump +// the re-run count; we assert neither happens after the drop. +// +// Determinism: the validator's only correction-queuing path runs *after* its +// fetch returns (the post-fetch cancel checkpoint in `run_validator` gates it). +// We make that ordering race-free with a gate the test controls — the fetcher +// blocks until `gate.release()`, and we release only *after* `drop(sim)` has set +// the cancel flag. So however the multi-thread scheduler interleaves the spawned +// task and this thread, the fetch (and thus the post-fetch checkpoint) can only +// complete once cancellation is already observable, and the correction is +// suppressed. We deliberately do NOT assert the fetcher was never reached: the +// product only guarantees a cancel seen at a checkpoint suppresses side effects, +// not that an in-flight fetch is skipped — asserting the latter was the original +// over-strict, racy condition. #[tokio::test(flavor = "multi_thread")] async fn dropping_speculative_sim_aborts_before_queueing_correction() -> Result<()> { let token = Address::repeat_byte(0x44); @@ -1234,10 +1253,10 @@ async fn dropping_speculative_sim_aborts_before_queueing_correction() -> Result< let recipient = Address::repeat_byte(0x66); let mut cache = cache_with_balance(token, owner, U256::from(1000)).await?; - let called = Arc::new(std::sync::atomic::AtomicBool::new(false)); - cache.set_storage_batch_fetcher(tracking_fetcher( + let gate = Gate::new(); + cache.set_storage_batch_fetcher(gated_tracking_fetcher( HashMap::from([((token, balance_slot_for(owner)), U256::from(50))]), - Arc::clone(&called), + gate.clone(), )); let mut controller = FreshnessController::new(FreshnessRegistry::new(), AlwaysVerify); @@ -1249,9 +1268,12 @@ async fn dropping_speculative_sim_aborts_before_queueing_correction() -> Result< transfer_calldata(recipient, U256::from(100)), )], )?; - // Drop immediately, with NO intervening await, so the abort flag is set - // before the spawned task is ever polled. + // Drop with NO intervening await, then release the gate. Releasing only after + // the drop guarantees the validator's fetch (if it even reaches it) returns + // strictly after the cancel flag is set, so its post-fetch checkpoint bails + // out before queuing anything. drop(sim); + gate.release(); settle().await; @@ -1260,10 +1282,6 @@ async fn dropping_speculative_sim_aborts_before_queueing_correction() -> Result< 0, "dropping the sim must abort validation before it queues a correction" ); - assert!( - !called.load(std::sync::atomic::Ordering::SeqCst), - "the aborted validator should never have reached the fetcher" - ); assert_eq!(controller.rerun_count(), 0, "no re-run after abort"); Ok(()) } @@ -1562,8 +1580,8 @@ async fn run_unverified_without_fetcher() -> Result<()> { // A `from_backend` cache exposes no fetcher (no provider captured). let base = cache_with_balance(token, owner, U256::from(1000)).await?; let mut cache = EvmCache::from_backend( - base.backend().clone(), - base.blockchain_db().clone(), + base.unchecked_backend().clone(), + base.unchecked_blockchain_db().clone(), None, base.chain_id(), None, diff --git a/tests/serialization_roundtrip.rs b/tests/serialization_roundtrip.rs index 71850cf..27bdc32 100644 --- a/tests/serialization_roundtrip.rs +++ b/tests/serialization_roundtrip.rs @@ -87,6 +87,16 @@ fn immutable_data_cache_round_trips() { let len_before = cache.len(); cache.save(&path).expect("save immutable cache"); + let bytes = std::fs::read(&path).expect("read immutable cache file"); + assert!( + bytes.starts_with(b"EFCMETA\0"), + "immutable cache must carry a magic header" + ); + assert_eq!( + &bytes[8..12], + &1u32.to_le_bytes(), + "immutable cache must carry an explicit version" + ); let loaded = ImmutableDataCache::load(&path).expect("load immutable cache"); // Counts and scalar values survive the round trip. @@ -118,6 +128,20 @@ fn immutable_data_cache_round_trips() { assert_eq!(bal.last_change_block, U256::from(18_000_000u64)); } +#[test] +fn immutable_data_cache_load_legacy_raw_bincode_is_none() { + let dir = TempDir::new("immutable_legacy"); + let path = dir.path("legacy_immutable_data.bin"); + let mut cache = ImmutableDataCache::default(); + cache.set_token_decimals(Address::repeat_byte(0xA1), 6); + std::fs::write(&path, bincode::serialize(&cache).unwrap()).expect("write legacy cache"); + + assert!( + ImmutableDataCache::load(&path).is_none(), + "unversioned legacy bincode must be treated as a cache miss" + ); +} + #[test] fn immutable_data_cache_load_missing_file_is_none() { let dir = TempDir::new("immutable_missing"); @@ -180,6 +204,16 @@ mod tick_snapshots { assert_eq!(cache.len(), 1); cache.save(&path).expect("save tick cache"); + let bytes = std::fs::read(&path).expect("read tick cache file"); + assert!( + bytes.starts_with(b"EFCTICK\0"), + "tick snapshot cache must carry a magic header" + ); + assert_eq!( + &bytes[8..12], + &1u32.to_le_bytes(), + "tick snapshot cache must carry an explicit version" + ); let loaded = V3TickSnapshotCache::load(&path).expect("load tick cache"); let snap = loaded.get(pool).expect("snapshot present"); @@ -190,6 +224,24 @@ mod tick_snapshots { assert_eq!(snap.to_ticks(), ticks, "ticks survive round trip"); } + #[test] + fn v3_tick_snapshot_cache_load_legacy_raw_bincode_is_none() { + let dir = TempDir::new("v3_ticks_legacy"); + let path = dir.path("legacy_v3_tick_snapshots.bin"); + let pool = Address::repeat_byte(0x77); + let mut cache = V3TickSnapshotCache::default(); + cache.set( + pool, + V3PoolTickSnapshot::from_pool_data(&HashMap::new(), &HashMap::new(), 0, 0), + ); + std::fs::write(&path, bincode::serialize(&cache).unwrap()).expect("write legacy cache"); + + assert!( + V3TickSnapshotCache::load(&path).is_none(), + "unversioned legacy bincode must be treated as a cache miss" + ); + } + #[test] fn v3_tick_snapshot_silently_drops_unparseable_keys() { // Pin the documented behavior (KNOWN_ISSUES): a string key that does not diff --git a/tests/shared_memory_capacity.rs b/tests/shared_memory_capacity.rs new file mode 100644 index 0000000..f3fc029 --- /dev/null +++ b/tests/shared_memory_capacity.rs @@ -0,0 +1,137 @@ +//! Offline tests for the configurable EVM shared-memory pre-allocation +//! ([`SharedMemoryCapacity`]) wired through [`EvmCacheBuilder`]. +//! +//! Covers the three user-facing behaviors: the default, an explicit `Fixed` size, +//! and `Auto` sizing from the chain state loaded at build time (the +//! "intelligently allocate from a bincode state file" path). All offline. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy_primitives::{Address, U256}; +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, EvmCacheBuilder, SharedMemoryCapacity}; + +fn mock_provider() -> Arc> { + Arc::new(RootProvider::::new(RpcClient::mocked( + Asserter::new(), + ))) +} + +/// A unique temp dir for a disk-backed cache (no two tests collide). +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_smc_{tag}_{nanos}")) +} + +#[tokio::test(flavor = "multi_thread")] +async fn default_capacity_is_fixed_64k() -> Result<()> { + let cache = EvmCacheBuilder::new(mock_provider()).build().await; + assert_eq!( + cache.shared_memory_capacity(), + 65_536, + "the default must be Fixed(64 * 1024)" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn fixed_capacity_is_honored() -> Result<()> { + let cache = EvmCacheBuilder::new(mock_provider()) + .shared_memory_capacity(SharedMemoryCapacity::Fixed(8_192)) + .build() + .await; + assert_eq!(cache.shared_memory_capacity(), 8_192); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn auto_capacity_with_no_loaded_state_falls_back_to_floor() -> Result<()> { + // No cache_config → nothing loaded → Auto resolves to the 64 KiB floor. + let cache = EvmCacheBuilder::new(mock_provider()) + .shared_memory_capacity(SharedMemoryCapacity::Auto) + .build() + .await; + assert_eq!( + cache.shared_memory_capacity(), + SharedMemoryCapacity::MIN_AUTO + ); + Ok(()) +} + +/// The headline: `Auto` sizes the buffer from the chain state in a loaded bincode +/// state file. A first cache persists 10 000 storage slots; a second cache built +/// with `Auto` over the same `CacheConfig` loads them and pre-allocates +/// `10_000 * 16 = 160_000` bytes (vs. the 64 KiB default). +#[tokio::test(flavor = "multi_thread")] +async fn auto_capacity_scales_with_loaded_binary_state() -> Result<()> { + let dir = unique_cache_dir("auto"); + let cfg = CacheConfig::new(&dir, 1, Default::default(), Default::default()); + + // First cache: seed 10k slots into layer 2 and persist to the bincode state file. + { + let mut cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg.clone()) + .build() + .await; + let token = Address::repeat_byte(0x11); + let batch: Vec<(Address, U256, U256)> = (0..10_000u64) + .map(|i| (token, U256::from(i), U256::from(i + 1))) + .collect(); + cache.inject_storage_batch(&batch); + cache.flush()?; // writes evm_state.bin + } + + // Second cache: Auto over the same config loads the 10k slots and sizes from them. + let reloaded = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg.clone()) + .shared_memory_capacity(SharedMemoryCapacity::Auto) + .build() + .await; + assert_eq!( + reloaded.shared_memory_capacity(), + 160_000, + "Auto must size from the 10k loaded slots (10_000 * 16 bytes)" + ); + + // A Fixed override ignores the loaded state. + let fixed = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg.clone()) + .shared_memory_capacity(SharedMemoryCapacity::Fixed(64 * 1024)) + .build() + .await; + assert_eq!(fixed.shared_memory_capacity(), 65_536); + + let _ = std::fs::remove_dir_all(&dir); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn flush_reports_unwritable_cache_paths() -> Result<()> { + let path_conflict = unique_cache_dir("flush_error"); + std::fs::write(&path_conflict, b"not a directory")?; + let cfg = CacheConfig::new(&path_conflict, 1, Default::default(), Default::default()); + let cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg) + .build() + .await; + + let err = cache + .flush() + .expect_err("flush must report persistence failures"); + let rendered = format!("{err:#}"); + assert!( + rendered.contains("directory") || rendered.contains("Not a directory"), + "unexpected error: {rendered}" + ); + + let _ = std::fs::remove_file(&path_conflict); + Ok(()) +} diff --git a/tests/state_update.rs b/tests/state_update.rs index 2ee024e..815758e 100644 --- a/tests/state_update.rs +++ b/tests/state_update.rs @@ -10,7 +10,7 @@ //! - **layer 1 / overlay** = the CacheDB overlay (`db_mut().cache.accounts`), //! which wins on reads. //! - **layer 2 / backend** = the BlockchainDb backend -//! (`blockchain_db().storage()` / `.accounts()`). +//! (`unchecked_blockchain_db().storage()` / `.accounts()`). mod common; @@ -22,8 +22,8 @@ use common::{ }; use evm_fork_cache::cache::EvmCache; use evm_fork_cache::{ - AccountPatch, PurgeScope, SkippedBalanceDelta, SkippedDelta, SkippedMask, SlotChange, - SlotDelta, StateDiff, StateUpdate, + AccountPatch, PurgeScope, SkippedAccountPatch, SkippedBalanceDelta, SkippedDelta, SkippedMask, + SlotChange, SlotDelta, StateDiff, StateUpdate, }; use revm::state::{AccountInfo, Bytecode}; @@ -52,7 +52,7 @@ fn overlay_slot(cache: &mut EvmCache, addr: Address, slot: U256) -> Option /// Value of a slot in the BlockchainDb backend (layer 2) only. fn backend_slot(cache: &EvmCache, addr: Address, slot: U256) -> Option { cache - .blockchain_db() + .unchecked_blockchain_db() .storage() .read() .get(&addr) @@ -87,7 +87,7 @@ fn overlay_nonce(cache: &mut EvmCache, addr: Address) -> Option { /// Backend (layer 2) balance for `addr`, if a backend account exists. fn backend_balance(cache: &EvmCache, addr: Address) -> Option { cache - .blockchain_db() + .unchecked_blockchain_db() .accounts() .read() .get(&addr) @@ -133,6 +133,13 @@ fn state_update_constructors_produce_expected_variants() { patch: AccountPatch::default().balance(U256::from(9)), } ); + assert_eq!( + StateUpdate::account_upsert(a, AccountPatch::default().balance(U256::from(9))), + StateUpdate::AccountUpsert { + address: a, + patch: AccountPatch::default().balance(U256::from(9)), + } + ); assert_eq!( StateUpdate::purge(a, PurgeScope::Account), StateUpdate::Purge { @@ -341,15 +348,46 @@ async fn apply_account_code_patch_recomputes_hash() -> Result<()> { } #[tokio::test] -async fn apply_account_patch_materializes_absent_account() -> Result<()> { - // An account absent from both layers is created (in the backend) by a patch, - // and the value is readable. +async fn apply_account_patch_on_cold_account_is_skipped_and_surfaced() -> Result<()> { + // A partial Account patch against a cold account must not materialize a + // default backend account, because that would mask the real on-chain account. let addr = Address::repeat_byte(0x88); let mut cache = setup_cache().await?; assert!(!overlay_has_account(&mut cache, addr)); assert_eq!(backend_balance(&cache, addr), None); - let diff = cache.apply_update(&StateUpdate::balance(addr, U256::from(1234))); + let patch = AccountPatch::default().balance(U256::from(1234)); + let diff = cache.apply_update(&StateUpdate::account(addr, patch.clone())); + + assert_eq!( + backend_balance(&cache, addr), + None, + "cold patch must not materialize a backend account" + ); + assert!(diff.accounts.is_empty()); + assert_eq!( + diff.skipped_accounts, + vec![SkippedAccountPatch { + address: addr, + patch + }] + ); + assert!(diff.has_skipped()); + assert_eq!(diff.skipped_len(), 1); + Ok(()) +} + +#[tokio::test] +async fn account_upsert_intentionally_materializes_absent_account() -> Result<()> { + let addr = Address::repeat_byte(0x88); + let mut cache = setup_cache().await?; + assert!(!overlay_has_account(&mut cache, addr)); + assert_eq!(backend_balance(&cache, addr), None); + + let diff = cache.apply_update(&StateUpdate::account_upsert( + addr, + AccountPatch::default().balance(U256::from(1234)), + )); assert_eq!(backend_balance(&cache, addr), Some(U256::from(1234))); assert_eq!(diff.accounts.len(), 1); @@ -357,6 +395,7 @@ async fn apply_account_patch_materializes_absent_account() -> Result<()> { diff.accounts[0].balance, Some((U256::ZERO, U256::from(1234))) ); + assert!(diff.skipped_accounts.is_empty()); Ok(()) } @@ -394,7 +433,7 @@ async fn apply_purge_account_clears_both_layers() -> Result<()> { "backend storage gone" ); { - let accounts = cache.blockchain_db().accounts().read(); + let accounts = cache.unchecked_blockchain_db().accounts().read(); assert!(!accounts.contains_key(&token), "backend account removed"); } assert_eq!(diff.purged.len(), 1); @@ -1303,7 +1342,7 @@ async fn account_patch_on_backend_only_account_does_not_materialize_overlay() -> let acct = Address::repeat_byte(0x91); let mut cache = setup_cache().await?; // Seed only the backend (the cold-prefetched, layer-2-only case). - cache.blockchain_db().accounts().write().insert( + cache.unchecked_blockchain_db().accounts().write().insert( acct, AccountInfo { balance: U256::from(100), @@ -1582,7 +1621,7 @@ async fn account_patch_normalizes_zero_code_hash_across_layers() -> Result<()> { use revm::primitives::KECCAK_EMPTY; let acct = Address::repeat_byte(0x7f); let mut cache = setup_cache().await?; - cache.blockchain_db().accounts().write().insert( + cache.unchecked_blockchain_db().accounts().write().insert( acct, AccountInfo { balance: U256::from(1), @@ -1594,7 +1633,7 @@ async fn account_patch_normalizes_zero_code_hash_across_layers() -> Result<()> { cache.apply_update(&StateUpdate::balance(acct, U256::from(2))); let backend_hash = cache - .blockchain_db() + .unchecked_blockchain_db() .accounts() .read() .get(&acct)