Skip to content

feat(mega-evme): batch replay and verification tooling - #366

Open
RealiCZ wants to merge 123 commits into
cz/chore/upgrade-revm-40from
cz/feat/evme-replay-tooling
Open

feat(mega-evme): batch replay and verification tooling#366
RealiCZ wants to merge 123 commits into
cz/chore/upgrade-revm-40from
cz/feat/evme-replay-tooling

Conversation

@RealiCZ

@RealiCZ RealiCZ commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Turns mega-evme replay into a self-contained equivalence-verification tool: replay whole blocks or transaction lists in one process, verify every replay against its on-chain receipt, and persist reusable fixtures and RPC caches safely under concurrency.

  • Batch replay: --block <N> / --tx-file <LIST> replay many transactions in a single process (one provider, one cache, each block executed once), emitting one NDJSON entry per target; single-transaction output stays byte-identical.
  • Receipt verification: --verify-receipt fetches each target's on-chain receipt and compares status / gasUsed / logs, reporting a structured diff; mismatches drive a dedicated exit code.
  • Fixture sweep: --dump-fixture-dir <DIR> bulk-dumps self-validating state-test fixtures (bench corpus format) with per-target fidelity gating.
  • Concurrent-safe caching: cache persistence takes a file lock and merges with on-disk state, so multiple processes can share one --rpc.cache-dir; cache merge consolidates existing per-worker caches; capture envelopes get optimistic-concurrency protection for the external-env snapshot.
  • Cache capacity: --rpc.cache-size is replaced by --rpc.cache-max-entries (0 = effectively unlimited), so long verification runs stop silently evicting early entries.
  • Rate-limit clarity: --rpc.rate-limit is renamed to --rpc.cu-per-sec (old name kept as a visible alias), with a warning for self-throttling values.
  • Request timeout: --rpc.request-timeout (default 30 s, 0 disables) bounds every HTTP request, so a stalled endpoint fails fast and retries instead of hanging the process.
  • Exit codes and structured errors: one documented taxonomy (0 success / 1 execution or input error / 2 verification mismatch / 3 RPC failure); --json runs always end with a machine-readable error object on failure.

All changes are confined to bin/mega-evme and docs/mega-evme; no consensus code is touched.

Testing

  • Full suite (cargo test -p mega-evme) plus envelope-gated offline batch integration tests.
  • Live mainnet verification: whole-block batch replays with --verify-receipt (29/29 and 25/25 receipt matches on fresh blocks), two-process concurrent cache sharing with no lost entries, and fixture sweeps self-validated through the state-test runner.
  • CI-grade lint: workspace clippy under -D warnings, rustfmt, cargo-sort, Prettier on docs.

Follow-up fixes (2026-08-11)

Rebased onto the current cz/chore/upgrade-revm-40 tip (repository rules forbid force-pushes, so the base sync landed as merge commit 62e1dc0; zero conflicts), plus three fixes:

  • Replay correctness — RPC account existence (36081e7): JSON-RPC cannot express "this account was never created" (eth_getBalance / eth_getTransactionCount / eth_getCode all answer zero), so the forked backend materialized never-created accounts as existing empty accounts. That flipped the EIP-7702 per-authorization refund condition: a brand-new authority was judged already-in-trie and each replayed authorization refunded 12,500 gas the chain did not, making replayed gasUsed under-report the on-chain receipt. The forked state now maps the all-zero answer back to None. This is safe because an existing-but-empty account cannot occur post-EIP-161, which every chain this tool replays has had from genesis. Tests: DB-level boundary matrix (all-zero → nonexistent; balance-only / nonce-only / code-only → still existing) plus an execution-level mock-transport test asserting a type-4 transaction with a fresh authority costs exactly 12,500 gas more than with an existing authority (verified to fail without the fix).
  • Batch replay cache policy (409de9a): online batch replay (--tx-file / --block) now engages the on-disk RPC cache only when --rpc.cache-dir is passed explicitly, and otherwise behaves as --rpc.no-cache-file. Rationale: a linear history scan's request keys are block-scoped and essentially never repeat across runs, so a shared cache file buys almost no hits — while the clean-exit persist re-reads, merges, and rewrites the whole file under a cross-process lock, a tail that grows linearly with the file (minutes at multi-GB sizes) and serializes concurrent batch workers into hour-long queues. Defaulting batch to no persistence makes the exit cost zero and independent of cache size, which dominates the alternative (incremental/sharded persist) that would still pay the per-process load cost and add format complexity. Single-transaction replay, run/tx --fork, and capture mode keep their previous defaults; --rpc.no-cache-file, --tx-file / --block / --verify-receipt semantics, and the exit-code taxonomy are unchanged. Tests: a batch run against a seeded default-path cache file leaves it byte-identical and creates no cache file anywhere (so a 26k-target batch exits with no cache tail at all), an explicit --rpc.cache-dir still persists, and single-transaction replay still persists by default.
  • tx --raw decoding (a77ed86): DecodedRawTx held the real envelope yet hand-mapped every variant into a TxEnv; it now derives the transaction through the upstream FromTxWithEncoded impl (which also fills the deposit parts and the enveloped bytes for L1 fee calculation), so new transaction types are picked up with the dependency instead of a manual mapping. The CLI-flag path (TxArgs) has no real envelope and is unchanged. Tests: EIP-155 signed-vector decode, deposit-envelope decode, and CLI-override behavior.

Review-fix batch + coherent --override.spec (2026-08-12)

