fix(anvil): stop loadState from corrupting canonical blocks on a fork - #16642
Open
gomesalexandre wants to merge 1 commit into
Open
fix(anvil): stop loadState from corrupting canonical blocks on a fork#16642gomesalexandre wants to merge 1 commit into
gomesalexandre wants to merge 1 commit into
Conversation
anvil_loadState on a forked node let dumped blocks at or below the fork's head number silently claim their number's slot in the number->hash map, even though only the head entry got explicitly corrected afterward. Every other dumped block's foreign hash stayed in place, producing a chain with mismatched parentHash links between adjacent blocks. BlockchainStorage::load_blocks/load_transactions now take an optional fork_boundary: when the loaded head was resolved from the fork rather than the dump, dumped blocks/transactions numbered at or below that boundary no longer claim the canonical slot for that number - the fork's own real chain does. Block data is still stored by hash so hash-based lookups keep working; only the number->hash and blockNumber->tx associations are skipped for the fork's own number range. The non-fork load path is untouched (fork_boundary is None there).
gomesalexandre
marked this pull request as ready for review
September 4, 2026 20:40
gomesalexandre
requested review from
0xrusowsky,
DaniPopes,
figtracer,
grandizzy,
mablr,
mattsse and
stevencartavia
as code owners
September 4, 2026 20:40
Contributor
✅ Changelog foundThe deterministic check will validate the changed entry. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
anvil_loadStateon a forked node (--fork-url+--load-state, a documented flag combination) silently replaces the fork's own canonical blocks below its head with unrelated blocks from the loaded state dump, corrupting the chain.Root cause
Two spots in
crates/anvil/src/eth/backend/:mem/storage.rs'sBlockchainStorage::insert_blockunconditionally writes bothself.blocks[hash]andself.hashes[number], andload_blockscalls it in a loop for every dumped block.mem/mod.rs'sBackend::load_statecallsstorage.load_blocks(blocks)for the whole dump, then afterward corrects only one entry inhashes- the selected head. Every other dumped block at a number ≤ the fork's head keeps its foreign (dump-source) hash.The read path (
block_by_number→mined_block_by_number) checks local storage first, before ever asking the fork RPC, so the foreign block wins for every read at that number. The chain becomes provably discontinuous:block(n).parentHash != block(n-1).hash.serialized_blockssorts dumped blocks canonical-last specifically so the lastinsert_blockcall wins thehashesmap entry (pinned by its own test,serialized_blocks_puts_canonical_block_last) - that machinery exists to makehashes[n]land on the dump's own canonical block when there's no fork involved. Nobody re-points it for the case where the head instead comes from the fork.The existing
test_fork_load_statetest forks, dumps, and reloads into a second fork, but only ever assertsblock_numberand balances - it never asks for an intermediate block number, so it exercises exactly this path without ever catching the corruption.Fix
BlockchainStorage::load_blocks/load_transactionsnow take anOption<u64>fork_boundary. It's set only whenBackend::load_stateresolved the loaded head from the fork rather than from the dump itself. When set, dumped blocks/transactions numbered at or below that boundary no longer claim the canonicalnumber -> hash(or transactionblockNumber) slot for that number - the fork's own real chain keeps it. Block data is still stored by hash (so hash-based lookups keep working for a retained-but-noncanonical dumped block), only the canonical number-keyed associations are skipped.The non-fork load path is untouched:
fork_boundaryisNonethere, so behavior is byte-for-bit identical to before this change.Why drop transactions rather than just blocks
load_transactionsgets the samefork_boundarytreatment: a dumped transaction whoseblockNumberis ≤ the boundary is dropped entirely rather than inserted, since keeping it would leteth_getTransactionByHashreport ablockNumberwhose canonical block is a different (foreign) block after this fix.Known limitation (disclosed, not fixed here)
A dumped block at/below the boundary is still retained by hash in
storage.blocks(foreth_getBlockByHash(hash, false)lookups), but since its transactions are now dropped fromstorage.transactions,eth_getBlockByHash(hash, full=true)andeth_getTransactionByBlockHashAndIndexcan't fully materialize it and will return null/fall through to the fork provider, which won't have it either. This only affects deliberately querying a retained-but-noncanonical dump block by its own (foreign) hash, which shouldn't come up in normal use. Flagged by an adversarial Codex review; not fixed in this PR to avoid adding a second, more invasive noncanonical-block-reconstruction path for an edge case with no live-observed impact.Testing
test_fork_load_state_preserves_fork_blocks_below_head(crates/anvil/tests/it/state.rs): two independent local anvils (origin, what the fork forks from;dump_source, an unrelated node) each mine 5 blocks with distinct transaction values (so their blocks can't coincidentally collide), fork a third node fromoriginat block 5, loaddump_source's dump (best_block_number == 5, exercising the<=boundary exactly at the fork head, not just strictly below it), then assert every intermediate block 1..=5 still has the fork's real hash and correctparentHashchain, that the fork's own transactions are still discoverable by hash, and that the dump's foreign transactions are not.state::test module: 30/30 passing, including the existingtest_fork_load_state,test_fork_load_state_keeps_number_opcode_in_sync, and the deliberately-testedbest_number == fork_numberboundary case intest_load_state_equal_height_fork_keeps_fork_anchor(unaffected).cargo check -p anvilclean.cargo fmt --check -p anvilclean (stable toolchain; some nightly-only rustfmt options unavailable in this environment, same caveat as prior PRs in this series).cargo clippy -p anvil --all-targets -- -D warningsfails onactive_monad_context_for_mined_block/active_monad_context_before_mined_transactioninmem/mod.rs(unrelatedmissing_const_for_fnlint) - confirmed pre-existing on a cleanmastercheckout with the same command, not introduced by this diff.<=boundary predicate correct, the explicit head-restoration ordering correct, and skipping thegenesis_hashupdate on the fork path correct (a dumped block 0 could otherwise overwrite genesis_hash with a foreign value). Two Low findings addressed (test now covers the exact boundary and transaction filtering, per above); one Low finding (the noncanonical-block-by-hash limitation above) disclosed rather than fixed.receipts
No runtime UI - this is a data-integrity fix in anvil's in-memory chain storage; the regression test IS the receipt (it live-drives real local anvil nodes, no external RPC dependency).