Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,33 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).
many simulations against the same snapshot without reallocating: it clears the
per-simulation dirty layer (keeping the snapshot `Arc`, `ext_db`, and the
reusable shared-memory buffer), reading the pristine snapshot again and behaving
exactly like a freshly-built overlay. The 64 KB shared-memory buffer is also
exactly like a freshly-built overlay. The 64 KiB shared-memory buffer is also
recycled across the build→transact→revert call methods (stored as a plain
`Vec<u8>`, so the overlay stays `Send`).
- **Configurable EVM shared-memory pre-allocation** — `SharedMemoryCapacity`
(`Fixed(usize)` / `Auto`, default `Fixed(64_000)`) set via
(`Fixed(usize)` / `Auto`, default `Fixed(64 * 1024)` / 65,536 bytes) set via
`EvmCacheBuilder::shared_memory_capacity`. `Fixed` pins the per-context working-
memory buffer (general users running wide fan-outs of small simulations can lower
it to cut per-overlay memory; the previous behavior is the default); `Auto` sizes
it from the chain state loaded at build time (e.g. a bincode state file), clamped
to a 64 kB floor / 4 MiB ceiling. The resolved size is readable via
to a 64 KiB floor / 4 MiB ceiling. The resolved size is readable via
`EvmCache::shared_memory_capacity()` and is propagated to every snapshot so
snapshot-backed overlays pre-allocate the same amount. `with_cache_capacity` is
the lower-level constructor behind the builder setter.
- **Explicit cold-account materialization** — `StateUpdate::AccountUpsert` and
`StateUpdate::account_upsert(...)` intentionally materialize an account absent
from both layers. Normal `StateUpdate::Account` patches are now cold-aware and
surface skipped cold patches through `StateDiff.skipped_accounts:
Vec<SkippedAccountPatch>`.
- **Invalidating layer-2 mutation wrapper** — `EvmCache::with_blockchain_db_mut`
runs a synchronous direct `BlockchainDb` mutation and invalidates the Phase 5
memoized COW base automatically after the closure returns.
- **Exact access-list RLP data-gas helper** —
`access_list::access_list_rlp_data_gas(&AccessList)` returns the EIP-2930 RLP
calldata gas for an access list and backs the L2 profitability calculation.
- **Versioned on-disk cache envelope** — binary EVM state, bytecode,
`ImmutableDataCache`, and V3 tick snapshot cache files now start with
crate-specific magic bytes plus a `u32` version before the bincode payload.

### Changed

Expand All @@ -183,7 +197,25 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).
layer-2 bulk write now marks the touched addresses dirty for the memoized
copy-on-write base. The write itself is still a direct backend (layer-2) write
with the same semantics; only the receiver mutability changed.

- **Raw layer-2 handles were renamed to unchecked accessors** (Phase 5) —
`EvmCache::blockchain_db()` is now `unchecked_blockchain_db()` and
`EvmCache::backend()` is now `unchecked_backend()`. The rename makes the
bypass explicit; use `with_blockchain_db_mut` for synchronous direct writes that
should automatically invalidate the snapshot base.
- **Persistence APIs now return `Result<()>`** — `cache::save_binary_state`,
`PrefetchRegistry::save`, and `EvmCache::flush` report serialization,
directory-creation, and write failures to explicit callers. `Drop` remains
best-effort and logs `flush()` errors.
- **Block re-pins clear stale context** — `set_block` sets `block_number` only
for concrete numeric pins, clears it for tag/hash/`None` pins, and clears stale
`basefee` on block changes and on non-concrete pin calls that can drift under
the same tag. `repin_to_block` follows the same no-stale-basefee rule; callers
refresh `NUMBER`/`BASEFEE` via `set_block_context` after fetching the new
header.
- **Legacy raw-bincode cache files are treated as misses** — the versioned cache
envelope intentionally rejects unversioned `evm_state.bin`, `bytecodes.bin`,
`immutable_data.bin`, and `v3_tick_snapshots.bin` payloads rather than trying
to deserialize ambiguous layouts.
- Simulation entry points that distinguish failure modes return
`SimulationResult<T>` (`Result<T, SimError>`), separating decoded reverts,
EVM halts, and host errors. `SimulationErrorKind` remains as a deprecated alias.
Expand All @@ -199,6 +231,19 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).

### Fixed