22 commits (16 changes + 2 test-hardening + 4 merges), all in bin/mega-evme/ and docs/mega-evme/; workspace suite 1846 green, clippy/fmt/prettier/cargo-sort clean.

  • Batch target classification is now total over the (blockNumber, blockHash) resolution space: a mined answer without an inclusion hash and the contradictory null-number-with-hash shape both fail the target as rpc instead of being queued or misread as pending; a block-body hash that resolves to null aborts as a typed RPC inconsistency (exit 3) that still names the vanished transaction for the abort sweep.
  • The single-transaction path now matches batch on fetch coherence: target metadata is classified exhaustively before any block fetch, the parent block must link to the fetched block, the target must anchor to it (reported inclusion hash and body membership), block-body nulls reuse the same typed rpc variant (the user-supplied hash keeps its definitive not-found, exit 1), and a pending replay fetches the latest block once and reuses it for both roles.
  • replay --override.spec is now a coherent what-if: the executor receives a schedule synthesized from the forced spec (activation from the spec, per-fork parameters delegated to the chain config), so pre-block predeploys, EIP-2935/4788 gating, block-level limits, and EVM semantics all derive from one spec; without an override the behavior is byte-identical (verified by a binary-output diff probe). Replaying an old block under a newer spec deliberately installs predeploys that never existed at that height. Note: mainnet/testnet schedules currently publish only the Rex5 registry parameters, so --override.spec Rex6+ on those chains fails closed with a message naming the missing config (previously it crashed with a code-hash mismatch).
  • Every cache-file write now takes the sidecar lock: cache merge locks its output and folds the current on-disk file in under the lock (two-process serialization tests), lock-acquisition failure fails closed instead of degrading to an unlocked write, --rpc.clear-cache unlinks and loads inside one critical section, and the provider persist classifies the on-disk shape before writing so it can no longer replace a capture envelope. Safeguard diagnostics (chain-identity, output replacement) now reach stderr at default verbosity.
  • Capture hygiene: the capture transport no longer bakes result: null answers into fixtures — offline replay reports a cache miss naming the request instead of a frozen not-found.
  • Robustness: the panic hook writes fallibly, so a closed stdout (--json | head) ends with the documented exit 1 instead of a SIGABRT; the fixture pre-map's absent-means-nonexistent shape is pinned by tests; raw-tx decoding gains signed EIP-2930/1559/7702 vectors with full field assertions.

Review threads fixed by this batch are resolved; the batch-mode --rpc.clear-cache thread stays open pending a semantics decision.

Exit-taxonomy unification + batch-cache opt-in (2026-08-12, second batch)

11 commits, all in bin/mega-evme/ and docs/mega-evme/; workspace suite 1891 green, clippy/fmt/prettier/cargo-sort clean.

  • --rpc.clear-cache is a disk-cache opt-in for batch replay: the documented recovery flag now engages the cache (delete under the sidecar lock, start empty, persist on exit) instead of being silently dropped; the --rpc.no-cache-file combination's actual behavior (clear does not run) is documented as is.
  • Receipt handling is one contract across all modes: dump and verify classify a null, divergent, or unfetchable receipt identically (rpc, exit 3) with byte-identical error objects, and every receipt must belong to the transaction it was requested for — a receipt served for another transaction is an RPC inconsistency in single verify, single dump, and batch alike.
  • Batch classification is order-independent and truthful: per-target inclusion validation against the fetched block replaces the first-seen anchor; an anchored target absent from the block body is an endpoint contradiction (rpc); a non-target transaction aborting the block drives the run's exit class through a separate floor without corrupting the per-target totals; one unavailable receipt under --verify-receipt --dump-fixture-dir is counted once while both result fields are emitted; NDJSON output follows the documented (block, tx_index) order with absent targets last; a block containing none of its targets is answered without being executed.
  • Failed receipt fetches keep the replayed result: the target's execution facts stay on its NDJSON line with the failure under verification.error / fixture.error, per the documented keep-your-result policy; the documented jq selectors cover both failure shapes.
  • Pre-block RPC failures classify correctly: an unanswered state read inside the EIP-2935/EIP-4788 system calls exits 3 (was 1), matched at the cause boundary through named wrapper constants so an execution error merely embedding "RPC error:" text cannot misclassify. Known residue, deliberately out of scope: a keyless-deploy sandbox DB failure is collapsed to a selector-only revert inside the executor and cannot be classified from the tool side.
  • The --block 0 (invalid request, exit 1) vs endpoint-reported block-0 inclusion (contradictory endpoint data, exit 3) asymmetry is now documented rationale rather than an accident.

All four previously open review threads are fixed by this batch and resolved.

Served-answer authentication + classification consistency (2026-08-12, third batch)

Four fixes from the Codex review round on 8a8cad0, each with red-green regression tests:

  • Fetched transactions are authenticated before execution: all three eth_getTransactionByHash consumers (batch loop, single-path preceding loop, single-path target) recompute the hash from the served consensus encoding and re-derive the sender from the signature, refusing mismatches as an inconsistent-endpoint failure (exit 3). The response's own hash/from fields are never trusted — notably, alloy's trie_hash()/tx_hash() return the cached server-supplied hash for RPC-deserialized transactions, so authentication hashes the encoding explicitly.
  • Hashless block aborts attribute to the in-flight transaction: a rejection that names no hash (block-gas admission, for one) now lands on the transaction whose iteration raised it instead of sweeping the aborter itself as an unanswered peer.
  • A null endpoint-resolved block is an RPC failure: on the single path, the replayed block and its parent are fetched at heights the endpoint itself resolved, so a null answer is the divergent-views class (exit 3), matching batch; BlockNotFound (exit 1) stays reserved for user-supplied heights.
  • Local clear-cache failures exit 1: lock-acquisition and unlink failures classify as input errors, not RPC failures — retrying or switching the endpoint cannot fix the local filesystem.

Docs: the exit-code taxonomy in overview.md now records the authentication and resolved-block-null conventions; state-management.md records the clear-cache failure class. All four review threads from this round are fixed and resolved.

Refactor: consistency infrastructure + single-format RPC cache (2026-08-14, fourth batch)

