Skip to content

core: exempt empty no-op blocks from sidechain ghost-state rejection#2293

Open
bit2swaz wants to merge 3 commits into
0xPolygon:developfrom
bit2swaz:fix/2224-sidechain-ghost-state-empty-block
Open

core: exempt empty no-op blocks from sidechain ghost-state rejection#2293
bit2swaz wants to merge 3 commits into
0xPolygon:developfrom
bit2swaz:fix/2224-sidechain-ghost-state-empty-block

Conversation

@bit2swaz

Copy link
Copy Markdown

Summary

Fixes #2224.

After a validator restart, a node could refuse to sync past an empty (zero-transaction) block, drop every peer with sidechain ghost-state attack detected, and need manual intervention to recover. In the logs sideroot and canonroot are the same value.

The cause is in insertSideChain() in core/blockchain.go. When a sidechain block hits a pruned ancestor, the code rejects any block whose state root equals the canonical block's state root at the same height, treating the match as a shadow-state attack (a block trying to reuse existing canonical state so it can skip re-execution of its ancestors). That assumption only holds for a block that carried a state transition. An empty block makes no state transition, so its state root equals its parent's. Two legitimately distinct empty blocks at the same height (different seal, timestamp, or coinbase, so different hashes) therefore share a state root, and the check fires a false positive.

The fix narrows the rejection with one guard: the block is exempt only when its root equals its parent's root, which means it asserted no new state and there is nothing forged to skip-verifying. A block that claims the canonical root but differs from its parent's root (the real shadow-state attack, including a forged empty-looking block) is still rejected. If the parent header is unavailable, the original conservative behavior (reject) is kept.

This is go-ethereum derived code, so the same bug likely exists upstream.

Executed tests

  • TestSideImportEmptyBlockGhostState (new): builds a freezer-pruned canonical chain, generates a distinct empty sibling at a pruned height whose root equals both its parent's and the canonical block's, re-imports it, and asserts no ghost-state error. Confirmed it fails against the pre-fix code with the exact sidechain ghost-state attack error, then passes after the fix.
  • TestSideImportGhostStateStillRejected (new): asserts the attack path is still rejected. A side block that performs a real state transition (root differs from its parent) but claims a root equal to the canonical block must still return the ghost-state error.
  • TestSideImportPrunedBlocks (existing): still passes; the non-empty sidechain re-import path is unchanged.
  • go test ./core/ passes with no regressions.
  • gofmt -l core/blockchain.go core/blockchain_test.go is clean.
  • go build ./... compiles.

Both new tests run against the hash and path trie schemes.

Rollout notes

This change is consensus-relevant but backwards-compatible. It only narrows an existing rejection: it never accepts a block the old code would have accepted, and it still rejects every block whose root differs from its parent's. Empty blocks that previously wedged a node now import as normal sidechain blocks and fall through to the existing TD and reorg logic. No new dependencies, no config or storage format changes.

The guarded path is live in bor: insertSideChain runs on ErrPrunedAncestor with setHead, and bor uses validator-based difficulty and TD, which is the path the issue's production trace shows.

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@pratikspatil024
pratikspatil024 requested a review from Copilot July 1, 2026 08:34
@pratikspatil024

Copy link
Copy Markdown
Member

@claude review once

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a false-positive “sidechain ghost-state attack” rejection during sidechain import when encountering empty (no-op) blocks whose state root legitimately matches canonical state at the same height, which could otherwise wedge sync after restarts.

Changes:

  • Narrow insertSideChain’s ghost-state rejection by exempting blocks that appear to have made no state transition (root matches parent).
  • Add regression/security tests covering the empty-block false positive and ensuring the original rejection still triggers for a true shadow-state scenario.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
core/blockchain.go Adjusts the ghost-state attack heuristic in insertSideChain to exempt apparent no-op blocks.
core/blockchain_test.go Adds tests reproducing the empty-block sync wedge and asserting the attack path remains rejected.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/blockchain.go Outdated

@claude claude 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.

This narrows a consensus-critical guard in insertSideChain, so I'd like a human to sign off on the ghost-state security reasoning even though the change itself looks well-scoped and correct.

Extended reasoning...

Overview

