Repository navigation
evm-fork-cache 0.2.0
[0.2.0] - 2026-07-05
Closes the top publication-review gaps (account-level freshness, strict
block-context, bounded derived_slots, conservative deep-reorg behavior) and
lands the full Phase-8 liveness plan (spec steps 1–6). Breaking changes are
grouped under Changed; they are permitted pre-1.0 (see the policy above).
Performance
The round's RPC-economics wins (each documented with numbers and a reproduction
path; see the README "Performance & honest trade-offs" section for the full,
hedged picture):
- Bulk
eth_callstorage extraction is now the default loader. One
state-override call reads thousands of slots at a flat ~26 CU instead of one
point read each — e.g. 10,000 slots in 1 call vs 200,000 CU, and a full
Uniswap V3 tick range (7,674 slots) in 2 calls / 52 CU (~2,952× cheaper),
with automatic point-read fallback. Methodology + tables:
docs/bulk-storage-extraction.md;
reproduce withRPC_URL=… cargo run --release --example bulk_storage_bench. - Verified code-seed cold starts. Materializing a known contract set's code
and balances is one bulk call settled against on-chainEXTCODEHASHrather
than three reads per account: ~46× cheaper, ~25.6× faster for 20 contracts
(48 ms vs ~1.2 s, 26 CU vs 1,200). Same doc + bench (scenario 11). - Proof economics.
eth_getProof(no bulk substitute) is kept off the
per-block path: root-gate probes fire on a cadence (default every 16 blocks →
16× fewer probes by construction) and batch every gated account into one
bounded fan-out (≈4.7–7.3× over serial across runs, live-measured for a
50-account sweep — bounded by the concurrency cap, larger when per-proof
latency is higher; reproduce withE2E_RPC_URL=… cargo test --test liveness_root_gate -- --ignored).
Added
- Mid-lifecycle adapter register/unregister (
ReactiveEngine). A new
ReactiveEnginebinds aReactiveRuntimeto anEventSubscriberand drives
handler lifecycle as one operation, for consumers that add and drop adapters
while the cache runs (e.g. register an AMM on aPoolCreatedevent, drop it
later).register_handlerupdates runtime routing and subscriber interests
together and, once ingestion has journaled a canonical block, backfills the
new handler from that block automatically — closing the discovery→subscription
gap with no caller bookkeeping (register_handler_with_backfillfor deeper
history,register_handler_live_onlyto opt out;sync_handler_interests
bootstraps a pre-populated runtime).unregister_handlerremoves routing and
transport for that handler only; the runtime addslast_canonical_block(),
pending_resyncs(),cancel_pending_resyncs(address),handler_ids(),
contains_handler,handler_interests, andunregister_handlerfor the full
teardown recipe (unregister +untrack_account+cancel_pending_resyncs;
cache eviction stays explicit). Under the hood the new
InterestOwnerSubscribertrait lets a subscriber add/remove interests keyed by
a stable per-adapterHandlerIdwithout disturbing unrelated owners; growing
an owner's filter set carries its delivery anchor across the change and
self-heals the gap, identical filters across owners share one subscription, and
retired filters release their bookkeeping.SubscriberBackfilldescribes the
anchor for owner-scopedget_logsbackfill (ReactiveEngineError/
ReactiveEngineRegisterErrorare the engine's typed errors). All existing
runtime/subscriber APIs are unchanged;register_interestsremains the
full-replacement setup path. - Queryable cache health + metrics.
ReactiveRuntime::health() -> CacheHealth
(Healthy/Degraded/Unhealthy) andmetrics() -> CacheMetricsSnapshot
(atomic counters:deep_reorgs,reorgs_recovered,resync_requests/_failures,
missed_ranges,coverage_gaps,pending_contamination).ReactiveReport::Health
transition reports and a caller-invokedreset_health(). - Conservative deep-reorg self-heal + missed-range detection. A forward gap in
the canonical block sequence (block N → N+k) is no longer silently accepted:
it emitsReactiveReport::MissedBlockRange(+ResyncReason::MissedBlockRange)
and escalates health, while still applying the arriving block. Repeated trust-loss
events (deep reorg beyond the journal, or a missed range) escalate
Healthy → Degraded → Unhealthy(a "stop until rebuilt" signal). - Account/root fetcher seam.
EvmCache::account_proof_fetcher/
set_account_proof_fetcher(AccountProofFetchFnovereth_getProof, returning
AccountProof);ResyncTarget::Accountresyncs now resolve through it
(materializing so cold accounts are not silently skipped). - Bulk storage extraction (
bulk_storagemodule) — now the default storage
loader. Every provider-backed cache ships aStorageBatchFetchFnthat
loads thousands of slots pereth_callby overriding target code with
Dedaub's 23-byte extractor
(storage survives a code override), plus a Multicall3-dispatch path that
spans many contracts in one call — with the classic point-read fetcher as
automatic fallback (tiny requests, providers without override support —
which also latch the fetcher to point reads after consecutive failures —
and precompile targets). Public surface:
bulk_call_storage_fetcher[_with_fallback], the async core
fetch_slots_bulk,BulkCallConfig(chunking, concurrency,
CallDispatch::CallManyfor Erigon-lineage endpoints where one 20-CU
eth_callManyrequest carries the whole batch),planned_call_count,
StorageFetchStrategy+EvmCacheBuilder::{storage_fetch_strategy, bulk_call_config}(opt-out / tuning),point_read_storage_fetcher(the
extracted classic path), and both Shanghai (PUSH0) and pre-Shanghai
extractor variants — all executed against revm in the offline test suite.
Measured on Alchemy: 10,000 slots in one 26-CU call (vs 200,000 CU as point
reads); a full Uniswap V3 pool tick range — 7,674 slots — in 2 calls /
52 CU; 100 contracts × 30 slots in one call
(benchmarks + limitations).
StorageFetchErrorandRuntimeErrornow deriveCloneso one
chunk-level failure can be reported per affected slot. - Custom storage programs + companion extractors + prewarm.
StorageProgram/run_storage_program[s]inject caller-supplied bytecode
via the same override transport, enabling data-dependent extraction
derived in-EVM (a worked one-shot Uniswap V3 observation-ring loader — zero
calldata — ships in the benchmark example and revm tests);
fetch_account_fields_bulk(BALANCE+EXTCODEHASHfor many accounts in
one call) andfetch_block_context(seven env words in one call) ride the
same mechanism;EvmCache::prewarm_slotsbulk-loads a declared working set
into layer 2 through the installed fetcher, returning aPrewarmReport. - Verified code seeding & local etch. Adapters can push runtime bytecode
into the cache instead of paying aneth_getCode(plus the lazy backend's
balance/nonce round trips) per address, with per-address trust marks
(CodeSeedState; absence of a mark = RPC-origin) persisted to
code_seeds.bin— saved beforebytecodes.binand pruned on load, so an
unverified claim can never masquerade as chain-fetched across restarts.
seed_account_code[_with]records a canonical claim (Pending);
verify_code_seeds()settles the whole pending set against on-chain
EXTCODEHASHin oneeth_callthrough the newAccountFieldsFetchFn
seam (default-wired tofetch_account_fields_bulk), marking matches
Verifieddurably (real balance patched from the same response) and
purging contradicted claims for refetch —CodeVerifyReportbuckets
mismatched/not_deployed/codeless/unverifiable(the last is
fail-safe: transport failures keep seedsPending). Chain-fetched code
beats templates: an equal-hash seed over RPC-origin code verifies
instantly with zero RPC; a conflicting one errors
(CacheError::CodeSeedConflict) without touching the cache.
etch_account_codeis the raw-bytes deliberate divergence sibling
(Etched, never verified);override_account_code*anddeploy_contract
targets now also recordEtched, makingetched_accounts()the single
local-divergence health surface. The cold-start driver gained a
verify_codephase that runs first — no discover sim executes over an
unverified claim — recordingColdStartResults.code_verificationsand
guarded byColdStartError::NoAccountFieldsFetcherfor pending-bearing
rounds. Spec:docs/verified-code-seeding-spec.md. - Strict block context + engine-driven env refresh.
BlockContextRequirements
(strict/lenient/per-field) +BlockContextError;
EvmCacheBuilder::strict_block_context/block_context_requirementsand a
fallibletry_build(EvmCache::newstays infallible + lenient);
EvmCache::advance_block(header)refreshes the full block env
(number/basefee/coinbase/prevrandao/gas-limit/timestamp) with the same strict
validation, driven from the reactive canonical-header path; new
coinbase()/prevrandao()/block_gas_limit()getters. Validitystamping (opt-in).ReactiveRuntime::enable_freshness_stamping()
stamps canonical event-derived writesValidity::ValidThrough(N)so
event-maintained slots stop being needlessly re-verified.- storageHash root gate +
TrackingPolicy+RootGateCadence.
TrackingPolicy(Slots/WholeAccount/Scalars) +track_account;
the root gate emitsReactiveReport::CoverageGap(+
ResyncReason::RootMoved) when a tracked account's storage root moves with
no covering decoder, andScalarstracks native balance/nonce/code (which
do not move the storage root). The gate fires perRootGateCadence
(set_root_gate_cadence), default every 16 canonical blocks, never
per-block:eth_getProofis the slowest read the crate issues, and the
gate diffs against its persisted baseline (never block-over-block), so
skipping blocks trades bounded detection lag for a 16× probe-cost cut
without losing detection. The decoder-touched set accumulates across
skipped blocks (drained per firing) so covered writes never false-positive
as gaps;every_n_blocks(1)restores per-block probing andDisabled
turns the gate off. All root-gate and account-resync probes now go through
one seam invocation per firing (grouped by resync block), and the
default proof fetcher fans them out with bounded, order-preserving
concurrency —EvmCacheBuilder::max_concurrent_proofs(default 8) — so a
fleet probe costs ~ceil(N / cap)round trips instead ofN. - Cold-start root baseline (
roots.bin).RootBaselinepersists each
tracked account'sstorageHashalongside the state file (versioned binary,
magicEFCROOT); on restart,RootBaselinePlannerroot-probes the baseline
viaeth_getProofand converts moved roots into targeted resyncs instead of
trusting stale disk state (restart-drift detection, Phase-8 step 5). - Tier-3 trace-backed resync. The reactive runtime resolves matching
resync targets from onedebug_traceBlockByNumber(prestateTracer,
diffMode) block diff before falling back to storage/account point reads —
BlockStateDiffFetchFn(+set_block_state_diff_fetcher),
BlockStateDiff/BlockStateAccountDiff/BlockStateStorageDiff, and
ReactiveRuntime::ingest_batch_with_resync(Phase-8 step 6). Measured
economics/latency in
docs/trace-resync-benchmarks.md. - Bundle cost accounting.
BundleResult::successful_tx_gasand
reverted_tx_gas— net searcher cost (coinbase_payment + reverted_tx_gas) is now
direct underAllowReverts. - Snapshot-consistency generation guard.
EvmCache::snapshot_generation()
— an opaque monotonic counter bumped by targeted state writes
(apply_update/apply_updates/modify_slotand everything built on them)
and block re-pins (set_block/advance_block), but not by cold prefetch.
Read it aroundsnapshot()to detect (and retry) a snapshot taken
mid-block during continuous ingestion, closing the ROADMAP-listed
consistency gap (G6).
Changed (breaking)
- Freshness verdict taxonomy is honest about scope.
Validation::Confirmedis
renamedConfirmedStorage(it only ever meant "no volatile storage slot changed",
not account-level state); a newConfirmedFullcovers storage and verified
account fields;Validation::Corrected'schangedfield is renamed
changed_slotsand gainschanged_accounts: Vec<AccountChange>. ResyncFailureKind::UnsupportedAccountTargetremoved, replaced by
MissingAccountFetcher/AccountFetchFailed/AccountFetchOmittednow that
account resync is supported.- Growth-bound enums are now
#[non_exhaustive].ReactiveReport,
ResyncReason,ResyncFailureKind,TrackingPolicy,CacheHealth, and the
CacheMetricsSnapshotstruct are marked#[non_exhaustive](matching the
StateUpdate/PurgeScopeprecedent): downstreammatches need a wildcard arm,
and future variants/counters will no longer be breaking changes. - Snapshot API names are now role-based. The low-level in-place rollback copy
isEvmCache::checkpoint()and the immutable fan-out view is
EvmCache::snapshot() -> Arc<EvmSnapshot>. The pre-0.2.0snapshot()/
create_snapshot()public names were removed rather than aliased; the hidden
benchmark/reference helper is nowsnapshot_deep_clone(). - Storage batch tuning is per instance.
StorageBatchConfigexposes
slots_per_batchandmax_concurrent_batchesfor the provider-backed
storage fetcher.EvmCacheBuilder::storage_batch_config(...)accepts exact
values,CacheSpeedModeremains as presets viaFrom<CacheSpeedMode>, and
EvmCacheBuilder::speed_mode(...)is shorthand. The process-global
set_cache_speed_mode/cache_speed_modefunctions and static are removed. SpeculativeSim::validate()returnsResult<Validation>. Validator-owned
uncertainty still returnsValidation::Unverified, while a failed background
task (for example a panic) now surfaces as an error instead of being folded into
a verdict or panicking on the handle.- Crate-owned fallible APIs now return typed errors instead of
anyhow.
Public helpers expose domain errors such asCacheError,StorageFetchError,
RpcError,FreshnessError,DeployError,MulticallError, and
AccessListError;SimError::Othernow carriesSimHostError.anyhowis no
longer a normal library dependency, though examples/tests may still use it as
harness glue.
Fixed
EventPipeline::derived_slotsno longer grows unbounded. It is now a
block-horizon ring bounded toReorgConfig::depth(mirroring thetouchedring),
so steady-state ingestion does not leak memory.BLOCKHASH-reading sims fail closed in freshness validation. Validator
overlays carry no block hashes, so an in-lookback-rangeBLOCKHASHread
resolves to ZERO; such sims were previously eligible for a silent
ConfirmedStorage. The read is now recorded
(EvmOverlay::blockhash_zero_fallback) and the verdict is
Validation::Unverified— on the optimistic pass and on corrected re-runs.
Out-of-lookback reads return the spec-mandated ZERO and are not flagged.- Block-diff traces now surface full account deletions. A SELFDESTRUCTed
account (present in the trace'spre, absent frompost) gets explicit
zeroed balance/nonce/code inBlockStateDiff, so account-target resyncs
resolve from the trace instead of falling back to point reads.