From ab5064947d5d4051b5e8ccdc9399a0780fba8115 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Thu, 25 Jun 2026 14:03:19 +0100 Subject: [PATCH 1/5] Harden release readiness: chain_id config, doc accuracy, benches, tests Final pre-public-release pass closing the gaps surfaced by the readiness review. No behavioral regressions; the full CI gate (fmt, clippy -D warnings, tests, doctests, cargo doc -D warnings, cargo package) stays green. Engine / API - chain_id is now configurable instead of hard-defaulting to Arbitrum (42161). Add `EvmCacheBuilder::chain_id` (recommended) and `EvmCache::set_chain_id`; when unset, infer from the provider via `eth_chainId` and fall back to 1 (Ethereum mainnet). The inference also upgrades the bare `new`/`at_block` constructors. Resolution order: explicit builder value > disk CacheConfig > inferred > 1. (Breaking: the in-memory default changed from 42161 to inferred/1.) - events: `ingest_logs` now tracks `skipped_accounts` so a third-party decoder emitting cold `StateUpdate::Account` patches stays visible to reorg purge and freshness, matching the "every category" contract. Documentation accuracy - Narrow the freshness `Validation::Confirmed`/`Corrected` docs: reconcile covers volatile storage slots only, not account balance/nonce/code. Record the scope in KNOWN_ISSUES. - create3: relabel the factory as the ZeframLou/Solmate `CREATE3Factory` (LiFi-deployed) and disclaim that it is NOT pcaversaccio's CreateX. - access_list: drop the AMM (V2/V3 slot-selection) framing from the module and struct docs; the builder applies no per-entry selection. - Fix the StateDiff skip-accessor docs (all four skip categories), the cold definition (NotExisting overlay accounts), the cancellation/`# Panics`/empty `token_deltas` notes on the freshness optimistic loop, the `load_binary_state` read-error logging, and the overlay `block_hash`/ZERO behavior. Add KNOWN_ISSUES entries for BLOCKHASH-zero, unbounded `derived_slots`, deep-reorg under-purge, and >= 2^255 transfer magnitude. - Remove the never-shipped `SimulationErrorKind` deprecated alias. Benchmarks / packaging / community - Add a README "## Performance" section with measured COW-vs-deep-clone `create_snapshot` and overlay-reuse numbers, methodology, and reproduction. - Cargo.toml: exclude the internal phase specs and CI workflow from the package. - Add SECURITY.md and a dated CHANGELOG 0.1.0 section. Tests - Offline Multicall3 `aggregate3` coverage (input order + allowFailure) by etching the real Multicall3 runtime (new fixture), and a `TxConfig.access_list` EIP-2929 gas-accounting test. New `tests/builder_chain_id.rs` pins the chain_id order. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 +++- Cargo.toml | 6 ++ README.md | 42 +++++++++- SECURITY.md | 48 +++++++++++ docs/KNOWN_ISSUES.md | 34 +++++++- examples/freshness_multi_sim.rs | 9 +- examples/freshness_optimistic.rs | 13 ++- examples/reactive_cache.rs | 6 +- fixtures/README.md | 16 ++++ fixtures/multicall3_runtime.hex | 1 + src/access_list.rs | 24 ++++-- src/cache/binary_state.rs | 13 ++- src/cache/mod.rs | 72 ++++++++++++++-- src/cache/overlay.rs | 5 ++ src/create3.rs | 21 +++-- src/errors.rs | 7 -- src/events/mod.rs | 7 ++ src/freshness.rs | 87 ++++++++++++++------ src/state_update.rs | 48 ++++++----- tests/builder_chain_id.rs | 99 ++++++++++++++++++++++ tests/cache_state.rs | 69 ++++++++++++++++ tests/multicall.rs | 137 +++++++++++++++++++++++++++++-- 22 files changed, 693 insertions(+), 89 deletions(-) create mode 100644 SECURITY.md create mode 100644 fixtures/multicall3_runtime.hex create mode 100644 tests/builder_chain_id.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1310bc0..23e8eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,20 @@ surface freezes at 1.0. ## [Unreleased] +_Nothing yet._ + +## [0.1.0] - 2026-06-25 + This is the first release line. It captures the work done across the pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). ### Changed +- **Breaking:** the in-memory chain ID is no longer hard-defaulted to Arbitrum + (`42161`). When no chain ID is set explicitly and no disk `CacheConfig` is + supplied, the cache now infers it from the provider (`eth_chainId`) and falls + back to `1` (Ethereum mainnet) only if that query fails. Set it explicitly with + the new `EvmCacheBuilder::chain_id` / `EvmCache::set_chain_id`. - **Breaking:** extracted the old in-crate AMM adapter surface before public release. Protocol-specific storage layouts, protocol metadata, injector helpers, tick snapshots, and protocol log decoders belong in `evm-amm-state`; @@ -38,7 +47,9 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). immutable metadata. - **`EvmCacheBuilder`** — a fluent constructor (`EvmCache::builder(provider)`) subsuming the positional `with_cache` / `from_backend` constructors, with - block pin, EVM spec, cache-config, and shared-memory-capacity configuration. + block pin, EVM spec, cache-config, chain-ID, and shared-memory-capacity + configuration. `EvmCacheBuilder::chain_id` sets the `CHAINID` opcode value + explicitly (recommended); `EvmCache::set_chain_id` sets it post-construction. - **Snapshots and overlays** — `create_snapshot()` produces an immutable, `Send + Sync` `EvmSnapshot`; `EvmOverlay` is a cheap per-simulation clone for isolated parallel evaluation. @@ -235,7 +246,7 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). layouts. - Simulation entry points that distinguish failure modes return `SimulationResult` (`Result`), separating decoded reverts, - EVM halts, and host errors. `SimulationErrorKind` remains as a deprecated alias. + EVM halts, and host errors. ### Fixed @@ -327,4 +338,5 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - `EvmCache` requires a multi-thread tokio runtime for any RPC-touching path. - See [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) for current limitations. -[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/commits/main +[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/KaiCode2/evm-fork-cache/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 845b370..839c1f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ readme = "README.md" repository = "https://github.com/KaiCode2/evm-fork-cache" documentation = "https://docs.rs/evm-fork-cache" +# Keep the published crate lean: the per-phase development specs are internal +# planning artifacts (they reference an adapter surface extracted before release), +# and the CI workflow has no consumer value. The consumer-facing ROADMAP.md and +# KNOWN_ISSUES.md under docs/ are still shipped. +exclude = ["docs/phase-*-spec.md", ".github/"] + # Standalone workspace root: keeps this crate from being absorbed by any # ancestor-directory workspace and gives it its own Cargo.lock. [workspace] diff --git a/README.md b/README.md index 0c6d13b..5bd1392 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,46 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address); # } ``` +## Performance + +The headline is **Pillar A**: `create_snapshot()` is a copy-on-write view whose +cost tracks *changed* state, not *total* state — so the deeper the fork, the +larger the win over the retained `create_snapshot_deep_clone()` baseline. + +> [!NOTE] +> **Methodology.** Offline Criterion benchmark (`benches/simulation.rs`), median +> of the run, on an Apple M1 Pro (`aarch64-apple-darwin`). State is injected +> directly into the cache over a mocked provider — no network, no RPC. Absolute +> numbers vary by machine; the **ratio** is the point. "Deep clone" is the +> retained `create_snapshot_deep_clone()` (the legacy O(total state) flatten); +> "COW" is the current `create_snapshot()`. + +| Cache size (accounts × slots) | Deep clone | COW `create_snapshot` | Speedup | +|:-----------------------------:|:----------:|:---------------------:|:-------:| +| 100 × 8 | 53 µs | 2.1 µs | **~25×** | +| 1,000 × 8 | 791 µs | 19 µs | **~41×** | +| 2,000 × 16 | 3.2 ms | 52 µs | **~61×** | +| 5,000 × 16 | 9.5 ms | 113 µs | **~84×** | +| 10,000 × 16 | 16.5 ms | 214 µs | **~77×** | + +The deep clone copies every account and slot on every snapshot (O(total state)); +the COW snapshot folds only the hot delta over an `Arc`-shared, memoized base, so +its cost scales with *what changed* since the last snapshot. At a 10k-account fork +that is the difference between ~16 ms and ~0.2 ms per snapshot — the per-candidate +tax a search loop pays before every fan-out. + +Overlay buffer reuse (`EvmOverlay::reset()` recycling the shared-memory buffer +across simulations) trims a further slice of per-sim allocation overhead: + +| Concurrent overlays | Fresh per sim | Recycled (`reset`) | Speedup | +|:-------------------:|:-------------:|:------------------:|:-------:| +| 1 | 4.7 µs | 4.1 µs | ~1.15× | +| 8 | 35 µs | 32 µs | ~1.09× | +| 32 | 119 µs | 111 µs | ~1.07× | + +> Reproduce locally: `cargo bench --bench simulation` (groups +> `create_snapshot` and `overlay_fanout`). + ## Benchmarks Criterion benchmarks live in [`benches/`](benches). The offline benches exercise @@ -234,7 +274,7 @@ useful for A/B comparison: | `state_update` | `apply_updates` throughput across batch sizes (1 → 1000 `Slot`s) and per-variant apply cost (`Slot` vs `Account` vs `Purge`). | | `event_pipeline` | Per-decoder cost (ERC-20 `Transfer`, generic slot marker), `ingest_logs` decode+apply throughput (1 → 1000 logs), and `reorg_to` purge cost. | | `access_list` | Touch-set merge and EIP-2930 list construction. | -| `revert_decoding` | Built-in and custom revert decoding, including decoder dispatch with many registered errors. | +| `revert_decoding` | Built-in (`Error`/`Panic`) and custom-error revert decoding, and decoder dispatch over a registered custom error. | | `create3` | CREATE3 address derivation. | ```sh diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bb9a9de --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +`evm-fork-cache` is a forked-EVM simulation engine used in search / MEV / +backtesting pipelines, where a correctness bug can have direct financial +consequences for downstream users. Security reports are taken seriously. + +## Supported versions + +This crate is **pre-1.0** and under active development. Only the latest published +`0.x` release line receives security fixes; there is no back-porting to older +`0.x` versions before 1.0. See [`CHANGELOG.md`](CHANGELOG.md) for what shipped in +each release. + +| Version | Supported | +| ------- | --------- | +| latest `0.x` | ✅ | +| older `0.x` | ❌ | + +## Reporting a vulnerability + +**Please do not open a public issue for security-sensitive reports.** + +Report privately via GitHub's +[private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +on this repository: open the **Security** tab → **Report a vulnerability**. If +that channel is unavailable, open a minimal public issue asking for a private +contact (without disclosing details) and a maintainer will follow up. + +Please include: + +- the crate version, Rust version, and feature flags; +- a description of the issue and its impact; +- a minimal reproduction if possible. + +You can expect an initial acknowledgement within a few business days. Once a fix +is available it will be released and the advisory published, crediting the +reporter unless anonymity is requested. + +## Scope and known limitations + +This crate exposes deliberate, documented escape hatches that **bypass its safety +invariants** — notably `unchecked_blockchain_db()` / `unchecked_backend()` (which +sidestep the copy-on-write snapshot invalidation funnel) and the freshness model's +documented reconciliation scope (storage slots only, not account-level state). +These behaviors and their correct usage are described in +[`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md). Misuse of a documented escape hatch +is a usage error, not a vulnerability; a way to violate a documented invariant +*without* using an escape hatch is in scope. When in doubt, report it. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index da9bfeb..7d74d87 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -106,6 +106,18 @@ surface was moved out of this crate. ## Limitations by design / roadmap +- **Freshness reconciles storage slots only, not account-level state.** The + optimistic verify-and-rerun loop (`FreshnessController::run`) builds its verify + set exclusively from the volatile storage *slots* in each sim's read set; it + never re-fetches or diffs an account's native balance, nonce, or bytecode. + Consequently `Validation::Confirmed` guarantees only that *no volatile storage + slot the sims read had changed* — a sim whose result depends on a + `BALANCE`/`SELFBALANCE` (or nonce/code) that moved on-chain without a + co-changing storage slot can still be reported `Confirmed`. If account-level + state matters to a sim, classify the account `Pinned` and keep it fresh via + event-driven writes (`apply_update`/`apply_updates`), or reconcile it out of + band. The verdict types and the `freshness` module docs state this scope; a + future revision may extend reconcile to touched accounts. - **Solidity `Panic(uint256)` codes above `u64::MAX` decode as `Unknown`.** `decode_solidity_panic` drops out-of-range codes rather than exposing a lossy `u64`. Real compiler-emitted panic codes are single-byte constants, so this is @@ -114,7 +126,27 @@ surface was moved out of this crate. reads `from`/`to` from indexed topics and `value` from the first 32 data bytes. Non-standard or packed `Transfer` encodings may parse incorrectly or be skipped. A self-transfer where `from == to` nets to zero for that owner; this is - documented at the call site. + documented at the call site. Transfer **values ≥ 2^255** are reinterpreted as + negative when accumulated as a signed `I256` delta (`I256::from_raw`), so a + token emitting such a value would corrupt the reconstructed delta. Real ERC-20 + supplies are far below 2^255, so this is unreachable for honest tokens; a + malicious token can misreport balances by other means regardless. +- **`BLOCKHASH` resolves to ZERO in ext-db-less overlays.** Snapshots do not track + block hashes (`block_hashes` is always empty — the live cache does not track + them either), so an `EvmOverlay` built without an `ext_db` (e.g. the freshness + validator's internal overlays) returns `B256::ZERO` for the `BLOCKHASH` opcode. + A simulation of a contract that reads `BLOCKHASH` through such an overlay sees + ZERO rather than the real historical hash. +- **`EventPipeline::derived_slots` grows unbounded without reorgs.** Every + event-derived `(address, slot)` is retained for sampled reconciliation and is + only pruned by `reorg_to`. In long-running steady-state ingestion (no reorgs) + the set grows monotonically (~52 bytes/slot). The field doc says "seen so far"; + a future revision may bound it to the reorg ring's horizon. +- **Reorgs deeper than `ReorgConfig::depth` cannot fully purge.** The touched- + address ring is bounded to `depth` blocks; `reorg_to(n)` only purges addresses + still in the ring, so state touched solely in blocks that have already aged out + of the horizon is silently left un-purged (no error). Size `depth` above the + deepest reorg you expect to handle. - **Copy-on-write snapshots (Phase 5, Pillar A) — done.** `create_snapshot()` is no longer an O(total state) deep clone. The cold `BlockchainDb` index (layer 2) is flattened once into an internal, immutable, `Arc`-shared base (per-account diff --git a/examples/freshness_multi_sim.rs b/examples/freshness_multi_sim.rs index 15a8780..c837b12 100644 --- a/examples/freshness_multi_sim.rs +++ b/examples/freshness_multi_sim.rs @@ -29,7 +29,7 @@ use alloy_eips::BlockId; use alloy_primitives::{Address, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use anyhow::Result; -use evm_fork_cache::cache::StorageBatchFetchFn; +use evm_fork_cache::cache::{SimStatus, StorageBatchFetchFn}; use evm_fork_cache::freshness::{ AlwaysVerify, FreshnessController, FreshnessRegistry, SimRequest, Validation, Validity, }; @@ -133,7 +133,7 @@ async fn main() -> Result<()> { let optimistic_ok: Vec = sim .optimistic() .iter() - .map(|r| !r.logs.is_empty()) + .map(|r| matches!(r.status, SimStatus::Success)) .collect(); println!("optimistic (against the snapshot): {optimistic_ok:?} (all succeed)"); @@ -146,7 +146,10 @@ async fn main() -> Result<()> { for c in &changed { println!(" sender slot {} : {} -> {}", c.slot, c.old, c.new); } - let corrected_ok: Vec = results.iter().map(|r| !r.logs.is_empty()).collect(); + let corrected_ok: Vec = results + .iter() + .map(|r| matches!(r.status, SimStatus::Success)) + .collect(); println!("corrected results: {corrected_ok:?}"); // Exactly the second sim flipped success -> revert; the others are diff --git a/examples/freshness_optimistic.rs b/examples/freshness_optimistic.rs index 42fa191..d4b8c5d 100644 --- a/examples/freshness_optimistic.rs +++ b/examples/freshness_optimistic.rs @@ -74,11 +74,20 @@ async fn main() -> Result<()> { ); cache.set_storage_batch_fetcher(fetcher); - // Classification: the balance slot is volatile (default), and slot 6 (a - // would-be immutable like `token0`) is pinned so it is never re-verified. + // Classification: by default every slot is volatile (re-verified); we pin + // slot 6 (a would-be immutable, constructor-set config value) so it is never + // re-verified. let mut registry = FreshnessRegistry::new(); registry.pin_slot(token, U256::from(6)); + // Show the classification before the registry is moved into the controller: + // an unpinned slot is volatile, the pinned one is not (so it is skipped). + println!( + "classification — slot 0 volatile? {} | slot 6 (pinned) volatile? {}", + registry.is_volatile(token, U256::from(0), 0), + registry.is_volatile(token, U256::from(6), 0), + ); + let mut controller = FreshnessController::new(registry, AlwaysVerify); // A non-committing `transfer(recipient, 100)` evaluation sim. diff --git a/examples/reactive_cache.rs b/examples/reactive_cache.rs index e498ad1..87717e5 100644 --- a/examples/reactive_cache.rs +++ b/examples/reactive_cache.rs @@ -137,8 +137,12 @@ async fn main() -> Result<()> { mock::balance_of(&mut cache, token, bob)? ); + // `EventPipeline::new` uses the default `ReorgConfig`, whose + // `PurgeScope::AllStorage` clears the storage of every address touched after + // the new head — so bob's balance slot reads back as uncached (`None`) and the + // next access would re-fetch it from RPC. let purge = pipeline.reorg_to(&mut cache, 99); - println!("\n=== reorg to block 99 ==="); + println!("\n=== reorg to block 99 (default scope: AllStorage) ==="); println!( " purged {} address(es); bob balance slot now cached as {:?}", purge.purged.len(), diff --git a/fixtures/README.md b/fixtures/README.md index ce6f067..60703de 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -43,3 +43,19 @@ jq -r '.deployedBytecode.object' out/MockERC20.sol/MockERC20.json \ jq -r '.bytecode.object' out/MockERC20.sol/MockERC20.json \ | sed 's/^0x//' > fixtures/mock_erc20_creation.hex ``` + +## `Multicall3` + +- `multicall3_runtime.hex` — the canonical [Multicall3](https://github.com/mds1/multicall) + deployed (runtime) bytecode. Multicall3 lives at the same address + (`0xcA11bde05977b3631167028862bE2a173976CA11`) on virtually every EVM chain, so + etching this bytecode there lets the offline [`../tests/multicall.rs`](../tests/multicall.rs) + suite exercise the real `aggregate3` build/execute/decode path (input-order + results and `allowFailure` semantics) with no network. + +### Regenerating + +```sh +cast code 0xcA11bde05977b3631167028862bE2a173976CA11 --rpc-url "$RPC_URL" \ + | sed 's/^0x//' > fixtures/multicall3_runtime.hex +``` diff --git a/fixtures/multicall3_runtime.hex b/fixtures/multicall3_runtime.hex new file mode 100644 index 0000000..9013a03 --- /dev/null +++ b/fixtures/multicall3_runtime.hex @@ -0,0 +1 @@ +6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c146101d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e1461015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d610128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b604051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b2366004610c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffffffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d610242366004610a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b50604051418152602001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b3480156102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102d8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190915260008152606060208201528152602001906001900390816102f65790505b5092503660005b8281101561047757600085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b905060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffffffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d6000602084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b60405190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a5790505b5091503660005b8281101561068657600087878381811061056257610562610d60565b90506020028101906105749190610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061060957610609610d60565b602090810291909101015290508061067d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b6040805180820190915260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e657600084828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b905060200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b6040805180820190915260008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a105760008482815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af19150503d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b606091505b506020830152151581528715610a07578051610a07576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b6000806060610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b50813567ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed57602081850181015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ffffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b838152826020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf457600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284031215610d2a57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffffffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b8183823760009101908152919050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112610dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603018112610dc357600080fdfea2646970667358221220bb2b5c71a328032f97c676ae39a1ec2148d3e5d6f73d95e9b17910152d61f16264736f6c634300080c0033 diff --git a/src/access_list.rs b/src/access_list.rs index 4bb44d0..39d495b 100644 --- a/src/access_list.rs +++ b/src/access_list.rs @@ -1,9 +1,14 @@ -//! Selective EIP-2930 access list builder. +//! EIP-2930 access list builder with L2 profitability accounting. //! -//! Builds an access list containing only well-known, low-entropy storage slots -//! (V3 slot0/liquidity, V2 reserves) whose serialized form is mostly zero bytes -//! and cheap to post as L1 data. Skips keccak-derived mapping keys (tick bitmap, -//! tick info) which are 32 random bytes and expensive on L1. +//! The caller supplies the addresses and storage slots to include; this module +//! decides *whether attaching the list is profitable*, not *which slots are +//! interesting* (it carries no protocol-specific slot knowledge). The trade-off +//! is purely economic: pre-declaring an account/slot warms it (cheaper EIP-2929 +//! execution) but costs L1 data to post the list. Slots whose serialized form is +//! mostly zero bytes (e.g. small, low-entropy values) are cheap to post; dense, +//! high-entropy 32-byte keys are expensive on L1 — so the value of a given entry +//! depends on its bytes, which is why the include/exclude decision is left to the +//! caller and the profitability check below. //! //! On L2, automatically disables itself when L1 fees rise high enough that the //! L1 data cost exceeds the L2 execution savings. Arbitrum uses `ArbGasInfo` @@ -89,10 +94,13 @@ sol! { } } -/// A selective EIP-2930 access list built from the execution plan. +/// An EIP-2930 access list builder with L2 profitability accounting. /// -/// Only includes entries where the L2 execution savings (100 gas each) -/// are likely to exceed the L1 data posting cost of the serialized entry. +/// The caller decides which addresses/slots to add (via +/// [`add_address`](Self::add_address) / [`add_storage_key`](Self::add_storage_key)); +/// the builder itself applies no per-entry selection. The `into_access_list_*` +/// finalizers decide whether attaching the *whole* list is profitable, comparing +/// its L1 data-posting cost against the L2 warm-access savings. pub struct SmartAccessList { items: Vec, } diff --git a/src/cache/binary_state.rs b/src/cache/binary_state.rs index ccfb3d0..369d433 100644 --- a/src/cache/binary_state.rs +++ b/src/cache/binary_state.rs @@ -114,13 +114,22 @@ pub fn save_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> Result<() /// /// Returns `false` (rather than erroring) when `path` cannot be read or its /// contents fail the magic/version check or fail to decode as the expected -/// bincode layout; failures are logged at `warn` level. +/// bincode layout. A missing file (the normal cold-start case) is logged at +/// `debug`; an actual read error (e.g. permission denied) and any +/// magic/version/decode failure are logged at `warn`. pub fn load_binary_state(blockchain_db: &BlockchainDb, path: &Path) -> bool { let start = Instant::now(); let data = match std::fs::read(path) { Ok(d) => d, - Err(_) => return false, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + debug!("No binary EVM state file found, starting fresh"); + return false; + } + Err(e) => { + warn!(error = %e, "Failed to read binary EVM state, starting fresh"); + return false; + } }; let Some(state) = versioned::decode::( diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 19af0e4..8cadf91 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -294,6 +294,7 @@ pub struct EvmCacheBuilder

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

EvmCacheBuilder

@@ -308,6 +309,7 @@ where cache_config: None, spec_id: SpecId::CANCUN, shared_memory_capacity: SharedMemoryCapacity::default(), + chain_id: None, } } @@ -337,6 +339,20 @@ where self } + /// Set the chain ID reported to simulations via the `CHAINID` opcode. + /// + /// **Recommended.** This is the explicit, authoritative way to set the chain + /// ID. If left unset, [`build`](Self::build) infers it from the provider + /// (`eth_chainId`), falling back to `1` (Ethereum mainnet) only if that query + /// fails. A disk [`cache_config`](Self::cache_config) also carries a + /// `chain_id` (which additionally namespaces the on-disk cache directory); + /// when both are set, the value passed here wins for the `CHAINID` opcode, so + /// keep them consistent. + pub fn chain_id(mut self, chain_id: u64) -> Self { + self.chain_id = Some(chain_id); + self + } + /// Enable disk-backed caching with the given configuration. /// /// Supplying a [`CacheConfig`] turns on persistence of EVM state, bytecodes, @@ -362,15 +378,26 @@ where } /// Build the [`EvmCache`], fetching the pinned block's header for context. + /// + /// If a chain ID was not set via [`chain_id`](Self::chain_id), it is inferred + /// from the provider (`eth_chainId`); see [`chain_id`](Self::chain_id) for the + /// full resolution order. pub async fn build(self) -> EvmCache { - EvmCache::with_cache_capacity( + let explicit_chain_id = self.chain_id; + let mut cache = EvmCache::with_cache_capacity( self.provider, self.block, self.cache_config, self.spec_id, self.shared_memory_capacity, ) - .await + .await; + // An explicit builder value is authoritative for the `CHAINID` opcode and + // overrides both the inferred value and any `cache_config` chain id. + if let Some(chain_id) = explicit_chain_id { + cache.set_chain_id(chain_id); + } + cache } } @@ -985,15 +1012,32 @@ impl EvmCache { }, ); + // Resolve the chain ID reported to simulations (the `CHAINID` opcode). A + // disk `CacheConfig` is authoritative (its `chain_id` also namespaces the + // on-disk cache directory); otherwise infer it from the provider via + // `eth_chainId`, falling back to 1 (Ethereum mainnet) only if that query + // fails. Resolved before `provider` is moved into the backend below. + // Prefer setting it explicitly through `EvmCacheBuilder::chain_id`. + let chain_id = match cache_config.as_ref() { + Some(cfg) => cfg.chain_id, + None => match provider.get_chain_id().await { + Ok(id) => id, + Err(e) => { + debug!( + error = %e, + "Failed to infer chain ID from provider; defaulting to 1 (Ethereum mainnet). Set it explicitly via EvmCacheBuilder::chain_id." + ); + 1 + } + }, + }; + // Spawn the backend handler on a background task let backend = SharedBackend::spawn_backend(provider, blockchain_db.clone(), Some(block_id)).await; let db = CacheDB::new(backend.clone()); - // Extract chain_id from cache config if available, default to Arbitrum - let chain_id = cache_config.as_ref().map(|c| c.chain_id).unwrap_or(42161); - // Resolve the shared-memory pre-allocation. For `Auto` we size from the // amount of layer-2 chain state actually loaded (post-filter), so a large // bincode state file yields a larger buffer; `Fixed` ignores the count. @@ -1073,6 +1117,13 @@ impl EvmCache { /// /// Useful when you want to share a backend between multiple caches /// (e.g. parallel simulation threads). + /// + /// **Shared pinned block.** A `SharedBackend` owns a single pinned fork + /// height. Calling [`set_block`](Self::set_block) / `repin_to_block` on *any* + /// cache built from the same backend re-pins the RPC fork height for **all** + /// of them. Sibling caches sharing one backend should agree on a block and not + /// re-pin independently; build separate backends if they must fork at + /// different heights. pub fn from_backend( backend: SharedBackend, blockchain_db: BlockchainDb, @@ -2156,6 +2207,17 @@ impl EvmCache { self.chain_id } + /// Set the chain ID reported to simulations via the `CHAINID` opcode. + /// + /// Prefer setting this at construction through + /// [`EvmCacheBuilder::chain_id`]. This setter exists for cases where the + /// chain ID must change after construction. It takes effect on the next + /// [`create_snapshot`](Self::create_snapshot) / `build_evm`; existing + /// snapshots and overlays keep the chain ID captured when they were created. + pub fn set_chain_id(&mut self, chain_id: u64) { + self.chain_id = chain_id; + } + /// Take a low-level, same-thread snapshot of the CacheDB overlay for /// in-place restore. /// diff --git a/src/cache/overlay.rs b/src/cache/overlay.rs index 372f256..395815a 100644 --- a/src/cache/overlay.rs +++ b/src/cache/overlay.rs @@ -745,6 +745,11 @@ impl Database for EvmOverlay { if let Some(ref ext_db) = self.ext_db { return ext_db.block_hash_ref(number); } + // Snapshots never populate `block_hashes` (the live cache does not track + // block hashes), so without an `ext_db` the `BLOCKHASH` opcode resolves to + // ZERO. Overlays built internally (e.g. the freshness validator) pass + // `ext_db = None`; a contract that reads `BLOCKHASH` through such an + // overlay sees ZERO. Documented in docs/KNOWN_ISSUES.md. Ok(B256::ZERO) } } diff --git a/src/create3.rs b/src/create3.rs index b7d7082..e3f2779 100644 --- a/src/create3.rs +++ b/src/create3.rs @@ -9,13 +9,20 @@ use alloy_primitives::{Address, B256, address, b256, keccak256}; -/// Address of the widely deployed universal CREATE3 factory (the CreateX / -/// CREATE3 factory implementation). +/// Address of the widely deployed `CREATE3Factory` (the ZeframLou / Solmate-style +/// implementation, deterministically deployed cross-chain — e.g. by LiFi). /// -/// This is the canonical cross-chain address at which the CreateX-style -/// CREATE3 factory has been deterministically deployed on many EVM networks. -/// The derivation in this module assumes the factory at this address uses the -/// CREATE3 proxy init code whose hash is `CREATE3_PROXY_INITCODE_HASH`. +/// This is the canonical cross-chain address at which this factory has been +/// deployed on many EVM networks. Its derivation salts as +/// `keccak256(abi.encodePacked(deployer, salt))` over the standard Solady/Solmate +/// CREATE3 proxy init code whose hash is `CREATE3_PROXY_INITCODE_HASH`, which is +/// what [`derive_universal_create3_address`] reproduces. +/// +/// **This is not pcaversaccio's CreateX** (`0xba5Ed0...28ba5Ed`). CreateX applies +/// a guarded-salt transformation (permissioned-deploy / cross-chain-redeploy +/// protection, chain-ID mixing) that differs from the plain +/// `keccak256(deployer, salt)` used here, so this module does **not** predict +/// CreateX deployment addresses. /// /// Callers must verify the factory is actually deployed at this address on /// their target chain before relying on a derived address: if the factory is @@ -23,7 +30,7 @@ use alloy_primitives::{Address, B256, address, b256, keccak256}; /// address will not correspond to any real deployment. pub const UNIVERSAL_CREATE3_FACTORY: Address = address!("93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1"); -// CREATE3 proxy initcode used by the universal factory implementation. +// Standard Solady/Solmate CREATE3 proxy initcode used by this factory. // keccak256(0x67363d3d37363d34f03d5260086018f3) const CREATE3_PROXY_INITCODE_HASH: B256 = b256!("21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f"); diff --git a/src/errors.rs b/src/errors.rs index 46b9582..649ada7 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -654,13 +654,6 @@ impl From for SimError { } } -/// Deprecated alias for [`SimError`]. -#[deprecated( - since = "0.2.0", - note = "renamed to `SimError`; `Halt` is now a distinct variant" -)] -pub type SimulationErrorKind = SimError; - #[cfg(test)] mod tests { use super::*; diff --git a/src/events/mod.rs b/src/events/mod.rs index f4dbda8..0bfe210 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -297,6 +297,13 @@ impl EventPipeline { touched_addrs.insert(skip.address); self.note_touched_slot(&mut digest, skip.address, skip.slot); } + // Skipped (cold) `Account` patches carry an address but no slot, so + // track the address only — mirroring `skipped_balances`. A third-party + // decoder emitting `StateUpdate::Account` against a cold account would + // otherwise be invisible to reorg purge and freshness tracking. + for skip in &diff.skipped_accounts { + touched_addrs.insert(skip.address); + } digest.applied.merge(diff); } diff --git a/src/freshness.rs b/src/freshness.rs index f741243..5fe2957 100644 --- a/src/freshness.rs +++ b/src/freshness.rs @@ -21,6 +21,18 @@ //! `clock.now()` as `now: u64` through the tracker, the policy, and //! [`FreshnessRegistry::is_volatile`]. //! +//! # Reconciliation scope +//! +//! The optimistic loop verifies only **volatile storage slots** in each sim's +//! read set. Account-level state — native balance, nonce, and bytecode — is +//! **not** re-fetched or diffed, so [`Validation::Confirmed`] means *"no volatile +//! storage slot the sims read had changed"*, not *"no account state changed"*. A +//! sim whose result depends on a `BALANCE`/`SELFBALANCE` (or nonce/code) that +//! moved on-chain without a co-changing storage slot in its read set can still be +//! reported `Confirmed`. If account-level state matters to a sim, mark the +//! account [`Validity::Pinned`] and keep it fresh via event-driven writes, or +//! reconcile it out of band. See [`Validation`] for the per-verdict note. +//! //! # Example //! //! Classification + policy selection, no network required: @@ -32,22 +44,25 @@ //! }; //! use evm_fork_cache::cache::SlotObservationTracker; //! -//! let pool = Address::repeat_byte(0x01); -//! let slot0 = U256::from(0); -//! let immutable = U256::from(6); // e.g. token0 +//! let contract = Address::repeat_byte(0x01); +//! let volatile_slot = U256::from(0); +//! let immutable_slot = U256::from(6); // e.g. a constructor-set config value //! //! let mut registry = FreshnessRegistry::new(); // default: Volatile -//! registry.pin_slot(pool, immutable); // never re-verified +//! registry.pin_slot(contract, immutable_slot); // never re-verified //! //! // `now` is in clock units (block number for the default BlockClock). //! let now = 100; -//! assert!(registry.is_volatile(pool, slot0, now)); -//! assert!(!registry.is_volatile(pool, immutable, now)); +//! assert!(registry.is_volatile(contract, volatile_slot, now)); +//! assert!(!registry.is_volatile(contract, immutable_slot, now)); //! //! // Policies pick which volatile candidates to verify this cycle. //! let obs = SlotObservationTracker::new(); -//! let candidates = [(pool, slot0)]; -//! assert_eq!(AlwaysVerify.select(&candidates, &obs, now), vec![(pool, slot0)]); +//! let candidates = [(contract, volatile_slot)]; +//! assert_eq!( +//! AlwaysVerify.select(&candidates, &obs, now), +//! vec![(contract, volatile_slot)] +//! ); //! assert!(NeverVerify.select(&candidates, &obs, now).is_empty()); //! ``` @@ -458,12 +473,27 @@ pub struct SlotChange { } /// The deferred verdict on a [`SpeculativeSim`]'s optimistic results. +/// +/// # Reconciliation scope +/// +/// The verdict reflects only **volatile storage slots** in each sim's read set. +/// Account-level state — native balance, nonce, and bytecode — is **not** +/// re-verified, so a sim whose result depends on a `BALANCE`/`SELFBALANCE` (or +/// nonce/code) that changed on-chain *without* a co-changing storage slot in its +/// read set can still be reported [`Confirmed`](Validation::Confirmed). Classify +/// such accounts as [`Validity::Pinned`] and keep them fresh via event-driven +/// writes if their account-level state matters to your sims. See the module-level +/// docs for the full freshness contract. pub enum Validation { - /// Nothing the sims read had changed; the optimistic results are correct. + /// No **volatile storage slot** in the sims' read sets had changed, so the + /// optimistic results are trusted. Note this does *not* cover account-level + /// balance/nonce/code changes — see the [type-level scope](Validation). Confirmed, - /// At least one read slot changed. `results` is the optimistic set with the - /// affected sims re-run against the fresh values; `changed` lists the slots - /// that differed (also queued for flow-back into the cache). + /// At least one read storage slot changed. `results` is the optimistic set + /// with the affected sims re-run against the fresh values; `changed` lists the + /// slots that differed (also queued for flow-back into the cache). Only + /// storage slots are reconciled — account-level state is not (see the + /// [type-level scope](Validation)). Corrected { /// Optimistic results with the affected sims replaced by re-runs. results: Vec, @@ -558,12 +588,16 @@ impl SimRequest { /// cancel flag and aborts the background task. Cancellation is **cooperative and /// best-effort, not instantaneous**: `run_validator` is synchronous, so an abort /// cannot preempt it once it is running. Instead the validator checks the flag at -/// a few checkpoints — before fetching, and before recording observations or -/// queuing a correction — so a cancel observed at a checkpoint prevents the -/// remaining side effects. A validator already executing a synchronous step (e.g. -/// mid-fetch) completes that step before reaching the next checkpoint. The intent -/// is that a dropped speculation does not flow its corrections back into the -/// cache; it does not guarantee that an in-flight fetch is interrupted. +/// several checkpoints — before each fetch, and (on the first pass) after a fetch +/// returns but before it records observations or queues corrections — so a cancel +/// observed at a checkpoint skips the side effects downstream of it. A validator +/// already executing a synchronous step (e.g. mid-fetch) completes that step +/// before reaching the next checkpoint, and corrections accumulated up to a fetch +/// that completed in the *final* loop iteration may still be queued for flow-back +/// (the post-loop queue is not guarded by an immediately-preceding checkpoint). +/// The intent is that a dropped speculation stops doing further work and, in the +/// common case, does not flow corrections back into the cache; it does not +/// guarantee that an in-flight or just-completed fetch's correction is withheld. pub struct SpeculativeSim { optimistic: Vec, /// `Option` so `validate`/`into_optimistic` can take the handle and skip the @@ -577,6 +611,11 @@ pub struct SpeculativeSim { impl SpeculativeSim { /// The optimistic results, readable before validation completes. + /// + /// These (and the re-run results in [`Validation::Corrected`]) carry an + /// **empty `token_deltas`** map: the optimistic loop does not run transfer + /// tracking, so the signed per-token balance deltas populated by the + /// committing `simulate_call_with_balance_deltas` path are not available here. pub fn optimistic(&self) -> &[CallSimulationResult] { &self.optimistic } @@ -765,11 +804,13 @@ impl FreshnessController { /// [`SpeculativeSim`] immediately. /// /// # Panics - /// Spawns a background task whose (synchronous) fetcher uses - /// `tokio::task::block_in_place` internally, so it must run on a - /// **multi-thread** tokio runtime (`#[tokio::main(flavor = "multi_thread")]` - /// or `Builder::new_multi_thread()`). On a current-thread runtime - /// `block_in_place` panics, mirroring the [`EvmCache`] constructor note. + /// Must be called from within a tokio runtime: it calls `tokio::spawn` to + /// launch the background validator, which panics (`there is no reactor + /// running`) if no runtime is active. The spawned (synchronous) fetcher + /// additionally uses `tokio::task::block_in_place` internally, so the runtime + /// must be **multi-thread** (`#[tokio::main(flavor = "multi_thread")]` or + /// `Builder::new_multi_thread()`); on a current-thread runtime `block_in_place` + /// panics, mirroring the [`EvmCache`] constructor note. /// /// # Errors /// Returns an error if any optimistic simulation fails to execute against the diff --git a/src/state_update.rs b/src/state_update.rs index f67a474..8e688e9 100644 --- a/src/state_update.rs +++ b/src/state_update.rs @@ -207,9 +207,11 @@ pub enum StateUpdate { /// /// Read-modify-write: the current `AccountInfo::balance` is read, the /// [`SlotDelta`] applied, and the result written back through both layers - /// (nonce and code preserved). **Cold-aware** — "cold" here means the account - /// is absent from *both* layers (its balance is unknown). A `BalanceDelta` on a - /// cold account is not applied; it is surfaced in + /// (nonce and code preserved). **Cold-aware** — "cold" here means the + /// account's info is unknown to the EVM: absent from *both* layers, **or** + /// present in the overlay as revm `NotExisting` (which the internal account + /// read also treats as cold, mirroring `DbAccount::info()`). A `BalanceDelta` + /// on a cold account is not applied; it is surfaced in /// [`StateDiff::skipped_balances`] instead (so no default account is /// materialized to mask the real on-chain one — see the module docs). BalanceDelta { @@ -246,8 +248,9 @@ pub enum StateUpdate { /// Patch an already-known account's balance/nonce/code (partial — see /// [`AccountPatch`]). /// - /// Cold-aware: if the account is absent from **both** layers, the patch is not - /// applied and is surfaced in [`StateDiff::skipped_accounts`] as a + /// Cold-aware: if the account's info is unknown to the EVM (absent from + /// **both** layers, or present in the overlay as revm `NotExisting`), the patch + /// is not applied and is surfaced in [`StateDiff::skipped_accounts`] as a /// [`SkippedAccountPatch`]. Use [`StateUpdate::AccountUpsert`] when /// materializing a cold/default account is intentional. Account { @@ -474,7 +477,8 @@ pub enum PurgeScope { /// /// [`is_empty`](Self::is_empty) / [`len`](Self::len) are **changes-only**, so a /// cold-skipped update ([`SlotDelta`](StateUpdate::SlotDelta) / -/// [`BalanceDelta`](StateUpdate::BalanceDelta) / [`Account`](StateUpdate::Account)) +/// [`BalanceDelta`](StateUpdate::BalanceDelta) / +/// [`SlotMasked`](StateUpdate::SlotMasked) / [`Account`](StateUpdate::Account)) /// is invisible to them. After applying cold-aware updates, check /// [`has_skipped`](Self::has_skipped) (or inspect the `skipped_*` fields) — a cold /// target was dropped, not applied. @@ -511,28 +515,32 @@ pub struct StateDiff { impl StateDiff { /// Whether the diff recorded no change at all. /// - /// Changes-only: counts `slots` + `accounts` + `purged`. A skipped relative - /// update ([`skipped`](Self::skipped) / [`skipped_balances`](Self::skipped_balances)) - /// is informational metadata, not a change, so it does not affect this. + /// Changes-only: counts `slots` + `accounts` + `purged`. Any skipped update + /// (any of the `skipped_*` fields) is informational metadata, not a change, so + /// it does not affect this. pub fn is_empty(&self) -> bool { self.slots.is_empty() && self.accounts.is_empty() && self.purged.is_empty() } /// Total number of changed entries (slots + accounts + purges). /// - /// Changes-only: skipped relative updates are not counted (a skip is not a - /// change). See [`skipped_len`](Self::skipped_len) for the skip count. + /// Changes-only: skipped updates (any `skipped_*` field) are not counted (a + /// skip is not a change). See [`skipped_len`](Self::skipped_len) for the skip + /// count. pub fn len(&self) -> usize { self.slots.len() + self.accounts.len() + self.purged.len() } - /// Whether any relative update was skipped (slot **or** balance). + /// Whether any cold-aware update was skipped (relative slot, balance, masked + /// slot, **or** cold account patch). /// - /// `true` iff [`skipped`](Self::skipped) or - /// [`skipped_balances`](Self::skipped_balances) is non-empty. A cold-skipped + /// `true` iff any of [`skipped`](Self::skipped), + /// [`skipped_balances`](Self::skipped_balances), + /// [`skipped_masks`](Self::skipped_masks), or + /// [`skipped_accounts`](Self::skipped_accounts) is non-empty. A cold-skipped /// update produces no change, so it is invisible to - /// [`is_empty`](Self::is_empty) — callers applying relative updates should - /// check this to avoid silently dropping a balance update. + /// [`is_empty`](Self::is_empty) — callers applying cold-aware updates should + /// check this to avoid silently dropping an update. pub fn has_skipped(&self) -> bool { !self.skipped.is_empty() || !self.skipped_balances.is_empty() @@ -618,8 +626,9 @@ pub struct SkippedDelta { } /// A relative balance update ([`StateUpdate::BalanceDelta`]) that could not be -/// applied because the account is absent from **both** cache layers (its native -/// balance is unknown). +/// applied because the account's info is unknown to the EVM — absent from +/// **both** cache layers, or present in the overlay as revm `NotExisting` (so its +/// native balance is unknown). /// /// A delta against a cold account is skipped rather than applied (applying it /// against an assumed-zero balance would corrupt an unknown value, and @@ -662,7 +671,8 @@ pub struct SkippedMask { } /// An account patch ([`StateUpdate::Account`]) that could not be applied because -/// the account is absent from **both** cache layers. +/// the account's info is unknown to the EVM — absent from **both** cache layers, +/// or present in the overlay as revm `NotExisting`. /// /// A partial patch against a cold account is skipped rather than applied against /// [`AccountInfo::default`](revm::state::AccountInfo::default), because default diff --git a/tests/builder_chain_id.rs b/tests/builder_chain_id.rs new file mode 100644 index 0000000..cea0780 --- /dev/null +++ b/tests/builder_chain_id.rs @@ -0,0 +1,99 @@ +//! Offline tests for chain-ID resolution on [`EvmCacheBuilder`] / [`EvmCache`]. +//! +//! Resolution order (highest priority first): +//! 1. an explicit [`EvmCacheBuilder::chain_id`] value; +//! 2. a disk [`CacheConfig`]'s `chain_id` (also the on-disk namespace); +//! 3. the value inferred from the provider via `eth_chainId`; +//! 4. `1` (Ethereum mainnet) as a last-resort fallback when inference fails. +//! +//! All tests run fully offline over a mocked provider whose `eth_chainId` query +//! errors (empty `Asserter`), so the inference branch deterministically takes the +//! mainnet fallback — letting us assert the explicit / config / fallback rungs +//! without a network. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy_provider::RootProvider; +use alloy_provider::network::AnyNetwork; +use alloy_rpc_client::RpcClient; +use alloy_transport::mock::Asserter; +use anyhow::Result; +use evm_fork_cache::cache::{CacheConfig, EvmCache, EvmCacheBuilder}; + +fn mock_provider() -> Arc> { + Arc::new(RootProvider::::new(RpcClient::mocked( + Asserter::new(), + ))) +} + +fn unique_cache_dir(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("evm_fork_cache_chainid_{tag}_{nanos}")) +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_builder_chain_id_wins() -> Result<()> { + // 8453 = Base. The explicit builder value is authoritative. + let cache = EvmCacheBuilder::new(mock_provider()) + .chain_id(8453) + .build() + .await; + assert_eq!(cache.chain_id(), 8453); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn unset_chain_id_falls_back_to_mainnet_when_inference_fails() -> Result<()> { + // No explicit value, no cache_config, and the mock provider's eth_chainId + // errors — so resolution lands on the mainnet (1) fallback, not Arbitrum. + let cache = EvmCacheBuilder::new(mock_provider()).build().await; + assert_eq!(cache.chain_id(), 1); + + // The bare `new` constructor takes the same inference path. + let via_new = EvmCache::new(mock_provider()).await; + assert_eq!(via_new.chain_id(), 1); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cache_config_chain_id_is_used_when_no_explicit_value() -> Result<()> { + // 10 = OP Mainnet. With a disk cache and no explicit builder value, the + // CacheConfig chain id is authoritative. + let dir = unique_cache_dir("config"); + let cfg = CacheConfig::new(&dir, 10, Default::default(), Default::default()); + let cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg) + .build() + .await; + assert_eq!(cache.chain_id(), 10); + let _ = std::fs::remove_dir_all(&dir); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_builder_chain_id_overrides_cache_config() -> Result<()> { + let dir = unique_cache_dir("override"); + let cfg = CacheConfig::new(&dir, 10, Default::default(), Default::default()); + let cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg) + .chain_id(8453) + .build() + .await; + // Explicit builder value wins for the CHAINID opcode even when a config is set. + assert_eq!(cache.chain_id(), 8453); + let _ = std::fs::remove_dir_all(&dir); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_chain_id_updates_after_construction() -> Result<()> { + let mut cache = EvmCacheBuilder::new(mock_provider()).build().await; + assert_eq!(cache.chain_id(), 1); + cache.set_chain_id(137); // Polygon + assert_eq!(cache.chain_id(), 137); + Ok(()) +} diff --git a/tests/cache_state.rs b/tests/cache_state.rs index cd663a4..b01c6fa 100644 --- a/tests/cache_state.rs +++ b/tests/cache_state.rs @@ -8,6 +8,7 @@ mod common; +use alloy_eips::eip2930::{AccessList, AccessListItem}; use alloy_primitives::{Address, B256, Bytes, I256, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use anyhow::{Context, Result}; @@ -119,6 +120,74 @@ async fn call_raw_with_carries_native_value() -> Result<()> { Ok(()) } +/// `TxConfig.access_list` is wired into EIP-2929/EIP-2930 gas accounting: declaring +/// the touched account + slot warms them for the call but adds the EIP-2930 +/// intrinsic cost (2400/address + 1900/key). For a `balanceOf` that reads the slot +/// once, the intrinsic outweighs the ~2000-gas warm SLOAD saving, so the declared +/// run costs *more* — what matters is that the field measurably changes gas, i.e. +/// it is not silently ignored. +#[tokio::test(flavor = "multi_thread")] +async fn tx_config_access_list_changes_eip2929_gas_accounting() -> Result<()> { + let mut cache = setup_cache().await?; + let token = Address::repeat_byte(0x11); + let owner = Address::repeat_byte(0x22); + install_default_account(&mut cache, Address::ZERO); + install_mock_erc20(&mut cache, token); + + // Layer-1 CacheDB balance seed so `balanceOf` reads a real (non-zero) value. + let slot = + U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0); + cache + .db_mut() + .insert_account_storage(token, slot, U256::from(123u64))?; + + let calldata = Bytes::from(MockERC20::balanceOfCall { account: owner }.abi_encode()); + + let gas_of = |result: ExecutionResult| match result { + ExecutionResult::Success { gas_used, .. } => gas_used, + other => panic!("balanceOf did not succeed: {other:?}"), + }; + + // No access list: the `balanceOf` SLOAD pays the cold (2100-gas) cost. + let gas_plain = gas_of(cache.call_raw_with( + Address::ZERO, + token, + calldata.clone(), + false, + &TxConfig::default(), + )?); + + // Declare (token, slot): the SLOAD is warmed, but the access list carries its + // own EIP-2930 intrinsic cost. + let tx = TxConfig { + access_list: Some(AccessList(vec![AccessListItem { + address: token, + storage_keys: vec![B256::from(slot)], + }])), + ..Default::default() + }; + let gas_with_list = gas_of(cache.call_raw_with(Address::ZERO, token, calldata, false, &tx)?); + + assert_ne!( + gas_plain, gas_with_list, + "TxConfig.access_list must affect gas accounting, not be ignored" + ); + // Net for a single warmed-once slot: +2400 (addr) + 1900 (key) − 2000 (warm + // SLOAD) = +2300. The access list raises gas for this single-access call. + assert!( + gas_with_list > gas_plain, + "single-access declaration costs more intrinsic than it saves: \ + with_list {gas_with_list} vs plain {gas_plain}" + ); + assert_eq!( + gas_with_list - gas_plain, + 2_300, + "expected the exact EIP-2930 intrinsic minus single warm-SLOAD saving" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn simulation_reports_balance_deltas() -> Result<()> { let mut cache = setup_cache().await?; diff --git a/tests/multicall.rs b/tests/multicall.rs index 8ac1895..60e3dbc 100644 --- a/tests/multicall.rs +++ b/tests/multicall.rs @@ -1,23 +1,61 @@ //! Offline integration tests for the Multicall3 helpers. //! -//! The live `aggregate3` execution path requires the Multicall3 contract to be -//! deployed in the fork, which the RPC-gated `multicall_batch` example exercises. -//! These tests pin the network-free behavior: empty-batch short-circuits, the -//! result-decoding helpers, and the documented batch constants. +//! These pin the network-free behavior end to end: the empty-batch short-circuit, +//! the result-decoding helpers, the documented batch constants, and — by etching +//! the real Multicall3 runtime at its canonical address — the live `aggregate3` +//! build/execute/decode path, including input-order results and `allowFailure` +//! partial-result semantics. mod common; -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, Bytes, U256, hex, keccak256}; use alloy_sol_types::{SolCall, SolValue, sol}; use anyhow::Result; +use revm::state::{AccountInfo, Bytecode}; -use common::setup_cache; +use common::{MOCK_ERC20_BALANCE_SLOT, install_default_account, install_mock_erc20, setup_cache}; +use evm_fork_cache::cache::EvmCache; use evm_fork_cache::multicall::{ - IMulticall3, MAX_BATCH_SIZE, MulticallBatch, decode_result, execute_batched, try_decode_result, + IMulticall3, MAX_BATCH_SIZE, MULTICALL3_ADDRESS, MulticallBatch, decode_result, + execute_batched, try_decode_result, }; +/// Real Multicall3 deployed (runtime) bytecode, fetched from mainnet. Multicall3 +/// is deployed at the same address ([`MULTICALL3_ADDRESS`]) on virtually all EVM +/// chains, so etching it locally reproduces the real `aggregate3` behavior. +const MULTICALL3_RUNTIME_HEX: &str = include_str!("../fixtures/multicall3_runtime.hex"); + sol! { function getValue() external returns (uint256); + function balanceOf(address account) external returns (uint256); +} + +/// `keccak256(abi.encode(owner, balanceSlot))` — the MockERC20 balance mapping slot. +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0) +} + +/// Etch deployed runtime bytecode at `addr` and mark its storage local, so an +/// unseeded slot reads as zero rather than hitting the mocked RPC backend. +fn install_runtime(cache: &mut EvmCache, addr: Address, runtime_hex: &str) { + let code = Bytecode::new_raw(Bytes::from( + hex::decode(runtime_hex.trim()).expect("valid runtime hex"), + )); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + addr, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(code), + code_hash, + account_id: None, + }, + ); + cache + .db_mut() + .replace_account_storage(addr, Default::default()) + .expect("clear etched storage"); } /// An empty batch returns empty results without invoking the EVM, on all three @@ -95,3 +133,88 @@ fn decode_result_rejects_garbage_payload() { fn max_batch_size_constant() { assert_eq!(MAX_BATCH_SIZE, 200); } + +/// Etching the real Multicall3 runtime lets the live `aggregate3` path run fully +/// offline: results come back one-per-call in input order and decode to the +/// seeded values. +#[tokio::test(flavor = "multi_thread")] +async fn aggregate3_executes_offline_in_input_order() -> Result<()> { + let mut cache = setup_cache().await?; + install_runtime(&mut cache, MULTICALL3_ADDRESS, MULTICALL3_RUNTIME_HEX); + + let token = Address::repeat_byte(0x22); + let owner = Address::repeat_byte(0x33); + let other = Address::repeat_byte(0x44); + install_default_account(&mut cache, Address::ZERO); + install_mock_erc20(&mut cache, token); + // Seed the layer-1 CacheDB (the layer the EVM reads from) so the StorageCleared + // mock reports it; a backend-only seed would read as zero via the + // account-state-aware read path. + cache + .db_mut() + .insert_account_storage(token, balance_slot(owner), U256::from(5_000u64))?; + + let mut batch = MulticallBatch::new(); + batch.add_call(token, balanceOfCall { account: owner }, false); + batch.add_call(token, balanceOfCall { account: other }, false); + + let results = batch.execute(&mut cache)?; + assert_eq!(results.len(), 2, "one result per input call"); + assert!(results[0].success && results[1].success); + // Input order is preserved: owner first (5000), other second (0). + assert_eq!( + decode_result::(&results[0])?, + U256::from(5_000u64) + ); + assert_eq!(decode_result::(&results[1])?, U256::ZERO); + Ok(()) +} + +/// `allowFailure = true` lets a reverting call surface as `success = false` +/// alongside successful calls; `allowFailure = false` makes the whole batch revert +/// (an `Err`). `execute_tracked` captures the touched accounts. +#[tokio::test(flavor = "multi_thread")] +async fn aggregate3_allow_failure_partial_results_and_strict_revert() -> Result<()> { + let mut cache = setup_cache().await?; + install_runtime(&mut cache, MULTICALL3_ADDRESS, MULTICALL3_RUNTIME_HEX); + + let token = Address::repeat_byte(0x22); + let owner = Address::repeat_byte(0x33); + install_default_account(&mut cache, Address::ZERO); + install_mock_erc20(&mut cache, token); + cache + .db_mut() + .insert_account_storage(token, balance_slot(owner), U256::from(7u64))?; + + // An unknown selector reverts in the MockERC20 (no matching function). + let reverting = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); + + let mut batch = MulticallBatch::new(); + batch.add_call(token, balanceOfCall { account: owner }, false); + batch.add(token, reverting.clone(), true); // allowed to fail + let (results, access) = batch.execute_tracked(&mut cache)?; + + assert_eq!(results.len(), 2); + assert!(results[0].success); + assert_eq!( + decode_result::(&results[0])?, + U256::from(7u64) + ); + assert!( + !results[1].success, + "the reverting call surfaces as success = false, not an Err" + ); + assert!( + access.accounts.contains(&token), + "execute_tracked must capture the token account the inner call touched" + ); + + // The same revert with allow_failure = false fails the entire aggregate3. + let mut strict = MulticallBatch::new(); + strict.add(token, reverting, false); + assert!( + strict.execute(&mut cache).is_err(), + "a non-allowed revert reverts the whole batch" + ); + Ok(()) +} From 8d7d0b7f72c33c2c117524dd53e1a6a4acdc376d Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Thu, 25 Jun 2026 15:50:42 +0100 Subject: [PATCH 2/5] Reframe benchmarks around searcher value-prop, not COW internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README "Performance" section led with a copy-on-write `create_snapshot` vs `create_snapshot_deep_clone` table — an internal implementation A/B a first-time reader never chooses between. Replace it with the outcomes a searcher actually cares about, each measured against the fork-per-candidate loop they'd otherwise write. Four pillars, two of them exact CI-pinned integer counts: - **Data-fetch minimization (headline).** New `examples/fetch_minimization_counted.rs` and `tests/fetch_minimization.rs`: 500 candidates over a shared 8-slot hot set fetch 8 slots once (zero during fan-out) vs 4,000 fork-per-candidate — ~500x fewer RPC reads, a deterministic machine-independent count. - **Candidate-fan-out throughput.** New `benches/fanout.rs`: snapshot-once + overlay-per-candidate vs a full independent fork per candidate; per-candidate cost falls as N grows (~545x at N=128 over a 32k-slot fork). - **Reactive sync.** `tests/event_pipeline.rs` pins that `ingest_logs` keeps hot slots fresh with zero RPC fetches/block; `examples/reactive_cache.rs` prints it (0 during ingest, sampled reconcile as the honesty backstop). - **Optimistic latency-hiding.** Promote the existing `benches/freshness.rs` latency arms (time-to-result vs fetch-then-sim) in the README, labeled with the modeled 50ms delay. Honesty guardrails: both shared-hot-set (~Nx) and disjoint (1x, no win) cases are stated; wall-clock ratios are labeled machine-dependent and separated from the exact integer counts; the injected 50ms is called out as modeled, not measured. The internal COW-vs-deep-clone cost model moves to `docs/INTERNALS.md` (the `simulation` bench keeps it as a regression guard). `benches/fanout.rs` is registered in Cargo.toml. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++ Cargo.toml | 4 + README.md | 117 +++++++++++------- benches/fanout.rs | 140 +++++++++++++++++++++ docs/INTERNALS.md | 46 +++++++ examples/fetch_minimization_counted.rs | 162 +++++++++++++++++++++++++ examples/reactive_cache.rs | 42 +++++-- tests/event_pipeline.rs | 76 ++++++++++++ tests/fetch_minimization.rs | 128 +++++++++++++++++++ 9 files changed, 668 insertions(+), 55 deletions(-) create mode 100644 benches/fanout.rs create mode 100644 docs/INTERNALS.md create mode 100644 examples/fetch_minimization_counted.rs create mode 100644 tests/fetch_minimization.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e8eba..6a07821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,14 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). ### Added +- **Value-prop benchmarks & showcase.** The README "Performance" section now + leads with the searcher-facing outcomes vs a fork-per-candidate baseline: + data-fetch minimization (an exact, CI-pinned `~500x`-fewer-RPC-reads count via + the new `fetch_minimization_counted` example and `tests/fetch_minimization.rs`), + candidate-fan-out throughput (the new `benches/fanout.rs`), reactive event sync + (zero fetches per block, pinned in `tests/event_pipeline.rs`), and optimistic + latency-hiding (`benches/freshness.rs`). The internal copy-on-write snapshot + cost model moved to [`docs/INTERNALS.md`](docs/INTERNALS.md). - **Forked EVM cache** (`cache::EvmCache`) backed by `foundry-fork-db` with lazy RPC loading and on-disk persistence for accounts, storage, bytecode, and immutable metadata. diff --git a/Cargo.toml b/Cargo.toml index 839c1f9..e01d00f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,10 @@ harness = false name = "simulation" harness = false +[[bench]] +name = "fanout" +harness = false + [[bench]] name = "freshness" harness = false diff --git a/README.md b/README.md index 5bd1392..7659195 100644 --- a/README.md +++ b/README.md @@ -222,64 +222,95 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address); ## Performance -The headline is **Pillar A**: `create_snapshot()` is a copy-on-write view whose -cost tracks *changed* state, not *total* state — so the deeper the fork, the -larger the win over the retained `create_snapshot_deep_clone()` baseline. +A searcher evaluates many candidate transactions against the *same* recent block. +The naive build — a fresh revm fork/cache per candidate over an RPC node — +re-forks and cold-fetches the same hot state for every candidate, and blocks on +RPC to validate. `evm-fork-cache` **snapshots once, fans out cheaply, fetches each +slot at most once, and lets you act before validation returns.** The four results +below each compare against that naive loop. + +> **Fair baseline.** "Vanilla" / "fork-per-candidate" = the loop a searcher writes +> without this crate: a fresh independent fork (revm cache) per candidate that +> cold-fetches the slots it touches. Each result names how it is measured. + +**① Data fetched — the headline (exact, deterministic integer).** +Evaluating **500 candidates** that share an 8-slot hot working set, the crate +fetches each slot **once** and every candidate reads the frozen snapshot from +memory; the fan-out adds **zero** fetches: + +| | Slots fetched from RPC | +|---|---| +| **evm-fork-cache** (fetch once, fan out 500) | **8** | +| Vanilla (fresh fork per candidate) | **4,000** (500 × 8) | + +→ **~500× fewer RPC reads** (3,992 avoided). The ratio is `N` for a shared working +set; for fully *disjoint* per-candidate reads it is 1× (no win — stated honestly). +This count is machine-independent and CI-pinned. See +[`fetch_minimization_counted`](examples/fetch_minimization_counted.rs). + +**② Candidate throughput (wall-clock ratio).** +One `create_snapshot()` amortized across N cheap `EvmOverlay` clones vs a full +independent fork per candidate. Over a fork holding ~32k cold slots, per-candidate +cost *falls* as N grows while fork-per-candidate stays flat: + +| Candidates (N) | evm-fork-cache (per candidate) | Fork-per-candidate | Speedup | +|:---:|:---:|:---:|:---:| +| 1 | 41 µs | 2.1 ms | ~51× | +| 8 | 8.3 µs | 2.2 ms | ~260× | +| 32 | 4.9 µs | 2.2 ms | ~450× | +| 128 | 4.0 µs | 2.2 ms | **~545×** | + +→ ~250k candidates/sec vs ~460/sec at N=128. Overlays are `Send` and parallelize; +the live fork is not. (At N=1 there is little win — one snapshot is not yet +amortized.) See [`parallel_overlays`](examples/parallel_overlays.rs). + +**③ Reactive sync — staying fresh without polling (exact integer).** +Decoding a block's ERC-20 `Transfer` logs into targeted writes keeps the touched +balance slots correct with **0 RPC fetches/block**, where a poller would re-fetch +every changed slot. Sampled `reconcile()` re-reads a fraction to catch drift (the +honesty backstop). Holds only for slots the event stream covers. See +[`reactive_cache`](examples/reactive_cache.rs). + +**④ Optimistic execution — hiding validation latency (structural).** +Time-to-actionable-result is gated on local simulation, not RPC validation. With a +*modeled* 50 ms RPC round-trip, the optimistic result returns in **~14 µs** while +validation runs in the background (~53 ms); the naive fetch-then-act path pays +~54 ms before it can act. Only sims whose read set changed re-run (`rerun_count`). +The win is latency *hiding*, not elimination. See +[`freshness_optimistic`](examples/freshness_optimistic.rs). > [!NOTE] -> **Methodology.** Offline Criterion benchmark (`benches/simulation.rs`), median -> of the run, on an Apple M1 Pro (`aarch64-apple-darwin`). State is injected -> directly into the cache over a mocked provider — no network, no RPC. Absolute -> numbers vary by machine; the **ratio** is the point. "Deep clone" is the -> retained `create_snapshot_deep_clone()` (the legacy O(total state) flatten); -> "COW" is the current `create_snapshot()`. - -| Cache size (accounts × slots) | Deep clone | COW `create_snapshot` | Speedup | -|:-----------------------------:|:----------:|:---------------------:|:-------:| -| 100 × 8 | 53 µs | 2.1 µs | **~25×** | -| 1,000 × 8 | 791 µs | 19 µs | **~41×** | -| 2,000 × 16 | 3.2 ms | 52 µs | **~61×** | -| 5,000 × 16 | 9.5 ms | 113 µs | **~84×** | -| 10,000 × 16 | 16.5 ms | 214 µs | **~77×** | - -The deep clone copies every account and slot on every snapshot (O(total state)); -the COW snapshot folds only the hot delta over an `Arc`-shared, memoized base, so -its cost scales with *what changed* since the last snapshot. At a 10k-account fork -that is the difference between ~16 ms and ~0.2 ms per snapshot — the per-candidate -tax a search loop pays before every fan-out. - -Overlay buffer reuse (`EvmOverlay::reset()` recycling the shared-memory buffer -across simulations) trims a further slice of per-sim allocation overhead: - -| Concurrent overlays | Fresh per sim | Recycled (`reset`) | Speedup | -|:-------------------:|:-------------:|:------------------:|:-------:| -| 1 | 4.7 µs | 4.1 µs | ~1.15× | -| 8 | 35 µs | 32 µs | ~1.09× | -| 32 | 119 µs | 111 µs | ~1.07× | - -> Reproduce locally: `cargo bench --bench simulation` (groups -> `create_snapshot` and `overlay_fanout`). +> **Methodology.** All numbers are offline (mocked provider, state injected +> directly — no network). **Exact integer metrics** (slots fetched, fetches +> avoided/block) are deterministic and machine-independent; they are pinned by +> tests. **Wall-clock ratios** (throughput, latency) are Criterion medians on an +> Apple M1 Pro (`aarch64-apple-darwin`) — read the ratio, not the absolute. The +> 50 ms RPC latency in ④ is a modeled/injected delay, not a real network +> measurement. Live-RPC checks live behind the `RPC_URL` gate. +> +> Reproduce: `cargo run --example fetch_minimization_counted` (the deterministic +> headline), `cargo bench --bench fanout` (②), `cargo bench --bench freshness` +> (④). ## Benchmarks -Criterion benchmarks live in [`benches/`](benches). The offline benches exercise -the current hot paths at a range of cache sizes, including the Phase 5 -copy-on-write snapshot implementation and retained deep-clone baselines where -useful for A/B comparison: +Criterion benchmarks live in [`benches/`](benches) and run fully offline (mocked +provider) so they are reproducible: | Bench | Measures | | --- | --- | -| `simulation` | `create_snapshot` across cache sizes (100 → 10k accounts), overlay fan-out, `call_raw` throughput, sequential bundle execution, batched storage injection. | -| `freshness` | The optimistic loop end-to-end (CPU and latency-hiding), `verify_slots` at scale (1 → 1000 slots), and multi-sim fan-out. | +| `fanout` | **Pillar 1.** Amortized per-candidate throughput: `create_snapshot` + N overlays vs a full independent fork per candidate. | +| `freshness` | **Pillar 4.** The optimistic loop end-to-end (CPU and latency-hiding vs fetch-then-sim), `verify_slots` at scale (1 → 1000 slots), and multi-sim fan-out. | +| `event_pipeline` | **Pillar 3.** Per-decoder cost (ERC-20 `Transfer`), `ingest_logs` decode+apply throughput (1 → 1000 logs), and `reorg_to` purge cost. | | `state_update` | `apply_updates` throughput across batch sizes (1 → 1000 `Slot`s) and per-variant apply cost (`Slot` vs `Account` vs `Purge`). | -| `event_pipeline` | Per-decoder cost (ERC-20 `Transfer`, generic slot marker), `ingest_logs` decode+apply throughput (1 → 1000 logs), and `reorg_to` purge cost. | +| `simulation` | Hot-path micro-benches and snapshot-implementation regression guards (`create_snapshot` vs the deep-clone reference — an internal cost model, see [`docs/INTERNALS.md`](docs/INTERNALS.md)). | | `access_list` | Touch-set merge and EIP-2930 list construction. | | `revert_decoding` | Built-in (`Error`/`Panic`) and custom-error revert decoding, and decoder dispatch over a registered custom error. | | `create3` | CREATE3 address derivation. | ```sh cargo bench # all offline benches -cargo bench --bench simulation # one suite +cargo bench --bench fanout # one suite ``` The `rpc_mainnet` bench runs against **live mainnet state** to validate diff --git a/benches/fanout.rs b/benches/fanout.rs new file mode 100644 index 0000000..f4c82dc --- /dev/null +++ b/benches/fanout.rs @@ -0,0 +1,140 @@ +//! **Pillar 1 — parallel fan-out throughput.** +//! +//! Once recent chain state is snapshotted, the per-candidate cost of an isolated +//! simulation is a cheap `Arc`-clone overlay, not a fresh fork. This bench +//! contrasts the two loops a searcher can write to evaluate N candidates with +//! full per-candidate isolation, over a cache that holds realistic cold state: +//! +//! - **`snapshot_once`** (the crate): `create_snapshot()` once, then N cheap +//! `EvmOverlay` clones. The snapshot's cost is amortized across all N. +//! - **`fork_per_candidate`** (vanilla): a full independent fork per candidate — +//! modeled by `create_snapshot_deep_clone()` (the O(total state) flatten a +//! searcher pays to isolate each candidate without structural sharing) — then +//! the simulation. +//! +//! `Throughput::Elements(N)` makes Criterion report the **amortized per-candidate** +//! cost. As N grows the crate's per-candidate cost falls toward the overlay-clone +//! floor while fork-per-candidate stays flat at the full-clone cost; the gap +//! widens with the amount of state the fork holds. Wall-clock and +//! machine-dependent — read the ratio, not the absolute. (The integer fetch-count +//! win is in `examples/fetch_minimization_counted.rs`; this is the CPU side.) +//! +//! At N=1 there is little or no win: one snapshot is not yet amortized. That is +//! honest — the fan-out economics only pay off across many candidates. + +use std::hint::black_box; +use std::sync::Arc; + +use alloy_primitives::{Address, Bytes, U256, hex}; +use alloy_provider::RootProvider; +use alloy_provider::network::AnyNetwork; +use alloy_rpc_client::RpcClient; +use alloy_sol_types::{SolCall, sol}; +use alloy_transport::mock::Asserter; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use evm_fork_cache::cache::{EvmCache, EvmOverlay}; +use revm::context::result::ExecutionResult; +use revm::state::{AccountInfo, Bytecode}; +use tokio::runtime::Runtime; + +const MOCK_ERC20_RUNTIME_HEX: &str = include_str!("../fixtures/mock_erc20_runtime.hex"); +const BALANCE_SLOT: u64 = 3; + +sol! { + interface MockERC20 { + function balanceOf(address account) returns (uint256); + } +} + +fn addr(i: usize) -> Address { + let mut bytes = [0u8; 20]; + bytes[12..20].copy_from_slice(&(i as u64 + 1).to_be_bytes()); + Address::from(bytes) +} + +/// A warm cache holding `accounts x slots` of cold chain state (so a per-candidate +/// deep clone is realistically expensive) plus a MockERC20 with one funded owner. +fn warm_cache(rt: &Runtime, accounts: usize, slots: usize) -> (EvmCache, Address, Address, Bytes) { + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + let mut cache = rt.block_on(EvmCache::new(Arc::new(provider))); + + // Bulk cold state the fork is holding this block. + let mut batch: Vec<(Address, U256, U256)> = Vec::with_capacity(accounts * slots); + for a in 0..accounts { + for s in 0..slots { + batch.push(( + addr(a), + U256::from(s as u64), + U256::from((a * 31 + s) as u64), + )); + } + } + cache.inject_storage_batch(&batch); + + // The contract the candidates actually exercise. + let token = Address::repeat_byte(0xAA); + let owner = Address::repeat_byte(0xBB); + let runtime = Bytecode::new_raw(Bytes::from( + hex::decode(MOCK_ERC20_RUNTIME_HEX.trim()).unwrap(), + )); + let code_hash = runtime.hash_slow(); + cache.db_mut().insert_account_info( + token, + AccountInfo { + balance: U256::ZERO, + nonce: 0, + code: Some(runtime), + code_hash, + account_id: None, + }, + ); + cache + .insert_mapping_storage_slot(token, U256::from(BALANCE_SLOT), owner, U256::from(1_000u64)) + .unwrap(); + + let calldata = Bytes::from(MockERC20::balanceOfCall { account: owner }.abi_encode()); + (cache, token, owner, calldata) +} + +fn bench_candidate_fanout(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + // A fork holding ~32k cold slots (2,000 accounts x 16 slots) — the deep clone + // a fork-per-candidate loop pays scales with this; the snapshot shares it. + let (mut cache, token, owner, calldata) = warm_cache(&rt, 2_000, 16); + black_box(cache.create_snapshot()); // warm the memoized base once + + let mut group = c.benchmark_group("candidate_fanout"); + for &n in &[1usize, 8, 32, 128] { + group.throughput(Throughput::Elements(n as u64)); + + // Crate: one snapshot amortized across N cheap overlay clones. + group.bench_with_input(BenchmarkId::new("snapshot_once", n), &n, |b, &n| { + b.iter(|| { + let snapshot = cache.create_snapshot(); + for _ in 0..n { + 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); + } + }) + }); + + // Vanilla: a full independent fork (deep clone) per candidate. + group.bench_with_input(BenchmarkId::new("fork_per_candidate", n), &n, |b, &n| { + b.iter(|| { + for _ in 0..n { + let snapshot = cache.create_snapshot_deep_clone(); + let mut overlay = EvmOverlay::new(snapshot, None); + let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + black_box(result); + } + }) + }); + } + group.finish(); +} + +criterion_group!(benches, bench_candidate_fanout); +criterion_main!(benches); diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md new file mode 100644 index 0000000..30468cb --- /dev/null +++ b/docs/INTERNALS.md @@ -0,0 +1,46 @@ +# Internals — the copy-on-write snapshot cost model + +> This is an implementation note, not a value-prop benchmark. For the numbers a +> consumer cares about (fetch minimization, candidate throughput, reactive sync, +> optimistic latency) see the **Performance** section of the [README](../README.md). + +`create_snapshot()` is the operation a search loop runs once per block before +fanning candidates out. Phase 5 replaced its original O(total state) deep clone +with a two-tier copy-on-write view: the cold `BlockchainDb` index (layer 2) is +flattened once into an immutable, `Arc`-shared base (per-account storage shared by +`Arc`), memoized across snapshots and rebuilt copy-on-write only for changed +addresses; each snapshot then folds just the hot CacheDB delta (layer 1). Reads +stay O(1) and bit-for-bit identical to the deep clone (pinned by the differential +gate in `tests/cow_snapshot.rs`). + +The retained `create_snapshot_deep_clone()` is kept as (a) the read-equivalence +reference and (b) the A/B regression baseline, so a future change can't silently +regress `create_snapshot` back toward O(total state). The `create_snapshot` group +in `benches/simulation.rs` measures both. + +Indicative A/B (Apple M1 Pro, `aarch64-apple-darwin`, Criterion medians; offline, +state injected directly — read the ratio, not the absolute): + +| Cache size (accounts × slots) | Deep clone | COW `create_snapshot` | Ratio | +|:-----------------------------:|:----------:|:---------------------:|:-----:| +| 100 × 8 | 53 µs | 2.1 µs | ~25× | +| 1,000 × 8 | 791 µs | 19 µs | ~41× | +| 2,000 × 16 | 3.2 ms | 52 µs | ~61× | +| 5,000 × 16 | 9.5 ms | 113 µs | ~84× | +| 10,000 × 16 | 16.5 ms | 214 µs | ~77× | + +The deep clone copies every account and slot on every snapshot (O(total state)); +the COW snapshot shares the memoized base and folds only the hot delta, so its +cost tracks *changed* state, not *total* state. This is what makes the +[per-block fan-out](../README.md#performance) cheap — but it is an internal cost +model, which is why it lives here rather than in the README headline. + +**Residual cost (honest):** a COW snapshot is not free. When layer 2 is unchanged +it still pays an O(accounts) length-scan of the layer-2 maps plus an O(layer-1) +fold of the hot delta; a full rebuild (first snapshot, or after `set_block`) is +still O(total state). The decisions and the full cost model are in +[`phase-5-spec.md`](phase-5-spec.md) and [`ROADMAP.md`](ROADMAP.md), and the +residual is recorded in [`KNOWN_ISSUES.md`](KNOWN_ISSUES.md). + +Reproduce: `cargo bench --bench simulation` (the `create_snapshot` and +`resnapshot_hot_loop` groups). diff --git a/examples/fetch_minimization_counted.rs b/examples/fetch_minimization_counted.rs new file mode 100644 index 0000000..96b1881 --- /dev/null +++ b/examples/fetch_minimization_counted.rs @@ -0,0 +1,162 @@ +//! **Pillar 2 — data-fetch minimization (the headline number).** +//! +//! A searcher evaluating N candidate transactions against the *same* recent block +//! reads the same hot working set (a pool's slots, a handful of token balances) +//! over and over. The naive loop — a fresh fork/cache per candidate — cold-fetches +//! that working set *every* candidate. `evm-fork-cache` fetches it **once**, freezes +//! it into a snapshot, and fans every candidate out over cheap in-memory overlays +//! that hold no RPC backend, so the fan-out adds **zero** further fetches. +//! +//! This example counts real fetcher invocations (an `AtomicUsize`-wrapped +//! [`StorageBatchFetchFn`]) and prints the exact integers. The count is +//! deterministic and machine-independent — it is a literal tally of RPC reads +//! avoided, not a wall-clock measurement. +//! +//! Run with: `cargo run --example fetch_minimization_counted` + +#[path = "support/mock.rs"] +mod mock; + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use anyhow::Result; +use evm_fork_cache::cache::{EvmCache, EvmOverlay, StorageBatchFetchFn}; +use revm::context::result::ExecutionResult; +use revm::state::AccountInfo; + +/// Candidate transactions evaluated against the head this block. +const N_CANDIDATES: usize = 500; +/// Distinct storage slots in the shared hot working set (token balance slots). +const WORKING_SET: usize = 8; + +/// An owner address derived from an index. +fn owner(i: usize) -> Address { + let mut bytes = [0u8; 20]; + bytes[12..20].copy_from_slice(&(i as u64 + 1).to_be_bytes()); + Address::from(bytes) +} + +/// `keccak256(abi.encode(owner, MOCK_ERC20_BALANCE_SLOT))` — the balance slot. +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes( + keccak256((owner, U256::from(mock::MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0, + ) +} + +/// An `AtomicUsize`-counting batch fetcher: tallies every requested slot and +/// returns a canned non-zero balance, standing in for the RPC backend. +fn counting_fetcher(counter: Arc) -> StorageBatchFetchFn { + Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + counter.fetch_add(requests.len(), Ordering::Relaxed); + requests + .into_iter() + .map(|(addr, slot)| (addr, slot, Ok(U256::from(1_000u64)))) + .collect() + }, + ) +} + +/// Build an offline cache with a MockERC20 installed at `token` (storage left +/// non-cleared so warmed layer-2 slots are readable) plus a counting fetcher. +async fn cache_with_counter(token: Address) -> Result<(EvmCache, Arc)> { + let mut cache = mock::offline_cache().await?; + let runtime = mock::mock_erc20_runtime(); + let code_hash = runtime.hash_slow(); + cache.db_mut().insert_account_info( + token, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(runtime), + code_hash, + account_id: None, + }, + ); + let counter = Arc::new(AtomicUsize::new(0)); + cache.set_storage_batch_fetcher(counting_fetcher(counter.clone())); + Ok((cache, counter)) +} + +fn balance_of_calldata(account: Address) -> Bytes { + Bytes::from(mock::MockERC20::balanceOfCall { account }.abi_encode()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + let token = Address::repeat_byte(0x42); + let working_set: Vec<(Address, U256)> = (0..WORKING_SET) + .map(|i| (token, balance_slot(owner(i)))) + .collect(); + + // ── evm-fork-cache: fetch the working set ONCE, then fan out ────────────── + let (mut cache, counter) = cache_with_counter(token).await?; + + // Warm the hot working set in a single batch (the prefetch a searcher does + // once per block). Every slot is fetched exactly once. + cache.verify_slots(&working_set)?; + let warmup_fetches = counter.load(Ordering::Relaxed); + + // Freeze the warmed state, then fan N candidates out over cheap overlays. + let snapshot = cache.create_snapshot(); + counter.store(0, Ordering::Relaxed); // measure only the fan-out from here + + for c in 0..N_CANDIDATES { + // Each candidate is an isolated simulation reading the shared hot set. + // The overlay holds no RPC backend (`None`), so it cannot fetch — every + // read is served from the frozen snapshot in memory. + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for i in 0..WORKING_SET { + let result = + overlay.call_raw(owner(c % WORKING_SET), token, balance_of_calldata(owner(i)))?; + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + } + } + let fanout_fetches = counter.load(Ordering::Relaxed); + + // ── Vanilla baseline: a fresh cold cache per candidate ─────────────────── + // Each independent cache cold-fetches the working set it reads. We measure + // the per-candidate cost on one real cold cache; it then repeats N times. + let (mut cold, cold_counter) = cache_with_counter(token).await?; + cold.verify_slots(&working_set)?; + let per_candidate_fetches = cold_counter.load(Ordering::Relaxed); + let vanilla_total = per_candidate_fetches * N_CANDIDATES; + + let crate_total = warmup_fetches + fanout_fetches; + + println!( + "Workload: {N_CANDIDATES} candidate txs, each reading a shared {WORKING_SET}-slot hot set\n" + ); + println!("evm-fork-cache"); + println!(" warm-up fetches (once): {warmup_fetches}"); + println!( + " fan-out fetches ({N_CANDIDATES} sims): {fanout_fetches} <- overlays read the frozen snapshot, no backend" + ); + println!(" TOTAL slots fetched: {crate_total}"); + println!("\nVanilla fork-per-candidate"); + println!(" fetches per candidate: {per_candidate_fetches}"); + println!( + " TOTAL slots fetched: {vanilla_total} ({N_CANDIDATES} x {per_candidate_fetches})" + ); + println!( + "\n=> {}x fewer RPC reads ({} slots avoided)", + vanilla_total / crate_total.max(1), + vanilla_total - crate_total + ); + println!( + "\nNote: this is the shared-hot-set case. If every candidate read a DISJOINT\n\ + slot set, the union the crate fetches would equal the vanilla total and there\n\ + would be no fetch saving — the win comes from reuse across overlapping reads." + ); + + assert_eq!( + warmup_fetches, WORKING_SET, + "warm-up fetches the working set once" + ); + assert_eq!(fanout_fetches, 0, "the fan-out adds zero fetches"); + Ok(()) +} diff --git a/examples/reactive_cache.rs b/examples/reactive_cache.rs index 87717e5..45544c3 100644 --- a/examples/reactive_cache.rs +++ b/examples/reactive_cache.rs @@ -21,6 +21,7 @@ mod mock; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use alloy_eips::BlockId; use alloy_primitives::{Address, Bytes, Log, U256, keccak256}; @@ -77,6 +78,25 @@ async fn main() -> Result<()> { let mut controller = FreshnessController::new(FreshnessRegistry::new(), AlwaysVerify); + // Install a counting fetcher up front so we can show that *ingest* performs no + // RPC reads (it decodes logs into writes), while *reconcile* below samples a + // few slots on purpose. It returns the (deliberately drifted) chain value for + // bob so the reconcile step has something to correct. + let fetches = Arc::new(AtomicUsize::new(0)); + let counter = fetches.clone(); + let fresh: HashMap<(Address, U256), U256> = + HashMap::from([((token, bob_slot), U256::from(260))]); + let fetcher: StorageBatchFetchFn = Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + counter.fetch_add(requests.len(), Ordering::Relaxed); + requests + .into_iter() + .map(|(a, s)| (a, s, Ok(fresh.get(&(a, s)).copied().unwrap_or(U256::ZERO)))) + .collect() + }, + ); + cache.set_storage_batch_fetcher(fetcher); + let block = 100u64; let digest = pipeline.ingest_logs( &mut cache, @@ -85,6 +105,11 @@ async fn main() -> Result<()> { ); println!("=== ingested block {} ===", digest.block); + println!( + " RPC slot fetches during ingest: {} <- a poller would refetch {} touched slot(s)/block", + fetches.load(Ordering::Relaxed), + digest.touched_slots.len(), + ); println!( " decoded {} log(s) -> {} slot change(s), {} skipped", digest.decoded_logs, @@ -108,20 +133,13 @@ async fn main() -> Result<()> { digest.touched_slots.len() ); - let fresh: HashMap<(Address, U256), U256> = - HashMap::from([((token, bob_slot), U256::from(260))]); - let fetcher: StorageBatchFetchFn = Arc::new( - move |requests: Vec<(Address, U256)>, _block: Option| { - requests - .into_iter() - .map(|(a, s)| (a, s, Ok(fresh.get(&(a, s)).copied().unwrap_or(U256::ZERO)))) - .collect() - }, - ); - cache.set_storage_batch_fetcher(fetcher); - + fetches.store(0, Ordering::Relaxed); // measure reconcile's sampling cost let report = pipeline.reconcile(&mut cache, &[(token, bob_slot)])?; println!("\n=== reconcile (sampled {} slot) ===", report.checked); + println!( + " RPC slot fetches during reconcile: {} <- the sampled honesty backstop", + fetches.load(Ordering::Relaxed), + ); if report.mismatched.is_empty() { println!(" no drift: event-derived state matches chain"); } else { diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index 5cadd13..bea625e 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -532,3 +532,79 @@ async fn reconcile_errors_without_fetcher() -> Result<()> { ); Ok(()) } + +/// Pillar 3 (reactive sync): ingesting a block's `Transfer` logs keeps the hot +/// balance slots correct with **zero** RPC fetches — the decode→write pipeline +/// never touches the fetcher, unlike a poller that re-reads every changed slot +/// each block. Sampled `reconcile` (which *does* fetch) is the honesty backstop. +#[tokio::test(flavor = "multi_thread")] +async fn ingest_keeps_state_fresh_with_zero_fetches() -> Result<()> { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use alloy_eips::BlockId; + use common::{balance_of, install_default_account}; + use evm_fork_cache::cache::StorageBatchFetchFn; + + let token = Address::repeat_byte(0x33); + let owners: Vec