The PR modifies core/blockchain.go:insertSideChain to exempt empty (no-op) blocks from the ghost-state attack rejection. The fix adds a guard: only reject when the block's root differs from its parent's root — i.e., when the block actually asserts a state transition. When the block's root equals its parent's, it is a genuine empty block that legitimately shares a state root with the canonical block at the same height. If the parent header is missing, the code falls back to the original conservative reject.

Security risks

This is consensus-relevant code — insertSideChain is on the sidechain re-import path triggered by ErrPrunedAncestor. The fix only narrows the rejection, never broadens acceptance for blocks with a real state transition, so it cannot cause the original attack (a forged block claiming canonical state to skip re-execution) to succeed. For the exemption to fire, block.Root() == parent.Root() == canonical.Root(), meaning the block introduces no new state and there is nothing forged to skip-verify. The check remains applied recursively per block in the loop, so a chain of forged non-empty blocks still fails at the first block whose root differs from its parent's. The nil-parent fallback preserves the conservative behavior.

Level of scrutiny

High — this touches security-sensitive chain insertion logic (falls under blockchain-security.md scope) that guards against shadow-state attacks on the pruning mechanism. Even a narrow relaxation of a consensus guard warrants human sign-off. The reasoning is sound and the tests directly assert both the false-positive fix and that the real attack path is still rejected across both hash and path schemes, but a human familiar with the ghost-state threat model and the bor sidechain re-import path should confirm the argument holds.

Other factors

Two new tests exercise the fix and the security counterpart, both across HashScheme and PathScheme. TestSideImportPrunedBlocks (existing) is preserved. The description explicitly references bor's live path (validator-based difficulty, TD, setHead reorgs), suggesting the author validated the code path. SonarQube quality gate passed. The go-ethereum upstream likely has the same bug — worth noting for maintenance.

@bit2swaz

Copy link
Copy Markdown
Author

@pratikspatil024 sorry to disturb, but do you reckon i could get a review on this?

@pratikspatil024

Copy link
Copy Markdown
Member

@bit2swaz could you address the comments by copilot?
Also, diffguard is failing in the CI, please fix that as well.

Address PR 0xPolygon#2293 review feedback:

- Copilot: gate the empty-block exemption on the *canonical* chain also
  being a no-op at that height, measured against the trusted canonical
  parent header — not only the side block's own parent, which may be
  unverified/attacker-controlled in insertSideChain. This closes the
  forge-both-roots bypass where an attacker crafts matching side
  parent+child roots to skip state verification.

- diffguard (Quality metrics): extract the decision into
  isSidechainGhostState, dropping insertSideChain's complexity and size
  deltas back to zero.

Add TestSidechainGhostStateCanonicalGate covering the hardening: a no-op
side block at a height where the canonical chain changed state is still
rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bit2swaz

bit2swaz commented Jul 14, 2026

Copy link
Copy Markdown
Author

Pushed 5ac7160 for both.

On Copilot's ghost-state point: it's right. the exemption now checks whether the canonical chain was a no-op at that height too using the canonical parent header from GetHeaderByNumber which is already verified. I moved the check into isSidechainGhostState and added TestSidechainGhostStateCanonicalGate which rejects a no-op side block sitting at a height where the canonical chain did change state.

On diffguard: pulling the check out into its own function also brought insertSideChain back under the complexity (+5) and size (+11) thresholds it had crossed, so Cognitive Complexity and Code Sizes pass now and mutation testing is 13/13.
The one FAIL left is the core -> core circular dependency, but that's already there on develop (diffguard counts the core/core_test split as a cycle) and this PR doesn't add any new imports, so it isn't from these changes

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.80%. Comparing base (c788d1a) to head (5ac7160).
⚠️ Report is 43 commits behind head on develop.

Files with missing lines Patch % Lines
core/blockchain.go 70.00% 2 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (70.00%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #2293      +/-   ##
===========================================
+ Coverage    53.69%   53.80%   +0.10%     
===========================================
  Files          897      898       +1     
  Lines       160388   160836     +448     
===========================================
+ Hits         86122    86534     +412     
+ Misses       68927    68923       -4     
- Partials      5339     5379      +40     
Files with missing lines Coverage Δ
core/blockchain.go 63.56% <70.00%> (+0.48%) ⬆️

... and 43 files with indirect coverage changes

Files with missing lines Coverage Δ
core/blockchain.go 63.56% <70.00%> (+0.48%) ⬆️

... and 43 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bor sync wedges on empty block after producer restart — insertSideChain same-stateRoot false-positive

3 participants