- **Cold absolute account patches no longer mask on-chain accounts.**
`StateUpdate::Account` on an account absent from both layers now skips instead
of writing `AccountInfo::default()` fields through the shared backend. The
skipped patch is visible in `StateDiff.skipped_accounts`; intentional cold
creation uses `StateUpdate::AccountUpsert`.
- **Access-list profitability no longer conflates provider failures with
unprofitable lists.** `SmartAccessList::into_access_list_if_profitable` and
`access_list_if_profitable` now propagate provider/pricing failures as `Err`
and reserve `Ok(None)` for empty, zero-priced, or genuinely unprofitable lists.
- **`simulate_call_with_balance_deltas` now reports a real access list.** It
extracts the EIP-2930 touched account/slot list from the EVM journal before
commit/revert, including the pre/post `balanceOf` reads and the simulated call,
instead of returning `AccessList::default()`.
- **`cached_storage_value` silent-corruption bug** (Phase 3 §16.0, audit HIGH +
MED). For a storage slot absent from an overlay account whose revm
`account_state` is `StorageCleared` or `NotExisting`, the accessor now returns
Expand All @@ -214,7 +259,9 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).
**skips both layer writes** (returning an empty diff) when no field actually
changes, instead of unconditionally inserting `AccountInfo::default()` into the
shared backend for an all-`None` (or value-unchanged) patch on an absent address.
A real field change still materializes the backend account (unchanged intent).
Phase 5 later tightened this further: real field changes on cold accounts now
skip through `StateDiff.skipped_accounts` unless the caller uses
`StateUpdate::AccountUpsert`.
- **`account_state`-awareness extended to the snapshot + account-info paths**
(Phase 3 fix-review, HIGH + MED). A follow-up adversarial review found the §16.0
`cached_storage_value` fix had not been propagated to two sibling read paths:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ alloy-eips = "1.0.38"
alloy-network = "1.0.38"
alloy-primitives = { version = "1.4", features = ["map"] }
alloy-provider = "1.0.38"
alloy-rlp = "0.3"
alloy-rpc-client = "1.0.38"
alloy-rpc-types-eth = "1.0.38"
alloy-sol-types = "1.4"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,10 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address);

## Benchmarks

Criterion benchmarks live in [`benches/`](benches). The offline benches are the
baseline against which the planned copy-on-write snapshot rewrite (roadmap
Pillar A) will be measured, so they exercise the real hot paths at a range of
cache sizes:
Criterion benchmarks live in [`benches/`](benches). The offline benches exercise
the current hot paths at a range of cache sizes, including the Phase 5
copy-on-write snapshot implementation and retained deep-clone baselines where
useful for A/B comparison:

| Bench | Measures |
| --- | --- |
Expand Down
21 changes: 12 additions & 9 deletions benches/simulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
//! via `inject_storage_batch` (`populated_cache_layer2`), the way a fork cache
//! actually holds it. For each size it benches both the COW `create_snapshot`
//! and the retained `create_snapshot_deep_clone`. The deep clone is an O(total
//! state) copy, so its cost slopes up with the index size; the COW path folds
//! only the (empty) hot layer over an `Arc`-shared memoized base, so after the
//! base is warm it should stay roughly **flat** across sizes.
//! state) copy, so its cost slopes up with the index size; the COW path shares
//! the memoized base and avoids cloning total storage slots. It still scans
//! accounts and new layer-1 entries, so it should be much flatter than the deep
//! clone, especially as slots/account grows, but not strictly flat by account
//! count.
//! - **`resnapshot_hot_loop`.** Warms the base with one snapshot, applies a small
//! `apply_updates` layer-1 mutation, then measures `create_snapshot`. This is
//! the memoization win: ≈ O(changed) and flat across cold-index size, vs. the
//! deep clone's slope.
//! the memoization win: the COW path avoids cloning cold storage slots but
//! remains sensitive to account scans and new layer-1 entries.
//! - **`overlay_fanout`.** Measures fanning one frozen snapshot out into many
//! isolated simulations, comparing a fresh `EvmOverlay::new` per sim against a
//! single `reset()`-recycled overlay (Pillar A.2).
Expand Down Expand Up @@ -81,9 +83,9 @@ fn populated_cache_layer2(rt: &Runtime, accounts: usize, slots_per: usize) -> Ev
/// A/B snapshot creation across cold-index sizes: the COW `create_snapshot` vs.
/// the retained `create_snapshot_deep_clone`, both over a layer-2-seeded index.
///
/// The deep clone slopes up with the index; the COW path, after a warm-up
/// snapshot has memoized the base, should stay roughly flat (the hot layer is
/// empty, so it is an `Arc` handle copy plus the O(accounts) growth scan).
/// The deep clone slopes up with total slots; the COW path, after a warm-up
/// snapshot has memoized the base, avoids cloning those slots but still pays the
/// O(accounts) growth scan.
fn bench_create_snapshot(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let mut group = c.benchmark_group("create_snapshot");
Expand Down Expand Up @@ -115,7 +117,8 @@ fn bench_create_snapshot(c: &mut Criterion) {

/// The memoization win: a hot re-snapshot loop. Warm the base once, apply a
/// *small* layer-1 mutation, then measure `create_snapshot`. Cost should track
/// the changed state (≈ flat across cold-index size), unlike the deep clone.
/// account scanning plus new layer-1 entries, staying much flatter than the deep
/// clone as cold storage grows.
fn bench_resnapshot_hot_loop(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let mut group = c.benchmark_group("resnapshot_hot_loop");
Expand Down
Loading
Loading