= (0..8u8).map(|i| Address::repeat_byte(0x40 + i)).collect(); + + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, token); + install_default_account(&mut cache, Address::ZERO); // fee beneficiary + for o in &owners { + // Install each owner as an account (so it can be the balanceOf caller + // without a lazy RPC load) and seed its balance (layer 1, readable) so the + // deltas apply hot. + install_default_account(&mut cache, *o); + cache + .db_mut() + .insert_account_storage(token, mapping_slot(*o, 3), U256::from(1_000))?; + } + + // A counting fetcher: `ingest_logs` must never invoke it. + let fetches = Arc::new(AtomicUsize::new(0)); + let counter = fetches.clone(); + let f: StorageBatchFetchFn = + Arc::new(move |reqs: Vec<(Address, U256)>, _b: Option| { + counter.fetch_add(reqs.len(), Ordering::Relaxed); + reqs.into_iter() + .map(|(a, s)| (a, s, Ok(U256::from(1_000)))) + .collect() + }); + cache.set_storage_batch_fetcher(f); + + let mut registry = DecoderRegistry::new(); + registry.register(Arc::new(Erc20TransferDecoder::new(U256::from(3)))); + let mut pipeline = EventPipeline::new(registry); + + // A block of transfers around a ring (each owner sends 10 and receives 10 → + // balances net back to 1000), touching every owner's balance slot. + let logs: Vec = (0..owners.len()) + .map(|i| { + transfer_log( + token, + owners[i], + owners[(i + 1) % owners.len()], + U256::from(10), + ) + }) + .collect(); + + let digest = pipeline.ingest_logs(&mut cache, 100, &logs); + + assert_eq!( + fetches.load(Ordering::Relaxed), + 0, + "ingest decodes logs into writes and performs ZERO RPC fetches" + ); + assert_eq!(digest.decoded_logs, owners.len()); + assert!( + !digest.applied.has_skipped(), + "all hot balance deltas applied" + ); + // Correctness: state kept fresh from the logs alone equals the true post-state. + for o in &owners { + assert_eq!(balance_of(&mut cache, token, *o)?, U256::from(1_000)); + } + Ok(()) +} diff --git a/tests/fetch_minimization.rs b/tests/fetch_minimization.rs new file mode 100644 index 0000000..b622bcb --- /dev/null +++ b/tests/fetch_minimization.rs @@ -0,0 +1,128 @@ +//! Pins the Pillar 2 headline (data-fetch minimization) as a deterministic, +//! CI-guaranteed integer invariant: the crate fetches a shared hot working set +//! ONCE, and fanning N candidate simulations out over the frozen snapshot adds +//! ZERO further fetches — so a fork-per-candidate loop fetches `N x` as much. + +mod common; + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use anyhow::Result; +use evm_fork_cache::cache::{EvmCache, EvmOverlay, StorageBatchFetchFn}; +use revm::context::result::ExecutionResult; +use revm::state::AccountInfo; + +use common::{MOCK_ERC20_BALANCE_SLOT, MockERC20, mock_erc20_runtime, setup_cache}; + +const WORKING_SET: usize = 8; +const N_CANDIDATES: usize = 500; +const SEEDED_BALANCE: u64 = 1_000; + +fn owner(i: usize) -> Address { + let mut bytes = [0u8; 20]; + bytes[12..20].copy_from_slice(&(i as u64 + 1).to_be_bytes()); + Address::from(bytes) +} + +fn balance_slot(owner: Address) -> U256 { + U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0) +} + +fn counting_fetcher(counter: Arc) -> StorageBatchFetchFn { + Arc::new( + move |requests: Vec<(Address, U256)>, _block: Option| { + counter.fetch_add(requests.len(), Ordering::Relaxed); + requests + .into_iter() + .map(|(addr, slot)| (addr, slot, Ok(U256::from(SEEDED_BALANCE)))) + .collect() + }, + ) +} + +async fn cache_with_counter(token: Address) -> Result<(EvmCache, Arc)> { + let mut cache = setup_cache().await?; + let runtime = mock_erc20_runtime(); + let code_hash = runtime.hash_slow(); + cache.db_mut().insert_account_info( + token, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code: Some(runtime), + code_hash, + account_id: None, + }, + ); + let counter = Arc::new(AtomicUsize::new(0)); + cache.set_storage_batch_fetcher(counting_fetcher(counter.clone())); + Ok((cache, counter)) +} + +fn balance_of(overlay: &mut EvmOverlay, caller: Address, token: Address, account: Address) -> U256 { + let calldata = Bytes::from(MockERC20::balanceOfCall { account }.abi_encode()); + match overlay.call_raw(caller, token, calldata).unwrap() { + ExecutionResult::Success { output, .. } => { + MockERC20::balanceOfCall::abi_decode_returns(&output.into_data()).unwrap() + } + other => panic!("balanceOf failed: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn fan_out_reuses_one_warmup_fetch_across_all_candidates() -> Result<()> { + let token = Address::repeat_byte(0x42); + let working_set: Vec<(Address, U256)> = (0..WORKING_SET) + .map(|i| (token, balance_slot(owner(i)))) + .collect(); + + let (mut cache, counter) = cache_with_counter(token).await?; + + // Warm the working set once. + cache.verify_slots(&working_set)?; + assert_eq!( + counter.load(Ordering::Relaxed), + WORKING_SET, + "warm-up fetches each working-set slot exactly once" + ); + + let snapshot = cache.create_snapshot(); + counter.store(0, Ordering::Relaxed); + + // Fan N candidates out; each reads the whole shared hot set from the snapshot. + for c in 0..N_CANDIDATES { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for i in 0..WORKING_SET { + // Correctness: the warmed value is actually served (reuse works), and + // the read does not fetch. + assert_eq!( + balance_of(&mut overlay, owner(c % WORKING_SET), token, owner(i)), + U256::from(SEEDED_BALANCE) + ); + } + } + + assert_eq!( + counter.load(Ordering::Relaxed), + 0, + "the {N_CANDIDATES}-candidate fan-out adds zero fetches (overlays read the snapshot)" + ); + + // The fork-per-candidate baseline: one real cold cache fetches the whole + // working set, so N of them fetch N x as much. + let (mut cold, cold_counter) = cache_with_counter(token).await?; + cold.verify_slots(&working_set)?; + let per_candidate = cold_counter.load(Ordering::Relaxed); + assert_eq!(per_candidate, WORKING_SET); + assert_eq!( + per_candidate * N_CANDIDATES, + WORKING_SET * N_CANDIDATES, + "vanilla fork-per-candidate re-fetches the working set every candidate" + ); + + Ok(()) +} From 941d067f884b1aa7f606984f912dbe1086b14aa0 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Thu, 25 Jun 2026 15:56:32 +0100 Subject: [PATCH 3/5] Clarify that the fan-out throughput win is CPU/isolation cost, not RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of the benchmark reframe flagged a framing gap: the candidate-throughput baseline (`fork_per_candidate`) is `create_snapshot_deep_clone`, an in-memory deep copy that does NO fetching — so the ~545× throughput speedup is snapshot-construction/isolation cost, not RPC avoidance. The "fair baseline" box implied the baseline cold-fetches, conflating the throughput win (②) with the separate fetch-count win (①). Disambiguate in the README Performance section: ① measures the fetch cost (a cold cache per candidate re-fetches), ② measures the isolation/CPU cost (a full independent clone per candidate, no RPC) — the two are separate and not double-counted. All measured numbers are unchanged and verified accurate. Co-Authored-By: Claude Opus 4.8 --- README.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7659195..38cc2a6 100644 --- a/README.md +++ b/README.md @@ -223,15 +223,20 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address); ## Performance A searcher evaluates many candidate transactions against the *same* recent block. -The naive build — a fresh revm fork/cache per candidate over an RPC node — -re-forks and cold-fetches the same hot state for every candidate, and blocks on -RPC to validate. `evm-fork-cache` **snapshots once, fans out cheaply, fetches each -slot at most once, and lets you act before validation returns.** The four results -below each compare against that naive loop. +The naive build — a fresh revm fork/cache per candidate over an RPC node — pays +twice per candidate: it **re-fetches** the same hot state *and* **re-builds** an +independent fork to isolate the candidate, then **blocks on RPC** to validate. +`evm-fork-cache` fetches each slot once, snapshots once, clones cheaply per +candidate, and lets you act before validation returns. The results below isolate +each of those costs against the naive loop — the **fetch** win (①) and the +per-candidate **isolation/CPU** win (②) are *separate and not double-counted*. > **Fair baseline.** "Vanilla" / "fork-per-candidate" = the loop a searcher writes -> without this crate: a fresh independent fork (revm cache) per candidate that -> cold-fetches the slots it touches. Each result names how it is measured. +> without this crate. ① measures the **fetch** cost: a fresh cold cache per +> candidate that re-fetches the slots it touches. ② measures the **isolation/CPU** +> cost: a full independent fork (a deep clone of the warm, in-memory state) per +> candidate — it does *no* RPC, so ②'s speedup is snapshot-construction cost, not +> network. Each result names how it is measured. **① Data fetched — the headline (exact, deterministic integer).** Evaluating **500 candidates** that share an 8-slot hot working set, the crate @@ -248,10 +253,13 @@ set; for fully *disjoint* per-candidate reads it is 1× (no win — stated hones This count is machine-independent and CI-pinned. See [`fetch_minimization_counted`](examples/fetch_minimization_counted.rs). -**② Candidate throughput (wall-clock ratio).** +**② Candidate throughput (wall-clock ratio — CPU/isolation cost only).** One `create_snapshot()` amortized across N cheap `EvmOverlay` clones vs a full -independent fork per candidate. Over a fork holding ~32k cold slots, per-candidate -cost *falls* as N grows while fork-per-candidate stays flat: +independent fork (a deep clone) per candidate. **Both sides run warm with no RPC**, +so this isolates the *snapshot-once-and-share vs full-clone-per-candidate* cost — +it is **not** the fetch win in ① (which is counted separately). Over a fork +holding ~32k cold slots, per-candidate cost *falls* as N grows while +fork-per-candidate stays flat: | Candidates (N) | evm-fork-cache (per candidate) | Fork-per-candidate | Speedup | |:---:|:---:|:---:|:---:| From 3d747639018cc54728b3869e5262c1cf6d435e50 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Thu, 25 Jun 2026 16:13:43 +0100 Subject: [PATCH 4/5] Add SVG benchmark charts and mermaid architecture diagrams to the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visuals for the value-prop performance story: - assets/fetch_amplification.svg — the headline: fetches stay flat at 8 while fork-per-candidate grows linearly to 4,000 over 500 candidates (~500x). - assets/fanout_throughput.svg — per-candidate cost (log scale) falls 41→4 µs as N grows while fork-per-candidate stays flat near 2.1 ms (~545x at N=128). Both are self-contained dark-card SVGs (no external CSS) so they render on either GitHub theme; embedded via absolute raw-main URLs so they also resolve on crates.io after merge. - Mermaid state-stack/control-plane flowchart replacing the ASCII diagram in "Core concepts" (lib.rs keeps the ASCII for docs.rs), plus a sequence diagram of the optimistic verify-and-rerun loop showing time-to-result decoupled from RPC validation. Numbers match the measured benchmarks already cited in the Performance section. Co-Authored-By: Claude Opus 4.8 --- README.md | 59 +++++++++++++++++++++++++------ assets/fanout_throughput.svg | 63 ++++++++++++++++++++++++++++++++++ assets/fetch_amplification.svg | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 assets/fanout_throughput.svg create mode 100644 assets/fetch_amplification.svg diff --git a/README.md b/README.md index 38cc2a6..9945abc 100644 --- a/README.md +++ b/README.md @@ -125,16 +125,20 @@ println!( ## Core concepts The state stack flows bottom-to-top; reads flow up and the fork DB lazily fetches -misses from RPC: - -``` -EvmOverlay × N isolated, Send simulations (cheap Arc clones) - ▲ clone × N -EvmSnapshot immutable, point-in-time, Send + Sync - ▲ create_snapshot() -EvmCache lazy RPC fetch + local state cache + targeted writes/purge - ▲ lazy fetch -RPC provider +misses from RPC. The event-log path writes hot state in with **no RPC** (the +reactive-sync control plane): + +```mermaid +flowchart BT + RPC["RPC provider"] -->|"lazy fetch · once"| CACHE + LOGS["on-chain event logs"] -.->|"decode → write · 0 RPC"| CACHE + CACHE["EvmCache · !Send
fetch · cache · targeted writes/purge"] -->|"create_snapshot()"| SNAP + SNAP["EvmSnapshot · Send + Sync
immutable · Arc · point-in-time"] -->|"cheap Arc clone × N"| OV + OV["EvmOverlay × N · Send
isolated parallel simulations"] + classDef hot fill:#102a17,stroke:#3fb950,color:#e6edf3; + classDef cool fill:#0d1f2d,stroke:#388bfd,color:#e6edf3; + class SNAP,OV hot; + class RPC,CACHE,LOGS cool; ``` - **`EvmCache`** owns the mutable fork: it fetches, caches, persists, and applies @@ -149,7 +153,30 @@ The [`freshness`](src/freshness.rs) module layers a freshness controller on top: classify each address/slot (`Pinned` / `Volatile` / `ValidThrough`), observe how often slots change, pick what to verify each cycle with a `FreshnessPolicy`, and run the optimistic loop that returns speculative results immediately and a -`Confirmed`/`Corrected`/`Unverified` verdict asynchronously. +`Confirmed`/`Corrected`/`Unverified` verdict asynchronously. Time-to-actionable-result +is gated on local simulation, not on the RPC validation that runs behind it: + +```mermaid +sequenceDiagram + autonumber + participant S as Search loop + participant C as FreshnessController + participant V as Background validator + participant R as RPC + S->>C: run(candidate sims) + C->>C: snapshot + run optimistic sims + C-->>S: SpeculativeSim — optimistic results (~µs) + Note over S: act on speculative results now + C->>V: spawn (Send data only) + V->>R: verify volatile read-set (~L ms) + R-->>V: fresh values + alt nothing the sims read changed + V-->>S: validate().await → Confirmed + else a read slot changed + V->>V: re-run only the affected sims + V-->>S: Corrected { results, changed } + end +``` ## Examples @@ -253,6 +280,11 @@ set; for fully *disjoint* per-candidate reads it is 1× (no win — stated hones This count is machine-independent and CI-pinned. See [`fetch_minimization_counted`](examples/fetch_minimization_counted.rs). +

