fix(eth,consensus): gate the proposed-block handler on canonicality and storage - #2554
fix(eth,consensus): gate the proposed-block handler on canonicality and storage#2554gzliudan wants to merge 1 commit into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🟡 Changes recommended
The canonical hash can reference a fast-synced header whose full block was never stored, so the new gate needs an additional block-presence check.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a canonicality gate before downloaded blocks reach XDPoS consensus handling.
Changes:
- Adds
GetCanonicalHashto the downloader blockchain interface. - Adds canonicality scenarios and downloader test hooks.
File summaries
| File | Description |
|---|---|
eth/downloader/downloader.go |
Gates proposed-block handling on canonical hash. |
eth/downloader/downloader_test.go |
Adds canonicality and concurrent-head test scenarios. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
678dd3d to
3978be7
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The test fork-choice model diverges from production for equal-TD and shorter heavier reorgs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
eth/downloader/downloader_test.go:390
- This fork-choice model does not fully match
BlockChain.writeBlockWithState: production also promotes an equal-TD block when it has a higher number (core/blockchain.go:1670-1675), and a reorg to a shorter but heavier fork deletes canonical markers above the new head (core/blockchain.go:2614-2627). As written, the tester can report a different canonical hash than the real chain for both cases, weakening tests built onGetCanonicalHash. Mirror the tie-break and clear entries above every newly selected head.
_, headHash := dl.canonicalHead(true)
if td := dl.ownChainTd[headHash]; td == nil || dl.ownChainTd[block.Hash()].Cmp(td) > 0 {
dl.canonicalize(block.Hash(), block.NumberU64())
}
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
b46701f to
f7e17f2
Compare
834c8f7 to
f5e3893
Compare
f2e2f12 to
661dabe
Compare
f872cef to
d9b0b3d
Compare
3899753 to
caa539d
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The proposed checks still permit reorg races around consensus-state mutation and vote broadcasting, and the interface change breaks external implementations.
Review details
Suppressed comments (1)
consensus/XDPoS/engines/engine_v2/engine.go:882
- The re-check is not actually adjacent to the broadcast.
sendVoteperforms epoch lookups and signing, updateshighestVotedRound, inserts the vote into the local pool viavoteHandler, and only then asynchronously broadcasts it (vote.go:20-59). A reorg after this line can therefore still produce and broadcast a vote for a non-canonical block. Keep canonicality protected through the vote state mutation and enqueue, rather than checking before an unsynchronizedsendVotecall.
if ok, reason, canonicalHash := consensus.ShouldHandleProposedBlock(chain, blockHeader); !ok {
consensus.SkipLogLevel(reason)("[ProposedBlockHandler] skip vote for reorged block", "reason", reason, "hash", blockHeader.Hash(), "number", blockInfo.Number, "canonicalHash", canonicalHash)
return nil
}
return x.sendVote(chain, blockInfo)
- Files reviewed: 17/17 changed files
- Comments generated: 4
- Review effort level: Balanced
773aa1a to
76d9eec
Compare
…nd storage importBlockResults handed the tail of every batch to the proposed-block handler as long as InsertChain returned nil. A nil error does not mean the tail is canonical: a fork batch is written as side-chain entries, and a parked tail is not written at all. The engine did not make up for it: processQC updates highestQuorumCert, lockQuorumCert and the commit block before its own existence check, and an existence check cannot tell a reorged-away block from a canonical one, because the fork stays in the database. The fetcher and the miner call the same handler and had no gate at all. Canonicality is not enough on its own either: it is a property of the header, and the fast sync header phase marks a height canonical before its body lands, so a node could processQC and vote for a block that only ever existed as a header. A master node could therefore end up voting for, and committing state against, a block it had just reorged away. Judge the block once, in one place. consensus.ShouldHandleProposedBlock reports whether a header is the canonical block at its height and whether its body is stored, together with the reason it must be skipped and the canonical hash at that height, both for the skip log. It takes a minimal CanonicalChain (GetHeaderByNumber plus HasBlock) that both consensus.ChainReader and the downloader's BlockChain satisfy, so the callers cannot drift into two diverging judgments; on the downloader side that costs one addition of GetHeaderByNumber to its BlockChain interface, HasBlock being there already. The storage half uses HasBlock rather than GetBlock: only existence matters, and GetBlock would read and RLP-decode the whole body of every imported block. consensus.ChainReader gains HasBlock for it, which core.BlockChain already had and which HeaderChain and verifyChainReader now provide with the same semantics as their GetBlock. The downloader gates importBlockResults on that judgment instead of open-coding the same rule out of HasBlock and GetCanonicalHash, and logs the skip with its reason and the canonical hash it observed. The v2 engine re-checks the same judgment twice inside ProposedBlockHandler: in front of processQC, and again right before sendVote, because x.lock serialises the handler but not InsertChain, so a reorg can still land between the two checks. consensus.SkipLogLevel grades the skips by reason: SkipNonCanonical is a genuine reorg race and stays at Warn, while the sync-phase skips are routine and stay at Info so they do not drown the level in noise. The downloader's own skip log stays at Info on purpose: it is the pre-filter for the routine cases and correctness rests on the handler's re-checks, so grading it too would fire a Warn for every fork tail of a sync. Tests: - TestShouldHandleProposedBlock covers the four outcomes of the shared judgment, and TestHeaderChainShouldHandleProposedBlock pins that the header chain's interface stubs never pass it. - TestProposedBlockHandlerSkipsNonCanonicalBlock, TestProposedBlockHandlerSkipsReorgedBlockBeforeProcessQC, TestProposedBlockHandlerDropsVoteForReorgedBlock and TestProposedBlockHandlerSkipsBlockWithoutBody cover the two engine re-checks: highestQuorumCert, lockQC, the timeout certificate, the voted round and the commit block must stay untouched and no vote may be broadcast. The two reorg tests inject the reorg through a ChainReader wrapper that serves a fork header after N truthful reads, and assert the read count, so a chain read added to the handler cannot silently move where the injection lands. - TestProposedBlockHandlerGradesSkipLogLevelByReason pins the log level of each skip reason. - TestImportBlockResultsProposedBlockHandler covers six downloader shapes: a parked tail, a fully imported batch, a stored fork batch re-delivered after the local chain grew past it, a head advanced past the canonical tail by a concurrent import, a heavier fork that stays canonical, and a fast sync height that is canonical without a body. - downloadTester gains a canonical number-to-hash table, picked by total difficulty the way the real chain resolves a reorg, plus hooks to park a batch tail and to extend it after the insert. TestStoreBlockCleansStaleCanonicalMarkers, TestRollbackClearsCanonicalMarkers and TestInsertChainErrorReportsPosition pin the parts of that table the downloader tests rely on. - TestShouldNotSendVoteMsgIfBlockNotExtendedFromAncestor no longer proposes a forked block, which the new entry re-check now short-circuits; it proposes a canonical block below the locked ancestor instead. The fork case moved to TestShouldNotSendVoteMsgIfCanonicalBlockNotExtendedFromForkedAncestor, where the parent walk of isExtendingFromAncestor actually runs.
76d9eec to
833e4cc
Compare
fix(eth,consensus): gate the proposed-block handler on canonicality and storage
Summary
importBlockResultshanded the tail of every imported batch to the proposed-block handler as long asInsertChainreturned nil, but a nil error does not mean the tail is canonical: a fork batch is written as side-chain entries and a parked tail is not written at all. The engine did not compensate for it —processQCupdateshighestQuorumCert,lockQuorumCertand the commit block before its own existence check, and an existence check cannot distinguish a reorged-away block from a canonical one because the fork stays in the database — so a master node could vote for and commit state against a block it had just reorged away. This PR introduces a single shared judgment,consensus.ShouldHandleProposedBlock, which requires the header to be canonical at its height and its body to be stored, and gates both the downloader and the v2 engine on it. closes #____Motivation & Context
The downloader treats
InsertChainreturning nil as "the tail is on the canonical chain", which is only true for a batch that actually extended the canonical head. A fork batch is stored as side entries, and a tail parked by a gap or an interrupt is not stored at all; both still produced a nil error and were handed to the handler. The fetcher and the miner call the same handler and had no gate whatsoever. Canonicality alone is not sufficient either: it is a property of the header, and the fast sync header phase marks a height canonical before its body lands, so a node couldprocessQCagainst and vote for a block that only ever existed as a header. Voting is not a local-only action — a master node's vote, QC and commit block feed consensus — so the cost of acting on a non-canonical block is a wrong commit, not just a wasted log line.Changes
consensus/proposed_block.go(new):CanonicalChain(onlyGetHeaderByNumber+HasBlock),ShouldHandleProposedBlock, theSkipReasonconstants (SkipNoCanonicalHeader,SkipNonCanonical,SkipBodyNotStored) andSkipLogLevel. The judgment returns the skip reason and the canonical hash at the height, both for logging. Storage is checked withHasBlockrather thanGetBlock: only existence matters, andGetBlockwould read and RLP-decode the whole body of every imported block.consensus/consensus.go:ChainReadergainsHasBlock.core.BlockChainalready had it;core.HeaderChain,consensus.XDPoS.verifyChainReaderand thecore.fakeChainReader/ test chain mocks now provide it with the same semantics as theirGetBlock.eth/downloader/downloader.go:importBlockResultsgates the handler on the shared judgment instead of open-coding one out ofHasBlockandGetCanonicalHash, and logs the skip with its reason and the observed canonical hash at Info. ItsBlockChaininterface gainsGetHeaderByNumberso it satisfiesCanonicalChain.consensus/XDPoS/engines/engine_v2/engine.go:ProposedBlockHandlerre-checks the same judgment twice — in front ofprocessQC, and again right beforesendVote— becausex.lockserialises the handler but notInsertChain, so a reorg can land between the two checks. Skip levels follow the reason:SkipNonCanonical(a genuine reorg race) at Warn, the routine sync-phase skips at Info.Risk & Impact
consensus.ChainReadergainsHasBlock, so every implementation must be updated — all in-tree ones are, and out-of-tree implementations ofconsensus.Enginechains will fail to compile until they add it.GetHeaderByNumberand oneHasBlockper proposed block in the downloader, and two extra judgments per handler run; both are existence/header reads, no body decoding.