blockchain: reuse existing header node in maybeAcceptBlock
What changed, and why it matters
This commit fixes a bug in btcd's blockchain handling where accepting a full block could overwrite an existing header-only entry in the block index. That overwrite would leave an internal 'best header' view pointing to an orphaned record, causing later checks that rely on header membership to fail. The fix reuses the existing index entry and upgrades its status instead of replacing it.
Review whether any reachable code paths could have triggered incorrect behavior from the orphaned bestHeader pointer, such as stale header validation or netsync decisions. The fix should be backported if the affected release branch is in production use. No immediate emergency action is indicated because the bug is internal-state consistency rather than a direct funds-loss or remote-crash vector.
Security signals we found
Inconsistent internal block index state leading to incorrect header-chain membership checks
Potential for downstream synchronization logic to make wrong decisions based on orphaned bestHeader pointers
Regression test added to prevent reintroduction
Evidence from the diff
In blockchain/accept.go, maybeAcceptBlock previously always created a new blockNode and called b.index.AddNode(newNode), which replaced any existing index entry. If maybeAcceptBlockHeader had already created a header-only node, that node was referenced by bestHeader’s chainView. Replacing the index entry orphaned that pointer, so bestHeader.Contains and downstream checks such as IsValidHeader returned incorrect results. The patch looks up the existing node first; if present, it sets statusDataStored on it, otherwise it creates a new node with statusDataStored | statusHeaderStored. A regression test verifies that the same pointer remains in the index and in bestHeader after ProcessBlock.
Changed components
blockchain/accept.goblockchain/accept_test.goblock index / blockNode lifecyclebestHeader chainView consistencyInspect captured patch +89 / −5
diff --git a/blockchain/accept.go b/blockchain/accept.go
index 6a99bac..a409aac 100644
--- a/blockchain/accept.go
+++ b/blockchain/accept.go
@@ -64,11 +64,20 @@ func (b *BlockChain) maybeAcceptBlock(block *btcutil.Block, flags BehaviorFlags)
// Create a new block node for the block and add it to the node index. Even
// if the block ultimately gets connected to the main chain, it starts out
// on a side chain.
- blockHeader := &block.MsgBlock().Header
- newNode := newBlockNode(blockHeader, prevNode)
- newNode.status = statusDataStored
-
- b.index.AddNode(newNode)
+ //
+ // If a header-only node already exists (from maybeAcceptBlockHeader),
+ // upgrade its status rather than creating a new node. Creating a new
+ // node would overwrite the index entry, orphaning the pointer held by
+ // bestHeader's chainView and breaking Contains checks.
+ newNode := b.index.LookupNode(block.Hash())
+ if newNode != nil {
+ b.index.SetStatusFlags(newNode, statusDataStored)
+ } else {
+ blockHeader := &block.MsgBlock().Header
+ newNode = newBlockNode(blockHeader, prevNode)
+ newNode.status = statusDataStored | statusHeaderStored
+ b.index.AddNode(newNode)
+ }
err = b.index.flushToDB()
if err != nil {
return false, err
diff --git a/blockchain/accept_test.go b/blockchain/accept_test.go
new file mode 100644
index 0000000..28a1ff6
--- /dev/null
+++ b/blockchain/accept_test.go
@@ -0,0 +1,75 @@
+// Copyright (c) 2013-2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package blockchain
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/blockchain/internal/testhelper"
+ "github.com/btcsuite/btcd/btcutil"
+)
+
+// TestMaybeAcceptBlockReusesHeaderNode ensures that when a block header is
+// processed first via ProcessBlockHeader and later the full block arrives via
+// ProcessBlock, the existing blockNode pointer is reused rather than replaced.
+// Replacing the pointer would orphan the entry held by bestHeader's chainView,
+// causing bestHeader.Contains(index.LookupNode(hash)) to return false and
+// breaking IsValidHeader and downstream netsync checks.
+func TestMaybeAcceptBlockReusesHeaderNode(t *testing.T) {
+ chain, params, tearDown := utxoCacheTestChain(
+ "TestMaybeAcceptBlockReusesHeaderNode")
+ defer tearDown()
+
+ // Build a base chain of 3 blocks.
+ //
+ // genesis -> 1 -> 2 -> 3
+ tip := btcutil.NewBlock(params.GenesisBlock)
+ _, _, err := addBlocks(3, chain, tip, []*testhelper.SpendableOut{})
+ if err != nil {
+ t.Fatalf("failed to build base chain: %v", err)
+ }
+
+ // Create block 4 without processing it.
+ prevBlock, err := chain.BlockByHeight(3)
+ if err != nil {
+ t.Fatalf("failed to get block at height 3: %v", err)
+ }
+ block4, _, err := newBlock(chain, prevBlock, nil)
+ if err != nil {
+ t.Fatalf("failed to create block 4: %v", err)
+ }
+
+ // Process block 4's header first.
+ block4Hash := block4.Hash()
+ _, err = chain.ProcessBlockHeader(
+ &block4.MsgBlock().Header, BFNone, false)
+ if err != nil {
+ t.Fatalf("ProcessBlockHeader fail: %v", err)
+ }
+
+ // Capture the header-only node pointer from the index.
+ headerNode := chain.index.LookupNode(block4Hash)
+ if headerNode == nil {
+ t.Fatal("header node not found in block index")
+ }
+
+ // Now process the full block.
+ _, _, err = chain.ProcessBlock(block4, BFNone)
+ if err != nil {
+ t.Fatalf("ProcessBlock fail: %v", err)
+ }
+
+ // The index must still hold the same pointer that bestHeader has.
+ // Before the fix, maybeAcceptBlock would create a fresh node and
+ // overwrite the index entry, orphaning the pointer in bestHeader.
+ fullBlockNode := chain.index.LookupNode(block4Hash)
+ if fullBlockNode != headerNode {
+ t.Fatal("ProcessBlock replaced the header node pointer " +
+ "instead of reusing it")
+ }
+ if !chain.bestHeader.Contains(fullBlockNode) {
+ t.Fatal("node no longer in bestHeader after ProcessBlock")
+ }
+}
Why this scored 58/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.