+ RPC slot fetches: evm-fork-cache stays flat at 8 while fork-per-candidate grows linearly to 4,000 over 500 candidates +

+ **② Candidate throughput (wall-clock ratio — CPU/isolation cost only).** One `create_snapshot()` amortized across N cheap `EvmOverlay` clones vs a full independent fork (a deep clone) per candidate. **Both sides run warm with no RPC**, @@ -272,6 +304,11 @@ fork-per-candidate stays flat: the live fork is not. (At N=1 there is little win — one snapshot is not yet amortized.) See [`parallel_overlays`](examples/parallel_overlays.rs). +

+ Per-candidate cost on a log scale: evm-fork-cache falls from 41 to 4 microseconds as N grows while fork-per-candidate stays flat near 2.1 milliseconds +

+ **③ Reactive sync — staying fresh without polling (exact integer).** Decoding a block's ERC-20 `Transfer` logs into targeted writes keeps the touched balance slots correct with **0 RPC fetches/block**, where a poller would re-fetch diff --git a/assets/fanout_throughput.svg b/assets/fanout_throughput.svg new file mode 100644 index 0000000..3381f20 --- /dev/null +++ b/assets/fanout_throughput.svg @@ -0,0 +1,63 @@ + + Per-candidate simulation cost vs fan-out size + On a log scale, evm-fork-cache per-candidate cost falls from 41 to 4 microseconds as N grows while fork-per-candidate stays flat near 2.1 milliseconds — about 545x faster at N=128. + + + + Candidate throughput — amortized per-candidate cost + one snapshot shared across N overlays vs a full independent fork per candidate (log scale) + + + + + + + + + + + + + + 1µs + 10µs + 100µs + 1ms + 10ms + + cost per candidate + + + + 1 + 8 + 32 + 128 + + fan-out size (N candidates) + + + + + ≈ 2.1 ms + + + + + 41µs + 4.0 µs + + + + ~545× + + + + + evm-fork-cache (snapshot once, fan out) + + fork-per-candidate + + + Warm, in-memory — CPU/isolation cost, not the fetch win. Criterion medians, Apple M1 Pro; read the ratio, not the absolute. + diff --git a/assets/fetch_amplification.svg b/assets/fetch_amplification.svg new file mode 100644 index 0000000..f8bc570 --- /dev/null +++ b/assets/fetch_amplification.svg @@ -0,0 +1,62 @@ + + RPC slot fetches: evm-fork-cache vs fork-per-candidate + Evaluating 500 candidates over a shared 8-slot hot set, fork-per-candidate fetches grow linearly to 4,000 while evm-fork-cache stays flat at 8 — about 500x fewer RPC reads. + + + + Data fetched — 500 candidates, shared hot set + fork-per-candidate re-fetches the working set every candidate; evm-fork-cache fetches it once + + + + + + + + + + + + + + 0 + 1k + 2k + 3k + 4k + + slots fetched from RPC + + + + 0 + 100 + 250 + 500 + + candidate transactions evaluated (N) + + + + + 4,000 + + + + + 8 + + + + + fork-per-candidate = N × 8 + + evm-fork-cache = 8 (fetched once) + + + + ≈ 500× fewer + RPC reads (3,992 slots avoided) + + Exact, machine-independent count (CI-pinned). Shared hot set; fully disjoint reads → 1× (no win). + From c299215947c326abd058fb88cdb1f5135cd6bfec Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Fri, 26 Jun 2026 15:04:59 +0100 Subject: [PATCH 5/5] Reframe performance honestly: drop strawman multipliers, state parity plainly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A red-team of the benchmark baselines (steelmanning what a competent revm / foundry-fork-db user actually writes) found the headline multipliers were beating a strawman, not a real peer: - ~500x fewer fetches: a shared foundry-fork-db SharedBackend caches + dedups, so it also fetches each hot slot once within a block (its cache hit path is backend.rs:204-242). Within-block ratio is ~1x, not 500x. - ~545x throughput: the baseline (create_snapshot_deep_clone per candidate) is an O(total-state) copy nobody writes; checkpoint/revert isolation (the crate's own primitive) is O(touched), so single-threaded the gap is ~1x. - ~3,800x latency: ~99.97% of it is the injected 50ms sleep the baseline elects to pay; a competent loop doesn't block on a fetch before acting. I also re-measured the parallelism I expected to be the real win: it is only ~1.2x on these micro-sims (revm is allocation-bound per call, so it contends rather than scaling with cores) — so I do not headline a core-count multiplier either. Rewrites the README "Performance" section to say all this plainly (a candor table showing ~1x where it is ~1x) and lead with what genuinely holds: cross-block freshness (0 RPC fetches/block — foundry-fork-db's cache is not block-keyed), point-in-time consistency, modest-but-real parallel fan-out, and the act-then-validate control plane. benches/fanout.rs now measures sequential vs parallel (the honest comparison), not deep-clone-per-candidate. The two charts that visualized the inflated numbers are removed; a new cross_block_freshness.svg visualizes the one defensible quantitative win. The fetch example prints the shared-backend caveat. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 ++-- README.md | 147 +++++++++++-------------- assets/cross_block_freshness.svg | 62 +++++++++++ assets/fanout_throughput.svg | 63 ----------- assets/fetch_amplification.svg | 62 ----------- benches/fanout.rs | 138 +++++++++++++---------- examples/fetch_minimization_counted.rs | 13 ++- 7 files changed, 227 insertions(+), 279 deletions(-) create mode 100644 assets/cross_block_freshness.svg delete mode 100644 assets/fanout_throughput.svg delete mode 100644 assets/fetch_amplification.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a07821..36d415e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,14 +42,19 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). ### Added -- **Value-prop benchmarks & showcase.** The README "Performance" section now - leads with the searcher-facing outcomes vs a fork-per-candidate baseline: - data-fetch minimization (an exact, CI-pinned `~500x`-fewer-RPC-reads count via - the new `fetch_minimization_counted` example and `tests/fetch_minimization.rs`), - candidate-fan-out throughput (the new `benches/fanout.rs`), reactive event sync - (zero fetches per block, pinned in `tests/event_pipeline.rs`), and optimistic - latency-hiding (`benches/freshness.rs`). The internal copy-on-write snapshot - cost model moved to [`docs/INTERNALS.md`](docs/INTERNALS.md). +- **Honest performance section & benchmarks.** The README "Performance" section + is framed against a *competent* baseline (a shared `foundry-fork-db` + `SharedBackend` + `checkpoint`/`revert` isolation), not a naive + fork-per-candidate strawman. It states plainly that within-block fetch count, + single-threaded CPU, and time-to-result are ~1× against that baseline, and + leads with the genuinely-unique wins: cross-block freshness (0 RPC fetches/block, + pinned in `tests/event_pipeline.rs` — `foundry-fork-db`'s cache is not + block-keyed), parallel `Send` fan-out (`benches/fanout.rs`, a modest measured + ~1.2× on micro-sims), point-in-time consistency, and the act-then-validate + control plane (`benches/freshness.rs`). Adds `examples/fetch_minimization_counted.rs` + and `tests/fetch_minimization.rs` (the fetch-once mechanic, with the + shared-backend caveat stated). The internal copy-on-write snapshot cost model + moved to [`docs/INTERNALS.md`](docs/INTERNALS.md). - **Forked EVM cache** (`cache::EvmCache`) backed by `foundry-fork-db` with lazy RPC loading and on-disk persistence for accounts, storage, bytecode, and immutable metadata. diff --git a/README.md b/README.md index 9945abc..b2fa09a 100644 --- a/README.md +++ b/README.md @@ -247,95 +247,76 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address); # } ``` -## Performance - -A searcher evaluates many candidate transactions against the *same* recent block. -The naive build — a fresh revm fork/cache per candidate over an RPC node — pays -twice per candidate: it **re-fetches** the same hot state *and* **re-builds** an -independent fork to isolate the candidate, then **blocks on RPC** to validate. -`evm-fork-cache` fetches each slot once, snapshots once, clones cheaply per -candidate, and lets you act before validation returns. The results below isolate -each of those costs against the naive loop — the **fetch** win (①) and the -per-candidate **isolation/CPU** win (②) are *separate and not double-counted*. - -> **Fair baseline.** "Vanilla" / "fork-per-candidate" = the loop a searcher writes -> without this crate. ① measures the **fetch** cost: a fresh cold cache per -> candidate that re-fetches the slots it touches. ② measures the **isolation/CPU** -> cost: a full independent fork (a deep clone of the warm, in-memory state) per -> candidate — it does *no* RPC, so ②'s speedup is snapshot-construction cost, not -> network. Each result names how it is measured. - -**① Data fetched — the headline (exact, deterministic integer).** -Evaluating **500 candidates** that share an 8-slot hot working set, the crate -fetches each slot **once** and every candidate reads the frozen snapshot from -memory; the fan-out adds **zero** fetches: - -| | Slots fetched from RPC | -|---|---| -| **evm-fork-cache** (fetch once, fan out 500) | **8** | -| Vanilla (fresh fork per candidate) | **4,000** (500 × 8) | - -→ **~500× fewer RPC reads** (3,992 avoided). The ratio is `N` for a shared working -set; for fully *disjoint* per-candidate reads it is 1× (no win — stated honestly). -This count is machine-independent and CI-pinned. See -[`fetch_minimization_counted`](examples/fetch_minimization_counted.rs). +## Performance & honest trade-offs -