Two refactor waves, every step gated by a committed golden-equivalence baseline that was never regenerated.

A-phase (consistency infrastructure):

  • Typed capture-doctor test helpers (tests/common/doctor.rs): every envelope-tampering operation carries an exactly-one-hit contract, parsed-field locators (a trap entry pins that raw-marker matching cannot come back), and a full contract matrix.
  • Golden equivalence gate (tests/replay_equivalence.rs + tests/golden/): 22 argv rows × human/json over the committed captures, full-stderr pinning, exit-2/mixed-batch/keep-result rows, negative controls that run under bless too, and a bless flow that re-derives each snapshot from a second run before accepting it.
  • Shared target-coherence judgments (src/replay/coherence.rs): the metadata four-shape classification, genesis guard, parent-linkage, inclusion-anchor, and body-membership checks now exist once as pure predicates with typed errors; both drivers adapt them locally, and every contract message is byte-identical to before.
  • Single receipt admission (verify::ReceiptEvidence): dump+verify on the single path fetch/authenticate/anchor the receipt once instead of twice.

B-phase (single-format RPC cache):

  • The online per-chain cache is now served from the transport layer in the same envelope format capture writes (plus a kind: "cache" marker; capture fixtures stay byte-identical), with a typed caching policy: JSON-RPC errors, null results, eth_blockNumber, moving-block-tag requests, and pending transaction metadata are never persisted — fixing the open review thread where a cached pending answer froze --verify-receipt until a manual --rpc.clear-cache.
  • Warm replays now cache block bodies (previously never cached): a warm single-transaction verify drops from 3 RPC requests to 1.
  • The capacity flag is an eviction threshold rather than a preallocation (a huge --rpc.cache-max-entries no longer balloons memory), 0 keeps its published 1,048,576-entry ceiling.
  • Cache files self-identify: the body's chain_id is verified against the endpoint (hard error on mismatch), files the tool cannot identify are never deleted or overwritten, and the retired provider-array format self-heals with one warning (it stores only hashed keys under a different algorithm, so no migration is possible).
  • The provider-array support surface (alloy CacheLayer persistence, SharedCache, dual merge paths, filename chain-id heuristics) is deleted; cache merge is envelope-only, checks chain identity from the body, and refuses non-envelope input with actionable guidance. Its output carries no kind marker, so consolidating worker caches into a seedable per-chain file is retired (documented in cache.md).
  • Docs and --help text describe the single format and the real memory behavior.

Refactor: shared mined-block execution kernel (2026-08-14, fifth batch)

The single-transaction and batch replay drivers previously each carried their own copy of the block execution walk. This batch extracts one kernel and migrates both onto it, in four equivalence-gated steps:

  • Kernel extraction (src/replay/kernel.rs): fork parent state → authenticated per-transaction walk with in-flight abort attribution → early stop at the last target → finish and receipt harvest. Guards, fetching, entry assembly, and error adaptation stay driver-side.
  • Typed lifecycle: TargetLifecycle (inspector as a construction generic, fallible transaction-transform and post-execution hooks) and a two-phase materialization guarantee enforced by the type system — a fixture draft can only be redeemed against a CleanRun token the kernel mints after a clean finish, so a draft cannot be published for a block that aborted.
  • Single mined path migrated: trace, state dump, --override.*, --dump-fixture, and --verify-receipt all plug into the kernel's lifecycle points. Zero test modifications, zero golden regenerations; the surfaces the goldens did not cover were verified by a 239-file binary diff against the pre-migration build (only the execution-time line differs), and RPC wire-request counts are unchanged. The pending path deliberately keeps its dedicated adapter: its fetch-once-fill-both-roles anti-divergence semantics are a special case, not a kernel behavior.
  • Regression matrix committed: the equivalence gate grows from 48 to 79 cases — tracer modes (opcode/call/prestate/prestate-diff, inline and to-file), state dumps, spec and transaction overrides, preceding-transaction failure, and mid-block abort — all offline, with large trace bodies pinned by normalized digest and a negative control proving the digest tracks the body it stands for.

There is now exactly one body-walk implementation in the tool. Honest accounting: src/replay grew 13.5% (typed API surface, the now-standalone pending adapter, and reconciliation rationale in comments — about half the growth is prose); the structural goal, not the line count, was the point, and the same-fix-twice class of drift this PR repeatedly hit during review is structurally gone for the execution core.

Two deliberate, non-behavioral deltas: the human-output execution-time span now includes fork and finish, and the fixture-refusal path now finishes the block (provably no state read, no RPC, same surfaced error).

Review response: block-header authentication + planning-time target authentication (2026-08-18, sixth batch)

