diff --git a/blockchain/accept.go b/blockchain/accept.go index 6a99bac7..a409aacc 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 00000000..28a1ff69 --- /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") + } +}