- RPC slot fetches: evm-fork-cache stays flat at 8 while fork-per-candidate grows linearly to 4,000 over 500 candidates -

+It is easy to post huge multipliers against a *naive* loop (a fresh cold fork per +candidate that re-fetches everything and deep-clones to isolate). That is **not** +the loop a competent revm user writes. Measured against a **competent baseline** — +one shared [`foundry-fork-db`] `SharedBackend` (which caches and deduplicates +fetches) plus `checkpoint`/`revert` isolation on a single fork — this crate is +**roughly at parity on raw within-block speed**, and we say so plainly: -**② Candidate throughput (wall-clock ratio — CPU/isolation cost only).** -One `create_snapshot()` amortized across N cheap `EvmOverlay` clones vs a full -independent fork (a deep clone) per candidate. **Both sides run warm with no RPC**, -so this isolates the *snapshot-once-and-share vs full-clone-per-candidate* cost — -it is **not** the fetch win in ① (which is counted separately). Over a fork -holding ~32k cold slots, per-candidate cost *falls* as N grows while -fork-per-candidate stays flat: - -| Candidates (N) | evm-fork-cache (per candidate) | Fork-per-candidate | Speedup | -|:---:|:---:|:---:|:---:| -| 1 | 41 µs | 2.1 ms | ~51× | -| 8 | 8.3 µs | 2.2 ms | ~260× | -| 32 | 4.9 µs | 2.2 ms | ~450× | -| 128 | 4.0 µs | 2.2 ms | **~545×** | - -→ ~250k candidates/sec vs ~460/sec at N=128. Overlays are `Send` and parallelize; -the live fork is not. (At N=1 there is little win — one snapshot is not yet -amortized.) See [`parallel_overlays`](examples/parallel_overlays.rs). +| Axis | vs a competent shared-backend / checkpoint-revert loop | +|---|---| +| RPC reads **within one block** | **~1×** — a shared `SharedBackend` also fetches each hot slot once | +| Single-threaded per-candidate CPU | **~1×** — `checkpoint`/`revert` isolation is as cheap as an overlay | +| Time-to-result vs *blocking* validation | not a fair comparison — a competent loop doesn't block on a fetch before acting | + +The value of this crate is **not** a within-block speed multiplier. It is +correctness, cross-block freshness, and a structured control plane the bare +primitives don't give you: + +**① Cross-block freshness — the one quantitative win (exact, CI-pinned integer).** +`foundry-fork-db`'s cache is **not block-keyed**: re-pinning to a new block does +not invalidate cached slots, so a refresh-by-refetch loop must re-read every slot +that changed *each block* to stay correct. Decoding the block's logs into targeted +writes keeps that hot state correct with **0 RPC fetches/block** — pinned in +[`tests/event_pipeline.rs`](tests/event_pipeline.rs). Sampled `reconcile()` +re-reads a fraction to catch drift (the honesty backstop). See +[`reactive_cache`](examples/reactive_cache.rs).