Three fixes and two documented acknowledgements from the Codex round on the refactor batches:

  • Fetched block headers are now authenticated: the consensus-header hash is recomputed (hash_slow, not the served field) and compared against the endpoint's claim at every block-retrieval site in both drivers. Previously, a tampered capture or inconsistent backend could alter timestamp/gasLimit/baseFee while keeping the claimed hash and every guard passed — verified operationally against the pre-fix binary (forged gasLimit exited 0; now exits 3 naming both hashes). Mock chains in tests are now sealed bottom-up like real blocks; the capture doctor gained field-rewrite-and-reseal operations under its contract tests.
  • Batch target resolution authenticates before classifying: a pending-looking answer served under a different transaction's hash was previously stamped as definitively pending (resolution is the one lookup that never reaches the kernel's authenticated walk); it is now an inconsistent-endpoint rpc failure.
  • --overwrite requires --dump-fixture-dir at parse time instead of parsing successfully and silently doing nothing.
  • Documented: the per-chain cache persists mined/numbered answers as final (MegaETH's single-sequencer blocks do not reorg; --rpc.clear-cache / --rpc.no-cache-file are the outs on endpoints where that assumption fails), and --override.spec synthesizes the schedule but never rewrites forked parent state (contracts deployed by later forks remain reachable as plain bytecode under a downgrade).

All five review threads from this round are addressed and resolved.

Review response: height authentication + in-place merge concurrency (2026-08-18, seventh batch)

  • Fetched headers must match the requested height: an authentic, self-consistent block served for the wrong number — including a consistently shifted parent/block pair, which passed every existing guard and silently replayed another block's body under the requested number's stamp (verified against the pre-fix binary: exit 0 with cross-stamped receipts; now exit 3 naming both heights) — is rejected at the fetch by the shared coherence judgment.
  • In-place cache merge can no longer roll back a concurrent writer: an input that names the output is excluded from the pre-lock read and contributes only through the locked re-read, pinned by a two-process regression test that fails against the pre-fix binary.
  • Documented: the replay trust boundary — transaction bodies and block headers are authenticated by recomputable hash; the block body listing (rebuilding the transactions root would need trailing bodies the walk deliberately never fetches after the last target) and state reads remain trusted, with --verify-receipt as the cross-check for both.

All three review threads from this round are addressed and resolved.

Review response: hard-link aliases + served-hash-field invariant (2026-08-18, eighth batch)

  • Hard-linked aliases of the merge output are now detected: file identity is compared by (dev, ino) rather than canonical path, closing the remaining alias route by which an in-place cache merge could read a stale pre-lock copy of the output.
  • The served hash field joins the transaction authentication invariants: an honest payload carrying a lying hash field previously passed authentication (which deliberately recomputes) while the batch dump path filed the fixture under the lied name — result line reporting H, artifact written as X.json, --overwrite able to replace an unrelated target's fixture (verified against the pre-fix binary). Every tx_hash() read downstream of authentication is now a verified value.

Both review threads from this round are addressed and resolved.

--dump commit semantics (2026-08-25, ninth batch)

--dump serialized the raw EvmState without interpreting revm's account status flags, so it described a world the commit never produces:

  • An account destroyed by SELFDESTRUCT (created and erased in one transaction, EIP-6780) was printed as a live account with full code and storage, contradicting the prestate-diff tracer's post side. It is now the bare marker {"selfdestructed": true}, and --prestate reads such an entry as "this address does not exist" — so a dump stays safe to feed straight back, and the round-tripped world equals the committed one.
  • An address only observed as nonexistent (read, or touched without ever gaining balance, nonce, or code) was printed as an existing empty account; a round-tripped prestate would then answer EXTCODEHASH with the empty-code hash where the chain answers zero. Such addresses are now omitted; the tombstone outranks the omission.

A class sweep found no further members: the fixture pre-state closure already maps a missing account to absence, the prestate-diff tracer is upstream-owned and correct, storage slots have no observable zero-vs-absent distinction, and the commit paths use revm's own commit. The tombstone predicate is the same one revm's CacheDB::commit uses, so dump and commit cannot drift apart on it.

Live accounts serialize byte-identically to before; the equivalence goldens are untouched.

RealiCZ added 13 commits August 4, 2026 14:42
Canonical flag is now --rpc.cu-per-sec; --rpc.rate-limit remains a
visible alias. Clarify that the value is a CU/s budget, not RPS, and
warn once at provider build when retries are on and the budget is <100.
Delete --rpc.cache-size and introduce --rpc.cache-max-entries (default 0 =
never evict) so verification workloads keep large RPC caches. The cache
layer is always installed; 0 maps to u32::MAX capacity. Capture/replay
conflict lists and docs updated.
Alloy SharedCache preallocates its LRU hash table to full capacity, so
mapping "unlimited" to u32::MAX caused multi-GB RSS on every default
online run. Cap at 2^20 entries (cheap preallocation, covers far more
than any observed corpus) and add a construction-reality unit test.
Replay many transactions in a single process: build one provider and one
RPC cache, group targets by their containing block, and execute each block
once while recording every target's result. Batch mode emits NDJSON with
--json (one line per target, result or error entry), exits non-zero when a
target hit an infrastructure failure, and rejects the flags that only have
single-transaction semantics (fixture dump, overrides, forced spec, trace,
state dump).

The single-transaction path is unchanged; its output stays byte-identical.
Command dispatch now maps errors instead of propagating with ?, so the
error handler runs and diagnostics stay off stdout.
Share --rpc.cache-dir across processes via exclusive sidecar lock plus
re-read-merge on persist (provider cache and capture envelopes). Add
mega-evme cache merge for offline consolidation of both shapes.
Add `replay --verify-receipt`, which fetches each replayed transaction's
on-chain receipt and compares it against the receipt the replay produced:
success status, gas used, and the emitted logs (count plus each log's
address, topics, and data). Works in single-transaction and batch mode.

The comparison lives in a new `replay/verify` module as a pure function
over the consensus facts of both receipts, so it is independent of how
either receipt was obtained. The verdict is reported as a `verification`
object in JSON (absent without the flag) and as one verdict line in
human-readable output; a mismatch fails the run through a dedicated
`VerificationMismatch` error.

Anything that prevents the comparison from running is reported as an
infrastructure failure rather than a mismatch: a receipt the endpoint
cannot serve or has pruned, a receipt describing a different inclusion
than the replayed block (reorg or divergent endpoint), and pending
transactions.
Add batch sedimentation of self-validating EEST fixtures so a tx list or
block can be swept into <DIR>/<tx_hash>.json in one run. Per-target
fidelity/BLOCKHASH/unsupported-shape gates skip with a recorded reason
instead of failing the run; write failures stay infrastructure errors.
…e output

Every failure now maps onto one of four documented exit codes through a single
module: 0 success, 1 execution/internal error, 2 receipt verification mismatch,
3 RPC/transport failure. The mapping matches the error enums exhaustively, and
`main` is the only place that turns a command result into a process status.

Batch replay aggregates its per-target failures into a structured error carrying
the counts by class, so the run-level precedence (execution before rpc before
mismatch) is resolved from data instead of a formatted message.

On failure a run reports once: an `error: <message>` line on stderr, plus — with
`--json` — a compact `{"error":{"code","kind","message"}}` object as the last
stdout line, so a machine-readable run never ends with empty stdout. Per-target
NDJSON lines and all success-path output are unchanged.
…n checks

Reject conflicting non-null external_env on envelope persist (same rule as
cache merge), validate rpc-cache-{id}.json chain identity on provider merge,
classify malformed --rpc URLs as InvalidInput (exit 1), and type envelope
re-read hard vs degradable without substring matching.
Report a failed fixture dump on the target's own result line instead of
replacing it, so the receipt verification still runs and its mismatch is
counted; the failed dump remains an execution-class failure.

Classify targets swept up by a mid-block abort as unanswered (`rpc`) with
a message naming the aborting cause, reserving `not_found` for the target
the endpoint actually denied, and emit them in block transaction-index
order so the stream stays ascending by (block, index).

Route argument-parsing failures through the structured output, report a
capture-persist failure on stderr next to the run error that owns the
exit code, and stop mapping a `BatchFailed` with no counts to success.

Classify a block execution error by the database failure behind it, so a
state read that fails mid-execution exits 3 instead of 1; the pre-block
system calls and the keyless-deploy sandbox render their cause into a
message before it reaches the mapping and stay execution-class.
…c JSON

Carry load-time external_env through capture persist so intentional
--bucket-capacity refreshes win while true concurrent conflicts hard-error;
canonicalize bucket lists; always report non-aborting swept targets as rpc;
emit structured JSON on panic under --json.
Stop RpcCacheStore::new_envelope from re-reading the capture file after
build_capture_provider already loaded it. A concurrent writer between the
two reads could make C look like the load-time baseline so an A-derived
persist silently overwrote C. Carry the first-load snapshot as a parameter.
Default 30s bounds hung endpoints so they surface as retryable transport
errors (exit 3 after retries) instead of hanging the process forever.
@mega-maxwell

mega-maxwell Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Claude review status

Living comment — rewritten in place. The review workflow keeps this single comment up to date instead of posting a new one each round, so it always describes the latest reviewed commit and the earlier text is intentionally gone. No reply is needed here; reply to a finding in its own review thread, and answer an open question in a reply on this PR. The next review round reconciles your answer.

✅ Review clean

Last reviewed: bd5bedc2..c5afc367 · updated 2026-08-14T07:15:52+00:00

New this round: 0 finding(s), 0 question(s) · Resolved this round: 1 · Open questions: 0

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🧬 Mutation testing — ✅ PASS

Nothing to test — no mutants were generated on the changed lines.

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Documentation Impact

This PR adds a new top-level mega-evme cache command (bin/mega-evme/src/cache/, wired in src/cmd.rs). The docs/mega-evme/ spec pages and bin/mega-evme/AGENTS.md have since been updated to cover it (STRUCTURE now lists src/cache/ and src/common/exit.rs, thanks). One more reference was missed:

Agent / Skill Files

File Reason
AGENTS.md (repo root, also CLAUDE.md via symlink) The Workspace Structure table's mega-evme row still reads "CLI tool for EVM execution (run, tx, replay)" — missing the new cache subcommand added by this PR.

This update can be included in this PR or in a follow-up.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🧬 Mutation testing

No results at target/mutants/mutants.out — nothing was mutated (e.g. no mutatable changes).

@codspeed-hq

codspeed-hq Bot commented Aug 4, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 382 untouched benchmarks


Comparing cz/feat/evme-replay-tooling (14a7b6b) with cz/chore/upgrade-revm-40 (ccdeba2)

Open in CodSpeed

@RealiCZ RealiCZ added comp:mega-evme Changes to the `mega-evme` tool spec:unchanged No change to any `mega-evm`'s behavior dependencies Pull requests that update a dependency file rust Pull requests that update rust code api:compatible Only new interface or API is introduced. Existing software is compatible. comp:doc Changes in the documentation labels Aug 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8c47fd128

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/mega-evme/src/replay/batch.rs Outdated
Comment thread bin/mega-evme/src/replay/batch.rs Outdated
Comment thread bin/mega-evme/src/replay/batch.rs
Comment thread bin/mega-evme/src/replay/verify.rs Outdated
Comment thread bin/mega-evme/src/replay/batch.rs Outdated
RealiCZ added 2 commits August 4, 2026 15:09
Defer fixture writes until block finish succeeds; classify construction
failures as fixture errors rather than skips; validate parent-block hash
linkage; reject receipts with a null blockHash as infrastructure errors;
stamp receipt inner logs with block/tx identity and block-global indices.
The kernel exposed one untyped hook: a call between a target's execution
and its commit whose return value it carried to the harvest. That is not
enough for the single-transaction path to move onto it, and the parts it
was missing are the parts where a mistake is silent — an artifact
published from a block that never finished, an abort blamed on the wrong
transaction.

Type the whole participation as `TargetLifecycle`:

- The block executor is built with the driver's inspector, and the driver declares whether execution routes through it, so a plain replay keeps the EVM's non-inspected path while a tracing driver arms one.
- `before_target` turns the transaction the endpoint served into the transaction that runs, and the pre-execution nonce is read for whoever signs the result. The batch driver returns it unchanged.
- `on_target_executed` receives the inspector and the whole `ResultAndState` alongside the pre-commit database, and may now fail: a failing hook aborts the body exactly like a failed fetch.
- A draft leaves the run inside a `PendingDraft` that only yields its value against a `CleanRun` proof the kernel mints when the walk completes. Since a block that fails to finish drops its drafts inside the kernel, holding both means the block ran to a clean finish. A driver can still read a discarded draft to report it, but cannot take it, and publishing consumes it.

The abort is now attributed where it happens: the loop records the
transaction it was walking, so the driver no longer re-derives the
attribution from the error. The introspection fallback that did so was
already unreachable — every iteration names its transaction before any
fallible step — and it could name a different transaction than the one
that aborted, so it is retired along with its helpers.

The inspector bound is quantified over a borrow that only exists inside
the kernel, which is why the forked database is moved into `State`
instead of borrowed: a second reference layer under the quantifier is
more than the compiler can discharge for a real inspector.

Behavior is unchanged: the committed equivalence goldens are untouched.
…rnel

The single-transaction path walked its own block: it fetched every
preceding transaction, executed them, then ran the target and harvested
its receipt — the same sequence the kernel already performs for the batch
driver, written a second time and drifting from it in small ways.

Move that walk onto `execute_until_targets` by implementing
`TargetLifecycle` for the driver. It is the first participant that uses
the whole lifecycle: the target executes as `--override.*` rewrote it, an
inspector is armed for it alone, and the trace, the state diff and the
fixture draft are all read between its execution and its commit, coming
back as one draft the driver can only redeem against the kernel's
clean-run proof. The refusal to dump a fixture for a BLOCKHASH reader is
now a failing hook, which aborts the body exactly as it used to abort the
run.

Three points where the two walks disagreed are settled rather than left
to whichever code survived:

- **First log index.** The kernel counts the logs of the receipts of the committed body transactions ahead of the target; the old fold counted every receipt but the target's own. The two agree today because no receipt precedes the first transaction, and the kernel's reading is the one that stays right if that ever changes: the chain numbers logs across the receipts of the block body, so a receipt with no transaction behind it is not part of that numbering.
- **BLOCKHASH isolation.** The kernel clears the access record before every transaction where the old path cleared once after the preceding ones. The only reader is a target's own count, taken after its own clear, so the rhythms are indistinguishable. Written down as an invariant on the clear.
- **Target re-fetch.** The kernel asks the endpoint for each body hash, including the target the driver already resolved. The extra ask is served by the in-memory transport cache in every mode, so the wire sequence is unchanged: a mined transaction's metadata is cached under the same key the first lookup filled.

Pending targets keep their own adapter. Their single block fetch fills
both the fork and the environment role, and their metadata is exactly
what the online cache refuses to keep, so walking them through the kernel
would turn one endpoint lookup into two.

Behavior is unchanged: the committed equivalence goldens are untouched,
and trace, state-dump, override and fixture output — which those goldens
do not cover — was diffed byte for byte against the previous binary
across four target transactions and nineteen argument shapes, leaving
only the measured execution time.
The equivalence baseline pinned what a replay prints and what a fixture
dump writes, and nothing else. Everything the single-transaction path
gained by moving onto the shared kernel — the tracers, the state dump,
the overrides, and what a broken block body costs — was covered only by a
one-off byte-for-byte comparison against the previous binary, which is
evidence, not a gate. Fifteen rows turn it into one.

Eight rows cross the four tracers with the two destinations the flag
family offers, so a trace that stopped being generated, started being
generated from another moment of the block, or stopped reaching the file
it was pointed at, fails here. Two more do the same for the state dump.
Three drive the what-if knobs: a forced older spec, which prices the same
transaction at less than half its own fork's gas; all three transaction
overrides at once, where the value and the input each move the outcome on
their own; and a gas limit below the call's cost, the only form of that
override the EVM answers with a rejection rather than an identical
result. The last two break a block body — once ahead of a single target,
once between two batch targets — which pins the abort contract nothing
else reached: the target that had already committed keeps the result and
the receipt it earned, the one behind the break is reported as the abort.

A traced replay prints hundreds of kilobytes, so a row may opt into
recording its oversized sections as a content digest — byte count, line
count, keccak-256 — instead of the body. The gate stays as strict; what
is lost is the diff, which is why each digesting trace row has a sibling
that writes its payload to a file and keeps the printed summary verbatim,
and why the call tracer, small enough to hold whole, digests nothing. A
negative control drives the same row with and without
`--trace.opcode.disable-stack`: the reported output must not move while
the digest does, so a digest computed over the wrong bytes, or a
threshold that quietly stopped triggering, cannot pass.

No committed golden is rewritten: the twenty-two existing rows produce
the same bytes they did before.
Moving the mined single-transaction path onto the shared kernel left the
pending adapter holding three expressions that were already answers.

Its block-global log index folded over every receipt but the target's
own. The block it builds holds exactly one transaction, so the fold has
always summed to zero — but it is also the reading the kernel rejected
when the two walks were reconciled: a receipt produced before the block's
first transaction is not part of the numbering the chain assigns, and
counting its logs would shift the index off that numbering the day such a
receipt exists. Stated as zero instead, with the reason.

Its transaction index read the length of the preceding-hash list, which
`fetch_replay_context` only fills for a mined target and therefore leaves
empty here. Stated as zero, with the same reason written down.

Its trace was generated from a `ResultAndState` rebuilt out of clones of
the execution result and the whole post-state, when the outcome already
carries that pair — the kernel borrows it directly. Borrowed here too,
which drops two clones of the state from the pending path.

Behavior is unchanged: the equivalence goldens, including the trace and
state-dump rows added alongside, are untouched.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b9936c71a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/mega-evme/src/replay/batch.rs
Comment thread bin/mega-evme/src/replay/batch.rs Outdated
Batch target resolution read placement metadata straight out of the
served answer. A pending verdict exits planning immediately and never
reaches the authenticated lookup inside the execution kernel, so an
endpoint answering hash H with a different (pending-looking) transaction
could stamp H as definitively pending — and capture mode would persist
that answer for offline reuse. Resolution now authenticates every served
transaction before classification; an identity failure is an
inconsistent-endpoint rpc failure, matching the other fetch sites.

The abort-before-finish dump test switches to --block: as a listed
target its doctored index-2 object is now refused at planning, while the
block walk still reaches it after the dump target has drafted.
The flag is only consulted while a fixture directory is being written;
everywhere else it parsed successfully and silently did nothing, letting
an invalid automation invocation exit 0 while ignoring a requested
artifact policy. It now requires --dump-fixture-dir at parse time.
…e state boundary

Two review acknowledgements made explicit instead of implicit:

- The per-chain cache persists mined transactions, numbered blocks,
  receipts, and numbered state reads as final. That is MegaETH's
  single-sequencer reality; on an endpoint whose recent history can
  still change the assumption does not hold, and --rpc.clear-cache /
  --rpc.no-cache-file are the outs.
- --override.spec synthesizes the schedule, not history: forked parent
  state stays as recorded, so a contract a later fork had already
  deployed remains reachable under a downgrade as plain bytecode without
  its spec-gated interception. Deleting it would fabricate a parent
  state that never existed on any chain.
The `hash` an endpoint reports beside a block header is a claim about the
header, not a property of it, and every guard the replay applies to a block
consumes that claim: the inclusion anchor, the parent/block linkage and the
body membership all compare against it. An endpoint, or a tampered offline
capture, could therefore rewrite the fields the replay actually executes
under -- `timestamp`, `gasLimit`, `baseFeePerGas`, `parentHash` -- keep the
original `hash`, pass every guard, and have the target replayed in a world
it never ran in while the run exits 0.

A block header is a consensus object whose hash is a function of its own
fields, so the claim can be checked without asking the endpoint anything
further. `coherence::authenticate_block_header` recomputes it and rejects a
mismatch as an unanswered question (exit 3), naming both hashes. The hash is
recomputed from the served fields rather than read back through
`Header::hash()`, which returns the value being authenticated -- the same
trap the transaction-level authentication meets with the envelope's cached
hash.

Both drivers now fetch every block they execute against through one helper
each -- `batch::fetch_block` and `cmd::fetch_resolved_block` -- so the check
sits ahead of every guard that reads the reported hash. It is a local
recomputation, so the RPC call sequence is unchanged.

Test support follows the split the check introduces: the doctor's
`set_block_*` operations now produce unauthentic headers (the vehicle for
testing the authentication), and new `reseal_block` /
`reseal_block_keeping_references` operations rewrite a header and recompute
its hash, with and without relinking the references to the previous one.
The parent-linkage and mid-block-abort tests move to the resealing vehicles
so they still reach the behavior they pin, and the mock endpoints seal the
block headers they serve the same way they already computed authentic
transaction identities.
…vers

Each case rewrites one header field a replay executes under -- `timestamp`,
`gasLimit`, `baseFeePerGas`, `parentHash` -- leaves the served `hash` alone,
and requires the run to reject the block naming both the hash the fields
produce and the hash the endpoint reported. The single-transaction driver,
the `--tx-file` batch driver and whole-block mode are each driven over the
whole table, and a stripped capture holding nothing but the forged block
proves the verdict is reached without a further request.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

let envelope = CacheFileEnvelope::load(path)?;
let chain_id = envelope.chain_id;

P2 Badge Cross-check offline chain metadata with the captured response

Offline replay trusts the envelope's top-level chain_id without comparing it with the captured eth_chainId response, even though capture mode always records that request before persisting the envelope. If the metadata is corrupted or edited while the cached responses remain unchanged, replay silently selects the wrong hardfork schedule and exposes the wrong CHAINID value to execution, so it can emit incorrect results or false receipt mismatches. Query the replay transport for eth_chainId during construction and reject disagreement with envelope.chain_id.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/mega-evme/src/replay/coherence.rs Outdated
Comment thread bin/mega-evme/src/replay/batch.rs
Comment thread bin/mega-evme/src/cache/mod.rs
Authentication covers what a recomputable hash can prove — transaction
bodies and block headers. The block body listing and state reads stay
trusted: rebuilding the transactions root would need the trailing bodies
the walk deliberately never fetches after the last target, and state
reads carry no proof. --verify-receipt is the documented cross-check
for both.
`authenticate_block_header` proved a served header hashes to the hash it was
served under, but nothing proved the block answering a numbered fetch is the
height that was asked for: every block is fetched by number and no guard reads
the height back. An endpoint, or a tampered offline capture, could therefore
answer `eth_getBlockByNumber(N)` with a real, self-consistent block M. Offset
the parent fetch by the same distance and the parent/block linkage holds too --
whole-block mode then executed M's body under M's environment, forked the state
at N-1, stamped every receipt with N, and exited 0.

The shared judgment now takes the height that was asked for and rejects a
mismatch as an unanswered question (exit 3), naming both heights. The hash is
checked first: the height a header claims is one of the fields the hash covers,
so it is worth reading only once the header is proven to be the one the endpoint
vouches for. Both drivers' block fetches -- `batch::fetch_block` and
`cmd::fetch_resolved_block` -- pass the height they requested, so no call site
can wire one check without the other. Both checks are local, so the RPC call
sequence is unchanged.

The doctor gains `answer_block_with`, which moves one captured block's response
onto another height's request without rewriting a byte of it, so the moved
answer still authenticates and only its height gives it away. The
single-height offset is a new cross-driver row of the coherence table; the
matched pair, which the linkage and membership guards cannot see, is a
whole-block case of its own.
`cache merge out.json new.json -o out.json` names the output as an input too,
and inputs are read before the output lock is taken -- they are shape-checked
first so a doomed merge leaves no sidecar behind. The output was therefore read
twice: once as a pre-lock snapshot and once, correctly, under the lock. A
concurrent writer landing an updated value while the merge waited then lost it:
the union's inputs-win rule let the stale snapshot beat the locked read on the
shared key, and the merge exited 0 with nothing on stdout to say an entry had
been rolled back. That contradicts the whole point of taking the lock, which is
that neither side's entries are lost.

An input that names the output is no longer read as an input. It reaches the
union through the locked read alone, so `cache merge out.json new.json -o
out.json` means "fold new.json into whatever out.json holds now" -- and the
inputs-win rule is unchanged for the files that really are inputs. Identity is
by canonical path, so a symlink or a `..` path reaching the output is caught
too. Naming nothing but the output stays a success: the file is re-read under
the lock and written back, and only a missing or unreadable output with no other
input left is refused, since there would be nothing to write in its place.

The two-process harness gains the case the bug lives in: the writer updates a
key the merge already read while the merge is blocked on the lock, and the
merged file must carry the writer's value. Before the fix that assertion reports
the pre-lock value.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b6485930ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/mega-evme/src/cache/mod.rs Outdated
Comment thread bin/mega-evme/src/replay/batch.rs
An input that names the merge output is folded in under the output lock
instead of being read before it, so a pre-lock snapshot cannot overwrite
entries a concurrent writer landed while the merge waited. Sameness was
decided by comparing canonical paths, which sees through symlinks and
`..` but not through a hard link: one file under two real paths, each
canonicalizing to itself.

Compare the file system's own identity instead — `(device, inode)` on
Unix — falling back to the resolved path when no file stands behind the
name yet, which is the usual case for the output. Windows keeps the path
comparison alone, having no stable equivalent.
Authentication recomputed each fetched transaction's hash from its
encoding and compared it against the requested hash, deliberately
ignoring the response's own `hash` field. But an RPC deserialization
seeds the envelope's cached hash from that field, and that cached value
is what every later `tx_hash()` read returns: the name a dumped fixture
is filed and keyed under, and the identity a result line carries.

An honest payload paired with a `hash` field naming another transaction
therefore passed, and the batch dump wrote the target's execution to
`<other hash>.json` — replacing an unrelated target's fixture under
`--overwrite` — while the result line reported the real hash.

Make the served field a third invariant of `authenticate_transaction`.
Every fetched transaction is admitted through that one seam, so
`tx_hash()` on an authenticated transaction is a verified value and no
consumer has to recompute it.
The state dump serialized the raw post-execution EvmState without reading
the accounts' status, so a contract created and destroyed in the same
transaction was printed as a live account, complete with the code and
storage the commit is about to erase. The prestate diff tracer, reading
the same execution, already omitted it from its post side.

Such an account is now written as `{"selfdestructed": true}` and nothing
else, and a prestate load treats an entry carrying that marker as an
address absent from the file, so a dump still round-trips through
`--prestate` and reproduces the world the transaction committed.
Accounts that survive the transaction serialize byte for byte as before.
Describe how a state dump reports an address erased by SELFDESTRUCT and
how a prestate load reads that entry back.
An address the run only observed as nonexistent — read, or touched
without ever gaining balance, nonce, or code — was printed as an
existing empty account. No such account exists on either side of the
commit, and a round-tripped prestate would answer EXTCODEHASH with the
empty-code hash where the chain answers zero. Such addresses are now
omitted; the self-destruct marker outranks the omission, so an account
created and destroyed in one transaction still reports its tombstone.
The two dump fixtures lose exactly the zero-fee beneficiary ghosts.
@vincent-k2026

Copy link
Copy Markdown
Contributor

No blocking findings from this pass. 34k lines / 137 files, all under bin/mega-evme/ and docs/mega-evme/, and for a verification tool the fail-loud discipline holds up.

Things I verified myself rather than taking from the PR body:

Claim Result
Nothing outside bin/mega-evme/ + docs/mega-evme/ ✅ all 137 paths, filter comes back empty
--rpc.cache-max-entries 0 has an actual ceiling provider/mod.rs:570, EFFECTIVELY_UNLIMITED_CACHE_ENTRIES = 1_048_576, with the derivation in the constant's own comment (~200 entries per mainnet block → ~2,600 blocks)
Docs match the code tx.md:207 and state-management.md:326 both say "capped at 1,048,576 entries" — 0 is not documented as truly unlimited
--rpc.request-timeout covers every HTTP request ✅ exactly one reqwest::Client::builder() in the tool (provider/mod.rs:457), with .timeout() and .connect_timeout() both set

Also checked the two headline fixes for red-green discrimination and the mechanisms hold up: reverting the EIP-7702 fresh-authority None mapping makes both scenarios materialize the authority as existing, so the +12_500 assertion fails; reverting the served-hash authentication lets the batch dump write a fixture under <LYING_TX_HASH>.json, so the file-existence assertion fails. I reasoned these from the code rather than executing them — if you want it as merge evidence, a revert-then-red run pasted here would close it.

One design decision worth calling out as right: when --rpc.cache-size became --rpc.cache-max-entries, 0 inverted meaning (disable → effectively unlimited), and you chose to reject the old flag rather than alias it so a script fails loudly instead of silently doing the opposite. That's the correct handling of an inverted flag.

Non-blocking, process

This PR accumulated four rounds of "review-fix batch" in the body (22 commits, then 11, then 4 fixes) across 137 files. At this size it has stopped being a reviewable unit. For the next comparable piece of work, four PRs — batch replay / receipt verification / concurrent-safe caching / exit-code taxonomy — would each be independently reviewable; the coupling between them is low.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api:compatible Only new interface or API is introduced. Existing software is compatible. comp:doc Changes in the documentation comp:mega-evme Changes to the `mega-evme` tool dependencies Pull requests that update a dependency file rust Pull requests that update rust code spec:unchanged No change to any `mega-evm`'s behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants