Summary
Erigon has a recurring pattern in which a code path accepts an exact block hash, resolves it to a height, and then discards the hash. Everything after that reads by height, so a request naming a non-canonical block can be answered with the canonical block at the same height.
Issue #2226 described this class for EIP-1898 state selectors, and PR #19257 fixed one instance of it on eth_getBlockReceipts. This report covers two things:
- The same class still affects a number of unguarded JSON-RPC endpoints, listed in section A. These need only a reorg. Each needs its own guard, the way
eth_getBlockReceipts got one.
- The shared
BlockReader itself performs the substitution once the height has been frozen into snapshot files. This is section B, and it is the part we believe is new. Because it sits below every endpoint, it reaches the devp2p handlers and the Engine API, which the endpoint-level guard strategy cannot reach by construction.
We reported this privately first and were asked to open it publicly instead.
What this is, and what it is not
This is a correctness defect report, not a vulnerability claim. We found no consensus impact, no funds at risk, and no attacker-controlled precondition, and we say so up front so this can be triaged at the right priority rather than after ten sections. The section "What we did not establish" states the limits plainly, including one amplification we specifically went looking for and could not find.
Affected revisions
- Audited and reproduced at
993149925e454e7f15113a11e78f48af4f23a7a9.
- Reproduced again at
af146efbc6 (erigon version 3.7.0-dev-af146efb).
- The code paths are unchanged on current
main at ceab9efc4beea3ab467a6243bf0854ef460a7557 (2026-08-24). All line numbers below are from that revision, re-read there so they do not go stale on you. The observed values quoted below come from runs on the two earlier commits; we did not re-run the reproduction on main itself.
A. Endpoint-level hash discard (no freezing required)
These paths can be reached while the orphan block is still in the database. A plain reorg is enough. The endpoint resolves the hash to a height itself and never consults the hash again.
Runtime-confirmed with fixtures:
| Endpoint |
Code path |
Observed |
eth_getLogs (EIP-234 blockHash) |
eth_receipts.go:107-118 (resolveLogsRange) turns the hash into a begin/end range and keeps nothing else; getLogsV3 (:318) then reads canonical tx-number history, header, transaction and receipt at that height |
after a reorg the identical filter returned a log carrying the canonical sibling's blockHash and a transaction hash the requested block never contained |
erigon_getLogs |
erigon_receipts.go:127-128 resolves the hash through HeaderByHash and keeps only header.Number; :280 reloads by HeaderByNumber(blockNum) and emits header.Hash() |
a request for orphan hash 0xdcbb…06d returned a log whose blockHash was canonical sibling 0x18db…591 |
debug_getModifiedAccountsByHash |
debug_api.go:517 converts both hashes to heights via headerNumberByHash (:529) and queries canonical tx-number history |
the identical orphan-hash request returned branch A's account before the reorg and branch B's after it, then exactly matched the canonical-sibling request |
eth_simulateV1 |
eth_simulation.go:104 loads the base header by hash, but the state readers are anchored by height on the canonical base-parent, and the commitment reader resolves through canonicalReader.CanonicalHash(ctx, tx, blockNum) (:868) |
one response with mixed branch provenance, quoted below |
debug_getRawReceipts |
orphan transactions regenerated against canonical same-height state |
receipts satisfying neither the requested block's receipt root nor the canonical sibling's |
debug_getRawReceipts is the already-known #17351 mechanism on an endpoint that did not receive the #19257 guard. We list it for completeness, not as something new. It can reuse the guard already on eth_getBlockReceipts.
Two responses are worth quoting because the wrongness is self-evident from the response alone.
debug_getRawReceipts, where the returned receipts match no block's commitment:
orphan header's receipt root commitment 0xfa20d74e2031709e91b60c71b5c3bf9ccbbd5c2435eb932ea1a05017f5e7b8b3
root derived from the receipts returned for it 0xb0c757a6d58893c8db5b0ba3f7aa4420fef0b313c5d91e5512264f4bd315bc98
canonical sibling's receipt root 0x056b23fbba480696b65fe5a59b8f2148a1299103c4f57df839233af2cf4ca2d2
cumulative gas returned / canonical 43106 / 21000
eth_simulateV1, whose base-header lineage comes from the requested orphan block while its execution state is selected from canonical historical state:
simulated block's parentHash, after reorg 0x77c6f0e0…f871 (the requested orphan block)
simulated block's stateRoot, after reorg 0xf5f3dfe202b42aa67d616033e595b763e69d2c06b25f3845c7cfeb5267efe6f8
canonical sibling's simulated stateRoot 0xf5f3dfe202b42aa67d616033e595b763e69d2c06b25f3845c7cfeb5267efe6f8
Same shape, code-path confirmed but without a runtime fixture, so please treat these as lower-confidence: erigon_getLatestLogs, overlay_getLogs, the GraphQL log query (which delegates to eth_getLogs), and the hash inputs accepted by trace_call, trace_callMany and the trace_filter bounds. debug_executionWitness builds mixed-branch output in the same way, though a one-block fork can share the parent state, so it needs a deeper reorg to show.
B. The shared reader substitutes once the height is frozen
This is the part that is not covered by any endpoint guard.
// db/snapshotsync/freezeblocks/block_reader.go:801
func (r *BlockReader) blockWithSenders(ctx context.Context, tx kv.Getter, hash common.Hash,
blockHeight uint64, forceCanonical bool) (...) {
maxBlockNumInFiles := r.sn.BlocksAvailable()
if blockHeight == 0 || maxBlockNumInFiles == 0 || blockHeight > maxBlockNumInFiles {
if forceCanonical { // :816 — guard lives HERE only
canonicalHash, ok, err := r.CanonicalHash(ctx, tx, blockHeight)
...
}
block, senders, err = rawdb.ReadBlockWithSenders(tx, hash, blockHeight) // by exact hash
return block, senders, nil
}
// snapshot branch: no canonicality guard, no comparison against `hash`
seg, ok, release := r.viewSingleFile(tx, snaptype2.Headers, blockHeight)
h, buf, err := r.headerFromSnapshot(blockHeight, seg, buf)
...
hash = h.Hash() // :866 — caller identity discarded
Header (:626) has the same shape. It tries rawdb.ReadHeader(tx, hash, blockHeight) first and, on a miss, falls through to headerFromSnapshot(blockHeight, ...) with no comparison against the requested hash. Body, BodyRlp, BodyWithTransactions, BlockWithSenders and ReadAncestor all sit on this.
HeaderByHash (:568) is the counter-example. It compares the decoded snapshot header against the requested hash and does not substitute. Our fixture uses it as a control.
Two consequences that surprised us:
- Freezing alone is enough for
BlockWithSenders. The snapshot branch is selected from the height, so the database is never consulted. A control in the same transaction shows the requested block is still in the database and is still ignored. Pruning is needed only for the Header path.
forceCanonical is unreachable for frozen heights, because the guard sits inside the database branch.
The reader in isolation:
requested orphan hash 0xe3639fe6…4787
canonical hash at that height 0x130dc6f9…a5b7
BlockWithSenders(orphan hash, H), no pruning yet returned 0x130dc6f9…a5b7 with the canonical body
Header(orphan hash, H), after rawdb.PruneBlocks returned 0x130dc6f9…a5b7
HeaderByHash(orphan hash) no substitution (control)
Why this is not covered by the existing fix
The root-cause comment added by #19257, now in rpc/jsonrpc/receipt_root_validation_test.go, enumerates seven steps. Step 2 reads:
// 2. BlockWithSenders(hash, number) still returns the full block
That is true while the height is in the database, and the whole analysis rests on it. The reader hands back the block you asked for, and the defect is that its transactions are then re-executed against canonical state.
It stops being true once the height is frozen. The snapshot branch reads by height and overwrites hash, so the reader returns the canonical sibling instead. Three consequences:
- The mechanism is selection, not execution. It needs no re-execution, no state reader and no receipts.
- It therefore reaches devp2p and Engine API handlers, which never touch receipt generation at all. A per-endpoint guard cannot reach those.
- Its precondition is freezing, not merely a reorg, which is why it does not appear in reorg tests that stay near the tip.
devp2p GetBlockBodies
p2p/protocols/eth/handlers.go:148-170:
for lookups, hash := range query { // peer-supplied hashes
number, _ := blockReader.HeaderNumber(context.Background(), db, hash) // :158
if number == nil { continue }
bodyRLP, _ := blockReader.BodyRlp(context.Background(), db, hash, *number) // :162 by height
bodies = append(bodies, bodyRLP)
}
caps/eth.md specifies the reply as "The items in the list contain the body data of the requested blocks". We are not claiming an ordering violation, since BlockBodies carries no ordering requirement. The violation is narrower: the reply contains the body of a block that was not requested, and a BlockBodies item carries no hash, so nothing in the reply identifies which block it describes.
The counter-argument, stated by us rather than left for you to find. The same document says, under Chain Synchronization, "Retrieved block bodies must be validated against the headers". A compliant requester re-derives the transactions and withdrawals roots against the header it holds and rejects the reply. This is therefore not a poisoning primitive against compliant peers, and we do not present it as one. What it is: the node emits a body that fails its own header's commitment, so a compliant peer may treat the node as serving invalid data and drop or penalise it, and a non-compliant peer ingests the wrong body silently.
Two more devp2p handlers sit on the same primitive with peer-supplied hashes. We did not build separate reproductions, so please treat these as code-backed rather than observed:
GetReceipts — HeaderNumber(hash) then BlockWithSenders(hash, *number), so the receipts served belong to the canonical block.
GetBlockHeaders — the first lookup uses the hash-accurate HeaderByHash, but the walk then uses Header(hash, number) and ReadAncestor, which calls the same unsafe Header. The handler explicitly budgets up to 100 non-canonical ancestors, which is precisely the input class where the read returns something else.
Engine API engine_getPayloadBodiesByHashV1/V2
Our fixture runtime-confirms the affected ExecModule.GetBodiesByHashes implementation, and source tracing confirms endpoint reachability. We did not stand up the authenticated Engine JSON-RPC server and issue a real request, so this is a component-level result plus a traced call path, not an end-to-end endpoint reproduction.
Every hash on that path comes from the consensus client, and the response is positional with no hash in it. Driving GetBodiesByHashes directly for the same hash:
returned before freezing 0x056b227a…3ee3 (correct)
returned after freezing 0x2a4d3ef9…7cca (the canonical sibling's transaction)
Why the precondition is ordinary
The section B substitution needs a hash that is non-canonical, known to the node, and at a height already covered by snapshot files.
- An attacker cannot inject one.
InsertBlocks skips any block below the frozen boundary (execution/execmodule/inserters.go). Only hashes the node legitimately stored while the height was near the tip are eligible.
- But they accumulate on their own. Freezing happens at
head - MaxReorgDepth with MaxReorgDepth = 96, rounded down to 1000-block segments. Every ordinary tip reorg leaves an orphan header whose height freezes a few hundred blocks later, and the hash -> height mapping is permanent. PruneBlocks does not remove it, and pruneCanonicalMarkers removes only the hash named in each canonical marker. Our fixture confirms the mapping survives a real rawdb.PruneBlocks.
So a long-running node accumulates hashes that answer incorrectly from then on, indefinitely. The set is public, since mainnet orphans are recorded by every block explorer, but it is not attacker-chosen.
Reproduction
The test below is self-contained. It uses only your own harness, needs no patching and no external tooling. Save it as db/snapshotsync/freezeblocks/validation_blockreader_frozen_hash_test.go and run:
go test ./db/snapshotsync/freezeblocks -run '^TestValidationBlockReaderFrozenSnapshotHashSubstitution$' -count=1 -v
It builds two branches that both put a transaction in block 4, makes branch B canonical, freezes blocks 0-1000 into real snapshot segments with freezeblocks.DumpBlocks, and then asks BlockReader for the orphaned block by its exact hash. Four controls run alongside the two subjects, so a passing run also proves the orphan is still addressable and still present rather than merely gone.
One note before you paste it: this was written and run at 993149925e45, where NewBlockReader took two parameters. On current main it takes one, so drop the trailing nil in the freezeblocks.NewBlockReader(snapshots, nil) call. We checked the other harness APIs it uses against main and they are unchanged.
validation_blockreader_frozen_hash_test.go
package freezeblocks_test
import (
"math"
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/stretchr/testify/require"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/crypto"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/rawdb"
"github.com/erigontech/erigon/db/snapcfg"
"github.com/erigontech/erigon/db/snapshotsync/freezeblocks"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/chain/networkname"
"github.com/erigontech/erigon/execution/execmodule/execmoduletester"
"github.com/erigontech/erigon/execution/tests/blockgen"
"github.com/erigontech/erigon/execution/types"
)
// Once a height is covered by snapshot files, BlockReader resolves an exact block
// hash to a height and then returns whatever object the snapshot holds at that
// height, overwriting the requested hash instead of reporting a mismatch.
//
// Note the precondition is weaker than a prune: the orphan header is still in the
// database here. BlockReader chooses the snapshot path purely from the height, so
// the database copy is never consulted.
func TestValidationBlockReaderFrozenSnapshotHashSubstitution(t *testing.T) {
key, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
require.NoError(t, err)
sender := crypto.PubkeyToAddress(key.PublicKey)
logger := log.New()
genesis := &types.Genesis{
Config: chain.TestChainBerlinConfig,
GasLimit: 10_000_000,
Difficulty: uint256.NewInt(1),
Alloc: types.GenesisAlloc{
sender: {Balance: new(big.Int).Exp(big.NewInt(10), big.NewInt(24), nil)},
},
}
m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(genesis), execmoduletester.WithKey(key))
// Both branches put one transaction in block 4, with distinct calldata, so the
// two same-height blocks have different hashes and different bodies.
makeBranch := func(length int, calldata []byte) *blockgen.ChainPack {
pack, genErr := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, length, func(i int, b *blockgen.BlockGen) {
if i != 3 {
return
}
tx, signErr := types.SignTx(
types.NewTransaction(b.TxNonce(sender), common.HexToAddress("0xdeadbeef"),
uint256.NewInt(1), 100_000, uint256.NewInt(1), calldata),
*types.LatestSignerForChainID(m.ChainConfig.ChainID),
key,
)
require.NoError(t, signErr)
b.AddTx(tx)
})
require.NoError(t, genErr)
return pack
}
// Branch B must be long enough for a full 1000-block snapshot segment.
branchA := makeBranch(5, nil)
branchB := makeBranch(1001, []byte{0x01})
require.NoError(t, m.InsertChain(branchA))
require.NoError(t, m.InsertChain(branchB))
orphanBlock := branchA.Blocks[3]
canonicalBlock := branchB.Blocks[3]
require.Equal(t, uint64(4), orphanBlock.NumberU64())
require.Equal(t, orphanBlock.NumberU64(), canonicalBlock.NumberU64())
require.NotEqual(t, orphanBlock.Hash(), canonicalBlock.Hash())
// ---- freeze blocks 0..1000 into snapshot files (canonical data only) ----
snCfg, _ := snapcfg.KnownCfg(networkname.Mainnet)
snCfg.ExpectBlocks = math.MaxUint64
require.NoError(t, freezeblocks.DumpBlocks(m.Ctx, 0, 1000, m.ChainConfig, m.Dirs.Tmp, m.Dirs.Snap,
m.DB, 1, log.LvlInfo, logger, m.BlockReader, snCfg, nil))
// Re-open the block files the database itself exposes, the way a node does after
// retirement: transactions pin that view, and the reader shares it.
snapshots := m.DB.(freezeblocks.HasBlockFiles).DebugBlockFiles()
require.NoError(t, snapshots.OpenFolder())
require.GreaterOrEqual(t, snapshots.BlocksAvailable(), uint64(4),
"the reorg height must be covered by snapshot files for this path to be exercised")
blockReader := freezeblocks.NewBlockReader(snapshots, nil) // on main: NewBlockReader(snapshots)
tx, err := m.DB.BeginRo(m.Ctx)
require.NoError(t, err)
defer tx.Rollback()
// ---- control 1: the orphan header is still in the database under its own hash ----
number, err := blockReader.HeaderNumber(m.Ctx, tx, orphanBlock.Hash())
require.NoError(t, err)
require.NotNil(t, number, "the orphan hash still resolves to a height")
require.Equal(t, uint64(4), *number)
// ---- control 2: the canonical hash at that height is branch B's ----
canonicalHash, ok, err := blockReader.CanonicalHash(m.Ctx, tx, 4)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, canonicalBlock.Hash(), canonicalHash)
// ---- control 3: the raw orphan block is still in the database at this point ----
dbHeader, err := blockReader.Header(m.Ctx, tx, orphanBlock.Hash(), 4)
require.NoError(t, err)
require.NotNil(t, dbHeader)
require.Equal(t, orphanBlock.Hash(), dbHeader.Hash(),
"before pruning, Header still finds the exact requested header in the database")
// ---- subject A: BlockWithSenders substitutes on freezing alone ----
// Its snapshot branch is selected purely from the height, so the database copy
// that control 3 just proved to exist is never consulted.
gotBlock, _, err := blockReader.BlockWithSenders(m.Ctx, tx, orphanBlock.Hash(), 4)
require.NoError(t, err)
require.NotNil(t, gotBlock, "the lookup succeeds instead of reporting a mismatch")
require.Equal(t, canonicalBlock.Hash(), gotBlock.Hash(),
"BlockWithSenders(hash, height) returned the canonical block at that height")
require.NotEqual(t, orphanBlock.Hash(), gotBlock.Hash())
require.Equal(t, canonicalBlock.Transactions()[0].Hash(), gotBlock.Transactions()[0].Hash(),
"the returned body is the canonical sibling's, not the requested block's")
// ---- subject B: after the production prune, Header substitutes as well ----
tx.Rollback()
rwTx, err := m.DB.BeginRw(m.Ctx)
require.NoError(t, err)
_, err = rawdb.PruneBlocks(rwTx, 900, 10_000)
require.NoError(t, err)
require.NoError(t, rwTx.Commit())
tx2, err := m.DB.BeginRo(m.Ctx)
require.NoError(t, err)
defer tx2.Rollback()
// The hash -> height mapping survives the prune, which is what keeps the
// orphan hash addressable at all.
numberAfterPrune, err := blockReader.HeaderNumber(m.Ctx, tx2, orphanBlock.Hash())
require.NoError(t, err)
require.NotNil(t, numberAfterPrune, "the orphan hash still resolves to a height after pruning")
require.Equal(t, uint64(4), *numberAfterPrune)
gotHeader, err := blockReader.Header(m.Ctx, tx2, orphanBlock.Hash(), *numberAfterPrune)
require.NoError(t, err)
require.NotNil(t, gotHeader, "the lookup succeeds instead of reporting a mismatch")
require.Equal(t, canonicalBlock.Hash(), gotHeader.Hash(),
"Header(hash, height) returned the canonical block at that height")
require.NotEqual(t, orphanBlock.Hash(), gotHeader.Hash())
// ---- control 4: the exact-hash reader that does verify identity does not substitute ----
byHash, err := blockReader.HeaderByHash(m.Ctx, tx2, orphanBlock.Hash())
require.NoError(t, err)
if byHash != nil {
require.Equal(t, orphanBlock.Hash(), byHash.Hash(),
"HeaderByHash compares the decoded header against the requested hash")
}
t.Logf("VALIDATION_RESULT requested_hash=%s resolved_height=%d returned_block_hash_before_prune=%s returned_header_hash_after_prune=%s canonical_hash_same_height=%s blocks_available=%d headerbyhash_substituted=%t",
orphanBlock.Hash().Hex(), *number, gotBlock.Hash().Hex(), gotHeader.Hash().Hex(),
canonicalBlock.Hash().Hex(), snapshots.BlocksAvailable(),
byHash != nil && byHash.Hash() != orphanBlock.Hash())
}
Our run:
=== RUN TestValidationBlockReaderFrozenSnapshotHashSubstitution
validation_blockreader_frozen_hash_test.go:169: VALIDATION_RESULT
requested_hash=0xe3639fe6e6677925c022b8ef5757fe7b9ca10d31d278991e823cd6551c0b4787
resolved_height=4
returned_block_hash_before_prune=0x130dc6f99d7b0c3ee0df11353650c28d0bc1ca7becad7a2c3bff126ce5faa5b7
returned_header_hash_after_prune=0x130dc6f99d7b0c3ee0df11353650c28d0bc1ca7becad7a2c3bff126ce5faa5b7
canonical_hash_same_height=0x130dc6f99d7b0c3ee0df11353650c28d0bc1ca7becad7a2c3bff126ce5faa5b7
blocks_available=999
headerbyhash_substituted=false
--- PASS: TestValidationBlockReaderFrozenSnapshotHashSubstitution (0.16s)
ok github.com/erigontech/erigon/db/snapshotsync/freezeblocks 0.679s
The end-to-end runs behind the devp2p and Engine API claims
Those two surfaces were confirmed on a real node rather than in a unit test, so we describe the setup here rather than ask you to run our harness. A private PoS devnet runs an unmodified Erigon, block production is driven over the authenticated Engine API, and the node's own retirement path freezes blocks 0-1000 into snapshot files. Nothing in Erigon is patched, stubbed or mocked. The only custom component is the consensus-layer driver, which exists because a real CL will not produce a stored-but-never-canonical block on demand: it builds two payloads on one parent and names only one as head, which is what an ordinary reorg leaves behind. The devp2p client then joins as a real peer, with RLPx handshake, p2p Hello and eth Status, and sends GetBlockBodies for the orphan hash once before freezing and once after:
requested (orphan) hash: 0xc0de6b977d9b0aa036f5e86d4b2be3041444712105fc45c8276e49ab3f07f884
BEFORE freezing, GetBlockBodies returned withdrawals: [{"address":"0x…00A1","amount":111,"index":0}]
AFTER freezing, GetBlockBodies returned withdrawals: [{"address":"0x…00b2","amount":222,"index":0}]
The two same-height blocks are given distinct withdrawals precisely because a BlockBodies reply carries no header to compare against. Three details silently prevent reproduction if missed: --prune.mode=full is required, or the node builds state files but never freezes block segments; --externalcl is required, because dev mode otherwise forces an internal CL that competes for the head; and the chain must pass ~1100 blocks, since freezing happens at head - MaxReorgDepth (96) rounded down to 1000-block segments.
We are glad to attach that harness, along with unit-level fixtures for the section A endpoints, or to open a PR with them. Just say which you prefer.
What we did not establish
This section bounds the report. We would rather hand you the limits than have you find them.
- No consensus impact. Fork choice preserves exact
(hash, height) identity. It starts from HeaderByHash, walks the selected branch through exact parent hashes, and writes each selected hash as canonical before the height-driven execution step.
- No funds at risk. The impact is wrong data delivered to peers, to off-chain consumers, and to the consensus client.
- No attack scenario. We could not construct one, and we tried. An attacker cannot choose which hashes are eligible, and to trigger a substitution someone must request a non-canonical hash at an already-frozen height, which for an attacker means requesting it themselves. Honest requesters of that input class do exist, such as block explorers rendering reorged blocks, reorg monitors and indexers reading by hash, but they sit on JSON-RPC, where the wrong answer harms the consumer rather than the network.
- We did not construct a case in which validation accepts something it should reject. The substitution is a no-op whenever the requested hash is the canonical hash at that height, and most internal callers pass canonical-derived hashes, so they are latent rather than active.
The primitive is reachable from validation code with externally supplied hashes, which is the part we would still want you to look at. ForkValidator.ValidatePayload enters its walk only when IsCanonical has just returned false, so every read inside that loop uses a hash the code has just established is non-canonical. Also on the primitive: ValidateChain, unwindIfNeeded, purgeBadChain, the EVM BLOCKHASH resolver, and three rules.ChainReader shims. For any of these to substitute, a fork would have to be deeper than the freeze lag, and engine_server.go already returns ACCEPTED for payloads at least maxReorgDepth from the head. We checked BLOCKHASH specifically: the frozen boundary can sit as close as head-97, which does overlap the 256-block window, but reaching it needs a fork at least 97 deep that engine_server.go already declines, and post-EIP-2935 it is a system contract anyway.
One amplification we went looking for and did not find
Step 6 of the #19257 comment notes that wrong receipts are cached and "served to all subsequent callers". If a request naming an orphan hash could leave an entry that a later canonical request reads, this would stop being self-inflicted and would have a real victim. That looked plausible, since receiptCache is keyed by txNum rather than by hash, and an orphan block at height H is re-executed over the canonical txNums at H, so the keys do collide.
It is safe. Every read compares the cached receipt's BlockHash against the requested one and evicts on mismatch:
// rpc/jsonrpc/receipts/receipts_generator.go
if receipt, ok := g.receiptCache.Get(txNum); ok {
if receipt.BlockHash == blockHash && // elegant way to handle reorgs
calculatePostState == (len(receipt.PostState) != 0) {
return receipt, nil
}
g.receiptCache.Remove(txNum)
}
TryGetCachedReceipt carries the same check, and the block-level receiptsCache is keyed by block hash. We mention this because those two comparisons are load-bearing correctness properties, not reorg housekeeping. Removing either, or adding a cache read that skips the check, would convert this report's defect into a genuine cross-request poisoning bug. They are currently documented as "elegant way to handle reorgs". A comment saying what they actually prevent, plus a test pinning it, would be worth having.
Paths that already fail closed
Included so this is not read as a blanket claim, and because one of them is the fix we would suggest.
The BAL regenerator is the shape we think the fix should take. It compares the requested hash against CanonicalHash(blockNum) and returns "unavailable" on mismatch (execution/bal/regenerator.go). eth/71 GetBlockAccessLists is safe because of it.
Also correct at these revisions: the backward block downloader, the wit/0 witness handlers, engine_getPayloadBodiesByRangeV1/V2 (which resolves each height through canonicalHash), engine_getBlobsV1/V2/V3, the Engine API quick-status paths, and the large set of JSON-RPC endpoints that resolve hash selectors canonically, including eth_call, eth_estimateGas, eth_createAccessList, the state getters, eth_getProof, debug_traceCall, debug_traceBlockByHash, debug_storageRangeAt, debug_accountRange, trace_replayBlockTransactions, eth_getBlockReceipts, erigon_getBlockReceiptsByBlockHash, erigon_getLogsByHash, eth_getTransactionByBlockHashAndIndex and eth_getUncleByBlockHashAndIndex.
Suggested fix
- Harden the reader centrally. In
Header, Body and blockWithSenders, if the caller supplied a non-zero hash and the snapshot fallback produces an object with a different hash, return nothing or an explicit identity-mismatch error instead of overwriting the hash. Move the forceCanonical check so it also covers the snapshot branch. This alone fixes the devp2p handlers, the Engine API path and several RPC endpoints at once, and it is the layer a per-endpoint guard cannot reach.
- Where an endpoint cannot serve non-canonical data, resolve hash selectors through
GetCanonicalBlockNumber and return the existing non-canonical-hash error. debug_getRawReceipts can reuse the guard already on eth_getBlockReceipts.
- Where an endpoint intentionally supports non-canonical blocks, carry both hash and height through every downstream read instead of replacing the selector with a bare height. This applies to the log range resolution and to the simulator's state-reader selection.
- Correct step 2 of the
TestReceiptRootValidationAfterReorg comment, or scope it to the database case. As written it records an assumption about the reader that does not hold for frozen heights, and it is the most likely place for the next reader to acquire the wrong model.
- Add a freeze-and-prune test for the reader path, and a note on the two
receipt.BlockHash == blockHash comparisons explaining what they prevent.
We are glad to help with the fix or the tests, and can open a PR for the central reader change if that is useful.
Summary
Erigon has a recurring pattern in which a code path accepts an exact block hash, resolves it to a height, and then discards the hash. Everything after that reads by height, so a request naming a non-canonical block can be answered with the canonical block at the same height.
Issue #2226 described this class for EIP-1898 state selectors, and PR #19257 fixed one instance of it on
eth_getBlockReceipts. This report covers two things:eth_getBlockReceiptsgot one.BlockReaderitself performs the substitution once the height has been frozen into snapshot files. This is section B, and it is the part we believe is new. Because it sits below every endpoint, it reaches the devp2p handlers and the Engine API, which the endpoint-level guard strategy cannot reach by construction.We reported this privately first and were asked to open it publicly instead.
What this is, and what it is not
This is a correctness defect report, not a vulnerability claim. We found no consensus impact, no funds at risk, and no attacker-controlled precondition, and we say so up front so this can be triaged at the right priority rather than after ten sections. The section "What we did not establish" states the limits plainly, including one amplification we specifically went looking for and could not find.
Affected revisions
993149925e454e7f15113a11e78f48af4f23a7a9.af146efbc6(erigon version 3.7.0-dev-af146efb).mainatceab9efc4beea3ab467a6243bf0854ef460a7557(2026-08-24). All line numbers below are from that revision, re-read there so they do not go stale on you. The observed values quoted below come from runs on the two earlier commits; we did not re-run the reproduction onmainitself.A. Endpoint-level hash discard (no freezing required)
These paths can be reached while the orphan block is still in the database. A plain reorg is enough. The endpoint resolves the hash to a height itself and never consults the hash again.
Runtime-confirmed with fixtures:
eth_getLogs(EIP-234blockHash)eth_receipts.go:107-118(resolveLogsRange) turns the hash into a begin/end range and keeps nothing else;getLogsV3(:318) then reads canonical tx-number history, header, transaction and receipt at that heightblockHashand a transaction hash the requested block never containederigon_getLogserigon_receipts.go:127-128resolves the hash throughHeaderByHashand keeps onlyheader.Number;:280reloads byHeaderByNumber(blockNum)and emitsheader.Hash()0xdcbb…06dreturned a log whoseblockHashwas canonical sibling0x18db…591debug_getModifiedAccountsByHashdebug_api.go:517converts both hashes to heights viaheaderNumberByHash(:529) and queries canonical tx-number historyeth_simulateV1eth_simulation.go:104loads the base header by hash, but the state readers are anchored by height on the canonical base-parent, and the commitment reader resolves throughcanonicalReader.CanonicalHash(ctx, tx, blockNum)(:868)debug_getRawReceiptsdebug_getRawReceiptsis the already-known #17351 mechanism on an endpoint that did not receive the #19257 guard. We list it for completeness, not as something new. It can reuse the guard already oneth_getBlockReceipts.Two responses are worth quoting because the wrongness is self-evident from the response alone.
debug_getRawReceipts, where the returned receipts match no block's commitment:eth_simulateV1, whose base-header lineage comes from the requested orphan block while its execution state is selected from canonical historical state:Same shape, code-path confirmed but without a runtime fixture, so please treat these as lower-confidence:
erigon_getLatestLogs,overlay_getLogs, the GraphQL log query (which delegates toeth_getLogs), and the hash inputs accepted bytrace_call,trace_callManyand thetrace_filterbounds.debug_executionWitnessbuilds mixed-branch output in the same way, though a one-block fork can share the parent state, so it needs a deeper reorg to show.B. The shared reader substitutes once the height is frozen
This is the part that is not covered by any endpoint guard.
Header(:626) has the same shape. It triesrawdb.ReadHeader(tx, hash, blockHeight)first and, on a miss, falls through toheaderFromSnapshot(blockHeight, ...)with no comparison against the requested hash.Body,BodyRlp,BodyWithTransactions,BlockWithSendersandReadAncestorall sit on this.HeaderByHash(:568) is the counter-example. It compares the decoded snapshot header against the requested hash and does not substitute. Our fixture uses it as a control.Two consequences that surprised us:
BlockWithSenders. The snapshot branch is selected from the height, so the database is never consulted. A control in the same transaction shows the requested block is still in the database and is still ignored. Pruning is needed only for theHeaderpath.forceCanonicalis unreachable for frozen heights, because the guard sits inside the database branch.The reader in isolation:
Why this is not covered by the existing fix
The root-cause comment added by #19257, now in
rpc/jsonrpc/receipt_root_validation_test.go, enumerates seven steps. Step 2 reads:That is true while the height is in the database, and the whole analysis rests on it. The reader hands back the block you asked for, and the defect is that its transactions are then re-executed against canonical state.
It stops being true once the height is frozen. The snapshot branch reads by height and overwrites
hash, so the reader returns the canonical sibling instead. Three consequences:devp2p
GetBlockBodiesp2p/protocols/eth/handlers.go:148-170:caps/eth.mdspecifies the reply as "The items in the list contain the body data of the requested blocks". We are not claiming an ordering violation, sinceBlockBodiescarries no ordering requirement. The violation is narrower: the reply contains the body of a block that was not requested, and aBlockBodiesitem carries no hash, so nothing in the reply identifies which block it describes.The counter-argument, stated by us rather than left for you to find. The same document says, under Chain Synchronization, "Retrieved block bodies must be validated against the headers". A compliant requester re-derives the transactions and withdrawals roots against the header it holds and rejects the reply. This is therefore not a poisoning primitive against compliant peers, and we do not present it as one. What it is: the node emits a body that fails its own header's commitment, so a compliant peer may treat the node as serving invalid data and drop or penalise it, and a non-compliant peer ingests the wrong body silently.
Two more devp2p handlers sit on the same primitive with peer-supplied hashes. We did not build separate reproductions, so please treat these as code-backed rather than observed:
GetReceipts—HeaderNumber(hash)thenBlockWithSenders(hash, *number), so the receipts served belong to the canonical block.GetBlockHeaders— the first lookup uses the hash-accurateHeaderByHash, but the walk then usesHeader(hash, number)andReadAncestor, which calls the same unsafeHeader. The handler explicitly budgets up to 100 non-canonical ancestors, which is precisely the input class where the read returns something else.Engine API
engine_getPayloadBodiesByHashV1/V2Our fixture runtime-confirms the affected
ExecModule.GetBodiesByHashesimplementation, and source tracing confirms endpoint reachability. We did not stand up the authenticated Engine JSON-RPC server and issue a real request, so this is a component-level result plus a traced call path, not an end-to-end endpoint reproduction.Every hash on that path comes from the consensus client, and the response is positional with no hash in it. Driving
GetBodiesByHashesdirectly for the same hash:Why the precondition is ordinary
The section B substitution needs a hash that is non-canonical, known to the node, and at a height already covered by snapshot files.
InsertBlocksskips any block below the frozen boundary (execution/execmodule/inserters.go). Only hashes the node legitimately stored while the height was near the tip are eligible.head - MaxReorgDepthwithMaxReorgDepth = 96, rounded down to 1000-block segments. Every ordinary tip reorg leaves an orphan header whose height freezes a few hundred blocks later, and thehash -> heightmapping is permanent.PruneBlocksdoes not remove it, andpruneCanonicalMarkersremoves only the hash named in each canonical marker. Our fixture confirms the mapping survives a realrawdb.PruneBlocks.So a long-running node accumulates hashes that answer incorrectly from then on, indefinitely. The set is public, since mainnet orphans are recorded by every block explorer, but it is not attacker-chosen.
Reproduction
The test below is self-contained. It uses only your own harness, needs no patching and no external tooling. Save it as
db/snapshotsync/freezeblocks/validation_blockreader_frozen_hash_test.goand run:It builds two branches that both put a transaction in block 4, makes branch B canonical, freezes blocks 0-1000 into real snapshot segments with
freezeblocks.DumpBlocks, and then asksBlockReaderfor the orphaned block by its exact hash. Four controls run alongside the two subjects, so a passing run also proves the orphan is still addressable and still present rather than merely gone.One note before you paste it: this was written and run at
993149925e45, whereNewBlockReadertook two parameters. On currentmainit takes one, so drop the trailingnilin thefreezeblocks.NewBlockReader(snapshots, nil)call. We checked the other harness APIs it uses againstmainand they are unchanged.validation_blockreader_frozen_hash_test.goOur run:
The end-to-end runs behind the devp2p and Engine API claims
Those two surfaces were confirmed on a real node rather than in a unit test, so we describe the setup here rather than ask you to run our harness. A private PoS devnet runs an unmodified Erigon, block production is driven over the authenticated Engine API, and the node's own retirement path freezes blocks 0-1000 into snapshot files. Nothing in Erigon is patched, stubbed or mocked. The only custom component is the consensus-layer driver, which exists because a real CL will not produce a stored-but-never-canonical block on demand: it builds two payloads on one parent and names only one as head, which is what an ordinary reorg leaves behind. The devp2p client then joins as a real peer, with RLPx handshake, p2p
Helloand ethStatus, and sendsGetBlockBodiesfor the orphan hash once before freezing and once after:The two same-height blocks are given distinct withdrawals precisely because a
BlockBodiesreply carries no header to compare against. Three details silently prevent reproduction if missed:--prune.mode=fullis required, or the node builds state files but never freezes block segments;--externalclis required, because dev mode otherwise forces an internal CL that competes for the head; and the chain must pass ~1100 blocks, since freezing happens athead - MaxReorgDepth(96) rounded down to 1000-block segments.We are glad to attach that harness, along with unit-level fixtures for the section A endpoints, or to open a PR with them. Just say which you prefer.
What we did not establish
This section bounds the report. We would rather hand you the limits than have you find them.
(hash, height)identity. It starts fromHeaderByHash, walks the selected branch through exact parent hashes, and writes each selected hash as canonical before the height-driven execution step.The primitive is reachable from validation code with externally supplied hashes, which is the part we would still want you to look at.
ForkValidator.ValidatePayloadenters its walk only whenIsCanonicalhas just returned false, so every read inside that loop uses a hash the code has just established is non-canonical. Also on the primitive:ValidateChain,unwindIfNeeded,purgeBadChain, the EVMBLOCKHASHresolver, and threerules.ChainReadershims. For any of these to substitute, a fork would have to be deeper than the freeze lag, andengine_server.goalready returnsACCEPTEDfor payloads at leastmaxReorgDepthfrom the head. We checkedBLOCKHASHspecifically: the frozen boundary can sit as close ashead-97, which does overlap the 256-block window, but reaching it needs a fork at least 97 deep thatengine_server.goalready declines, and post-EIP-2935 it is a system contract anyway.One amplification we went looking for and did not find
Step 6 of the #19257 comment notes that wrong receipts are cached and "served to all subsequent callers". If a request naming an orphan hash could leave an entry that a later canonical request reads, this would stop being self-inflicted and would have a real victim. That looked plausible, since
receiptCacheis keyed bytxNumrather than by hash, and an orphan block at height H is re-executed over the canonicaltxNums at H, so the keys do collide.It is safe. Every read compares the cached receipt's
BlockHashagainst the requested one and evicts on mismatch:TryGetCachedReceiptcarries the same check, and the block-levelreceiptsCacheis keyed by block hash. We mention this because those two comparisons are load-bearing correctness properties, not reorg housekeeping. Removing either, or adding a cache read that skips the check, would convert this report's defect into a genuine cross-request poisoning bug. They are currently documented as "elegant way to handle reorgs". A comment saying what they actually prevent, plus a test pinning it, would be worth having.Paths that already fail closed
Included so this is not read as a blanket claim, and because one of them is the fix we would suggest.
The BAL regenerator is the shape we think the fix should take. It compares the requested hash against
CanonicalHash(blockNum)and returns "unavailable" on mismatch (execution/bal/regenerator.go). eth/71GetBlockAccessListsis safe because of it.Also correct at these revisions: the backward block downloader, the
wit/0witness handlers,engine_getPayloadBodiesByRangeV1/V2(which resolves each height throughcanonicalHash),engine_getBlobsV1/V2/V3, the Engine API quick-status paths, and the large set of JSON-RPC endpoints that resolve hash selectors canonically, includingeth_call,eth_estimateGas,eth_createAccessList, the state getters,eth_getProof,debug_traceCall,debug_traceBlockByHash,debug_storageRangeAt,debug_accountRange,trace_replayBlockTransactions,eth_getBlockReceipts,erigon_getBlockReceiptsByBlockHash,erigon_getLogsByHash,eth_getTransactionByBlockHashAndIndexandeth_getUncleByBlockHashAndIndex.Suggested fix
Header,BodyandblockWithSenders, if the caller supplied a non-zero hash and the snapshot fallback produces an object with a different hash, return nothing or an explicit identity-mismatch error instead of overwriting the hash. Move theforceCanonicalcheck so it also covers the snapshot branch. This alone fixes the devp2p handlers, the Engine API path and several RPC endpoints at once, and it is the layer a per-endpoint guard cannot reach.GetCanonicalBlockNumberand return the existing non-canonical-hash error.debug_getRawReceiptscan reuse the guard already oneth_getBlockReceipts.TestReceiptRootValidationAfterReorgcomment, or scope it to the database case. As written it records an assumption about the reader that does not hold for frozen heights, and it is the most likely place for the next reader to acquire the wrong model.receipt.BlockHash == blockHashcomparisons explaining what they prevent.We are glad to help with the fix or the tests, and can open a PR for the central reader change if that is useful.