validation: `Assertion failed: (!foundInUnlinked)` #35050

issue marcofleon opened this issue on April 10, 2026
  1. marcofleon commented at 1:48 PM on April 10, 2026: contributor

    This assertion in CheckBlockIndex() can fail upon startup of a pruned node.

    <details> <summary>Functional test to reproduce</summary>

    """Trigger startup m_blocks_unlinked inconsistency on pruned side-chain blocks.
    
    Reproduces a bug where BlockManager::LoadBlockIndex inserts a block into 
    m_blocks_unlinked based on nTx > 0 without checking BLOCK_HAVE_DATA.  
    When a block on a stale fork has been pruned (BLOCK_HAVE_DATA cleared, nTx still > 0) 
    and its parent was header-only (nTx == 0 => m_chain_tx_count == 0), the pruned block 
    is incorrectly re-added to m_blocks_unlinked on restart, violating the invariant asserted 
    by CheckBlockIndex:
        "Can't be in m_blocks_unlinked if we don't HAVE_DATA"
    """
    
    from test_framework.blocktools import (
        create_block,
        create_coinbase,
    )
    from test_framework.test_framework import BitcoinTestFramework
    from test_framework.util import assert_equal
    
    
    class FeaturePruneUnlinkedBugTest(BitcoinTestFramework):
        def set_test_params(self):
            self.num_nodes = 1
            self.extra_args = [["-prune=1", "-fastprune"]]
    
        def run_test(self):
            node = self.nodes[0]
    
            self.log.info("Create a 2-block side fork: header-only parent, data-only child")
            fork_point = node.getblockhash(1)
            tip_time = node.getblock(node.getbestblockhash())["time"] + 1
    
            side_parent = create_block(int(fork_point, 16), create_coinbase(2), tip_time)
            side_parent.solve()
            side_child = create_block(side_parent.hash_int, create_coinbase(3), tip_time + 1)
            side_child.solve()
    
            node.submitheader(side_parent.serialize().hex())
            node.submitblock(side_child.serialize().hex())
    
            assert_equal(node.getblockheader(side_parent.hash_hex)["nTx"], 0)
            assert_equal(node.getblockheader(side_child.hash_hex)["nTx"], 1)
    
            self.log.info("Advance chain and prune so the side child loses BLOCK_HAVE_DATA")
            self.generate(node, 500)
            node.pruneblockchain(node.getblockcount() - 100)
    
            self.log.info("Restart and mine one block to trigger CheckBlockIndex assertion")
            self.restart_node(0)
            self.generate(node, 1)
    
    
    if __name__ == "__main__":
        FeaturePruneUnlinkedBugTest(__file__).main()
    

    </details>

    This was found with a test running on Antithesis.

  2. marcofleon commented at 1:53 PM on April 10, 2026: contributor

    This bug doesn't do much other than leaving stale entries in m_block_unlinked. I think the fix could just be to gate on BLOCK_HAVE_DATA before inserting here.

  3. stratospher commented at 12:53 PM on April 13, 2026: contributor

    interesting! m_block_unlinked currently mixes blocks from 2 different situations:

    1. a block whose parents haven't been received yet since we're receiving blocks out of order. this is what m_block_unlinked was originally intended for. see here.
    2. when we're trying to reorg to another chain in FindMostWorkChain and the block's parents have been pruned away. this functionality got added later when support for pruning was implemented. see here

    so you're seeing a rare interaction of 1 + 2 in LoadBlockIndex!

    I think the suggested fix makes sense since insert into m_blocks_unlinked via 2 ideally happens only when a reorg is attempted and not in LoadBlockIndex.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-04-18 09:12 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me