- Per-candidate cost on a log scale: evm-fork-cache falls from 41 to 4 microseconds as N grows while fork-per-candidate stays flat near 2.1 milliseconds + Cumulative RPC slot reads over 8 blocks: refresh-by-refetch climbs linearly to 64 while event-driven writes stay flat at 8 (warmed once, then 0 per block)

-**③ Reactive sync — staying fresh without polling (exact integer).** -Decoding a block's ERC-20 `Transfer` logs into targeted writes keeps the touched -balance slots correct with **0 RPC fetches/block**, where a poller would re-fetch -every changed slot. Sampled `reconcile()` re-reads a fraction to catch drift (the -honesty backstop). Holds only for slots the event stream covers. See -[`reactive_cache`](examples/reactive_cache.rs). - -**④ Optimistic execution — hiding validation latency (structural).** -Time-to-actionable-result is gated on local simulation, not RPC validation. With a -*modeled* 50 ms RPC round-trip, the optimistic result returns in **~14 µs** while -validation runs in the background (~53 ms); the naive fetch-then-act path pays -~54 ms before it can act. Only sims whose read set changed re-run (`rerun_count`). -The win is latency *hiding*, not elimination. See +> Honest caveat: an equally sophisticated peer running their *own* log +> subscription + delta applier also reaches 0 fetches/block. The crate's +> contribution is the packaged, cold-aware, reorg-safe, reconcilable vocabulary — +> not an unreachable number. + +**② Parallel fan-out — available, modest, workload-dependent.** +`create_snapshot()` is an immutable `Send + Sync` view; cloning the `Arc` hands +each thread its own overlay, so candidates fan out across cores — which a single +mutable fork cannot do. The measured speedup is honest and modest: **~1.2×** over a +4,096-candidate batch on a 10-core M1 Pro, because these micro-sims are bound by +per-candidate allocation, not EVM compute. Heavier candidates (real txs doing +substantial execution) parallelize better; trivial ones barely. We don't headline a +core-count multiplier we can't reproduce. `cargo bench --bench fanout`; +[`parallel_overlays`](examples/parallel_overlays.rs). + +**③ Point-in-time consistency.** Every overlay reads one frozen, consistent block +state. A lazily-filled shared backend can interleave reads taken at slightly +different moments unless carefully pinned; the snapshot removes that class of bug. + +**④ Act-then-validate control plane (structure, not speed).** Run optimistically +against current state, return immediately, and validate the volatile read-set in +the background — re-running *only* the sims whose slots changed (`rerun_count`), +with `Confirmed`/`Corrected`/`Unverified` verdicts and block-pinned validation. A +searcher who simply acts on warm state is equally fast; the value is the **safe, +selective re-run**, not a latency multiplier. See [`freshness_optimistic`](examples/freshness_optimistic.rs). > [!NOTE] -> **Methodology.** All numbers are offline (mocked provider, state injected -> directly — no network). **Exact integer metrics** (slots fetched, fetches -> avoided/block) are deterministic and machine-independent; they are pinned by -> tests. **Wall-clock ratios** (throughput, latency) are Criterion medians on an -> Apple M1 Pro (`aarch64-apple-darwin`) — read the ratio, not the absolute. The -> 50 ms RPC latency in ④ is a modeled/injected delay, not a real network -> measurement. Live-RPC checks live behind the `RPC_URL` gate. -> -> Reproduce: `cargo run --example fetch_minimization_counted` (the deterministic -> headline), `cargo bench --bench fanout` (②), `cargo bench --bench freshness` -> (④). +> **Methodology & candor.** Offline (mocked provider, state injected — no +> network). We deliberately do **not** lead with the headline multipliers a naive +> baseline would produce (~500× fewer reads, ~545× throughput, ~3,800× latency): +> all three collapse toward ~1× against a competent `SharedBackend` + +> `checkpoint`/`revert` loop — which is the very primitive this crate wraps. The +> "0 fetches/block" integer is real and CI-pinned (`cargo test --test +> event_pipeline`); the parallel-fan-out ratio is a Criterion median on an Apple +> M1 Pro, read as a ratio not an absolute. Live-RPC checks live behind the +> `RPC_URL` gate. ## Benchmarks @@ -344,9 +325,9 @@ provider) so they are reproducible: | Bench | Measures | | --- | --- | -| `fanout` | **Pillar 1.** Amortized per-candidate throughput: `create_snapshot` + N overlays vs a full independent fork per candidate. | -| `freshness` | **Pillar 4.** The optimistic loop end-to-end (CPU and latency-hiding vs fetch-then-sim), `verify_slots` at scale (1 → 1000 slots), and multi-sim fan-out. | -| `event_pipeline` | **Pillar 3.** Per-decoder cost (ERC-20 `Transfer`), `ingest_logs` decode+apply throughput (1 → 1000 logs), and `reorg_to` purge cost. | +| `fanout` | **Parallel fan-out (②).** N candidates **sequential vs across cores** over one shared snapshot — the parallelism a live mutable fork can't do. | +| `freshness` | **Act-then-validate (④).** The optimistic loop CPU cost, selective re-run, and the latency-hiding shape (vs a baseline that elects to block). `verify_slots` at scale; multi-sim fan-out. | +| `event_pipeline` | **Cross-block freshness (①).** `ingest_logs` decode+apply throughput (1 → 1000 logs), `reorg_to` purge; the 0-fetch/block property is pinned in `tests/event_pipeline.rs`. | | `state_update` | `apply_updates` throughput across batch sizes (1 → 1000 `Slot`s) and per-variant apply cost (`Slot` vs `Account` vs `Purge`). | | `simulation` | Hot-path micro-benches and snapshot-implementation regression guards (`create_snapshot` vs the deep-clone reference — an internal cost model, see [`docs/INTERNALS.md`](docs/INTERNALS.md)). | | `access_list` | Touch-set merge and EIP-2930 list construction. | diff --git a/assets/cross_block_freshness.svg b/assets/cross_block_freshness.svg new file mode 100644 index 0000000..24fcef0 --- /dev/null +++ b/assets/cross_block_freshness.svg @@ -0,0 +1,62 @@ + + Cumulative RPC slot reads as blocks advance + When a hot set of ~8 slots mutates each block, a refresh-by-refetch loop re-reads them every block (cumulative reads climb linearly), while event-driven writes keep state fresh with zero re-fetches after the first block (flat at 8). + + + + Cross-block freshness — cumulative RPC slot reads + a ~8-slot hot set mutates each block; foundry-fork-db's cache is not block-keyed, so staying correct means re-reading + + + + + + + + + + + + 0 + 16 + 32 + 48 + 64 + + cumulative RPC slot reads + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + + block + + + + + 64 + + + + + + 8 + + + + refresh by re-fetch = ~8 reads / block + + event-driven writes = 0 reads / block (warm once) + + + 0 re-fetches / block + decode the block's logs → targeted writes + + Within ONE block a shared backend also fetches once (~1×); this is the cross-block win. A peer subscribing to logs themselves also reaches 0. + diff --git a/assets/fanout_throughput.svg b/assets/fanout_throughput.svg deleted file mode 100644 index 3381f20..0000000 --- a/assets/fanout_throughput.svg +++ /dev/null @@ -1,63 +0,0 @@ - - Per-candidate simulation cost vs fan-out size - On a log scale, evm-fork-cache per-candidate cost falls from 41 to 4 microseconds as N grows while fork-per-candidate stays flat near 2.1 milliseconds — about 545x faster at N=128. - - - - Candidate throughput — amortized per-candidate cost - one snapshot shared across N overlays vs a full independent fork per candidate (log scale) - - - - - - - - - - - - - - 1µs - 10µs - 100µs - 1ms - 10ms - - cost per candidate - - - - 1 - 8 - 32 - 128 - - fan-out size (N candidates) - - - - - ≈ 2.1 ms - - - - - 41µs - 4.0 µs - - - - ~545× - - - - - evm-fork-cache (snapshot once, fan out) - - fork-per-candidate - - - Warm, in-memory — CPU/isolation cost, not the fetch win. Criterion medians, Apple M1 Pro; read the ratio, not the absolute. - diff --git a/assets/fetch_amplification.svg b/assets/fetch_amplification.svg deleted file mode 100644 index f8bc570..0000000 --- a/assets/fetch_amplification.svg +++ /dev/null @@ -1,62 +0,0 @@ - - RPC slot fetches: evm-fork-cache vs fork-per-candidate - Evaluating 500 candidates over a shared 8-slot hot set, fork-per-candidate fetches grow linearly to 4,000 while evm-fork-cache stays flat at 8 — about 500x fewer RPC reads. - - - - Data fetched — 500 candidates, shared hot set - fork-per-candidate re-fetches the working set every candidate; evm-fork-cache fetches it once - - - - - - - - - - - - - - 0 - 1k - 2k - 3k - 4k - - slots fetched from RPC - - - - 0 - 100 - 250 - 500 - - candidate transactions evaluated (N) - - - - - 4,000 - - - - - 8 - - - - - fork-per-candidate = N × 8 - - evm-fork-cache = 8 (fetched once) - - - - ≈ 500× fewer - RPC reads (3,992 slots avoided) - - Exact, machine-independent count (CI-pinned). Shared hot set; fully disjoint reads → 1× (no win). - diff --git a/benches/fanout.rs b/benches/fanout.rs index f4c82dc..a99acad 100644 --- a/benches/fanout.rs +++ b/benches/fanout.rs @@ -1,29 +1,36 @@ //! **Pillar 1 — parallel fan-out throughput.** //! -//! Once recent chain state is snapshotted, the per-candidate cost of an isolated -//! simulation is a cheap `Arc`-clone overlay, not a fresh fork. This bench -//! contrasts the two loops a searcher can write to evaluate N candidates with -//! full per-candidate isolation, over a cache that holds realistic cold state: +//! The honest, defensible win of the snapshot/overlay model is **parallelism**. +//! A live mutable fork (one `revm` EVM isolated with `checkpoint`/`revert`) can +//! only evaluate candidates *sequentially* — it cannot be shared mutably across +//! threads. `create_snapshot()` produces an immutable `Send + Sync` view; cloning +//! the `Arc` hands each worker thread its own cheap `EvmOverlay`, so the same N +//! candidates fan out across cores. //! -//! - **`snapshot_once`** (the crate): `create_snapshot()` once, then N cheap -//! `EvmOverlay` clones. The snapshot's cost is amortized across all N. -//! - **`fork_per_candidate`** (vanilla): a full independent fork per candidate — -//! modeled by `create_snapshot_deep_clone()` (the O(total state) flatten a -//! searcher pays to isolate each candidate without structural sharing) — then -//! the simulation. +//! This bench therefore compares like-for-like execution, sequential vs parallel, +//! over the SAME warm in-memory snapshot (no RPC on either side): //! -//! `Throughput::Elements(N)` makes Criterion report the **amortized per-candidate** -//! cost. As N grows the crate's per-candidate cost falls toward the overlay-clone -//! floor while fork-per-candidate stays flat at the full-clone cost; the gap -//! widens with the amount of state the fork holds. Wall-clock and -//! machine-dependent — read the ratio, not the absolute. (The integer fetch-count -//! win is in `examples/fetch_minimization_counted.rs`; this is the CPU side.) +//! - **`sequential`** — N candidates one after another on overlays from one +//! snapshot. This is also where a competent single-threaded baseline lands: a +//! single fork isolated with `checkpoint`/`revert` per candidate is O(touched) +//! and runs in the same per-candidate range, so single-threaded the snapshot +//! model is **~1×** — not a throughput win. We do not claim one. +//! - **`parallel`** — the same N candidates split across +//! `available_parallelism()` worker threads, each driving its own overlays from +//! an `Arc::clone` of the shared snapshot. //! -//! At N=1 there is little or no win: one snapshot is not yet amortized. That is -//! honest — the fan-out economics only pay off across many candidates. +//! The win is the `parallel/sequential` ratio. Honest result: it is **modest** +//! (~1.2× on a 10-core M1 Pro for these workloads), because even at 48 ops per +//! candidate the cost is dominated by per-call revm allocation, which contends on +//! the allocator rather than scaling with cores. Heavier, compute-bound candidates +//! parallelize better; we report what we measure and do not claim a core-count +//! multiplier. Wall-clock and machine-dependent — read the ratio. The internal +//! copy-on-write snapshot-construction cost model (COW vs deep clone) lives in +//! `benches/simulation.rs` / `docs/INTERNALS.md`, not here. use std::hint::black_box; use std::sync::Arc; +use std::thread; use alloy_primitives::{Address, Bytes, U256, hex}; use alloy_provider::RootProvider; @@ -46,32 +53,11 @@ sol! { } } -fn addr(i: usize) -> Address { - let mut bytes = [0u8; 20]; - bytes[12..20].copy_from_slice(&(i as u64 + 1).to_be_bytes()); - Address::from(bytes) -} - -/// A warm cache holding `accounts x slots` of cold chain state (so a per-candidate -/// deep clone is realistically expensive) plus a MockERC20 with one funded owner. -fn warm_cache(rt: &Runtime, accounts: usize, slots: usize) -> (EvmCache, Address, Address, Bytes) { +/// A warm cache holding a MockERC20 with one funded owner (state in memory, no RPC). +fn warm_cache(rt: &Runtime) -> (EvmCache, Address, Address, Bytes) { let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); let mut cache = rt.block_on(EvmCache::new(Arc::new(provider))); - // Bulk cold state the fork is holding this block. - let mut batch: Vec<(Address, U256, U256)> = Vec::with_capacity(accounts * slots); - for a in 0..accounts { - for s in 0..slots { - batch.push(( - addr(a), - U256::from(s as u64), - U256::from((a * 31 + s) as u64), - )); - } - } - cache.inject_storage_batch(&batch); - - // The contract the candidates actually exercise. let token = Address::repeat_byte(0xAA); let owner = Address::repeat_byte(0xBB); let runtime = Bytecode::new_raw(Bytes::from( @@ -98,38 +84,72 @@ fn warm_cache(rt: &Runtime, accounts: usize, slots: usize) -> (EvmCache, Address fn bench_candidate_fanout(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - // A fork holding ~32k cold slots (2,000 accounts x 16 slots) — the deep clone - // a fork-per-candidate loop pays scales with this; the snapshot shares it. - let (mut cache, token, owner, calldata) = warm_cache(&rt, 2_000, 16); + let (mut cache, token, owner, calldata) = warm_cache(&rt); black_box(cache.create_snapshot()); // warm the memoized base once + let workers = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(8); + + // Each candidate runs a non-trivial simulation (many reads), modeling a real + // candidate tx that touches dozens of slots. Even so, the per-candidate cost + // stays largely allocation-bound (revm allocates per call), so the measured + // parallel speedup is modest (~1.2×) rather than core-count — reported + // honestly, not tuned until it looks good. + const OPS_PER_CANDIDATE: usize = 48; + let run_candidate = |overlay: &mut EvmOverlay| { + for _ in 0..OPS_PER_CANDIDATE { + let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!(result, ExecutionResult::Success { .. })); + black_box(result); + } + }; + let mut group = c.benchmark_group("candidate_fanout"); - for &n in &[1usize, 8, 32, 128] { + for &n in &[64usize, 256, 1_024] { group.throughput(Throughput::Elements(n as u64)); - // Crate: one snapshot amortized across N cheap overlay clones. - group.bench_with_input(BenchmarkId::new("snapshot_once", n), &n, |b, &n| { + // Single-threaded: N candidates one after another. The competent + // single-thread baseline (one fork, checkpoint/revert per candidate) lands + // here too — single-threaded the snapshot model is ~1×, not a win. + group.bench_with_input(BenchmarkId::new("sequential", n), &n, |b, &n| { b.iter(|| { let snapshot = cache.create_snapshot(); for _ in 0..n { let mut overlay = EvmOverlay::new(snapshot.clone(), None); - let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); - debug_assert!(matches!(result, ExecutionResult::Success { .. })); - black_box(result); + run_candidate(&mut overlay); } }) }); - // Vanilla: a full independent fork (deep clone) per candidate. - group.bench_with_input(BenchmarkId::new("fork_per_candidate", n), &n, |b, &n| { + // Parallel: the same N candidates across `workers` threads, each driving + // its own overlays from an Arc::clone of the shared immutable snapshot — + // which a single mutable fork cannot do. + group.bench_with_input(BenchmarkId::new("parallel", n), &n, |b, &n| { b.iter(|| { - for _ in 0..n { - let snapshot = cache.create_snapshot_deep_clone(); - let mut overlay = EvmOverlay::new(snapshot, None); - let result = overlay.call_raw(owner, token, calldata.clone()).unwrap(); - debug_assert!(matches!(result, ExecutionResult::Success { .. })); - black_box(result); - } + let snapshot = cache.create_snapshot(); + let per = n.div_ceil(workers); + thread::scope(|s| { + for start in (0..n).step_by(per) { + let end = (start + per).min(n); + let snapshot = snapshot.clone(); + let calldata = calldata.clone(); + s.spawn(move || { + for _ in start..end { + let mut overlay = EvmOverlay::new(snapshot.clone(), None); + for _ in 0..OPS_PER_CANDIDATE { + let result = + overlay.call_raw(owner, token, calldata.clone()).unwrap(); + debug_assert!(matches!( + result, + ExecutionResult::Success { .. } + )); + black_box(result); + } + } + }); + } + }); }) }); } diff --git a/examples/fetch_minimization_counted.rs b/examples/fetch_minimization_counted.rs index 96b1881..e160970 100644 --- a/examples/fetch_minimization_counted.rs +++ b/examples/fetch_minimization_counted.rs @@ -143,14 +143,19 @@ async fn main() -> Result<()> { " TOTAL slots fetched: {vanilla_total} ({N_CANDIDATES} x {per_candidate_fetches})" ); println!( - "\n=> {}x fewer RPC reads ({} slots avoided)", + "\n=> {}x fewer reads than a NAIVE un-shared loop ({} slots avoided)", vanilla_total / crate_total.max(1), vanilla_total - crate_total ); println!( - "\nNote: this is the shared-hot-set case. If every candidate read a DISJOINT\n\ - slot set, the union the crate fetches would equal the vanilla total and there\n\ - would be no fetch saving — the win comes from reuse across overlapping reads." + "\nHonest caveat: this beats only the *naive* baseline that builds a fresh cold\n\ + cache per candidate. A competent searcher sharing ONE foundry-fork-db\n\ + SharedBackend across candidates ALSO fetches each slot once within a block\n\ + (its cache dedups) — so within a single block this is ~1x, not {}x.\n\ + The durable win is CROSS-block: as blocks advance and the hot set mutates,\n\ + event-driven writes (see the reactive_cache example) keep it fresh with 0\n\ + re-fetches, where a refetch loop must re-read the changed slots every block.", + vanilla_total / crate_total.max(1), ); assert_eq!(