From c011e3aa542631a8857039df796ebf13a653e8a6 Mon Sep 17 00:00:00 2001 From: Martin Zumsande Date: Fri, 5 Dec 2025 11:42:30 -0500 Subject: [PATCH 1/3] test: Wrap validation functions with TestChainstateManager This allows to access them in the fuzz test in the next commit without making them public. Co-authored-by: TheCharlatan --- src/node/blockstorage.h | 16 ++++++------ src/test/util/validation.cpp | 48 ++++++++++++++++++++++++++++++++++++ src/test/util/validation.h | 13 ++++++++++ src/validation.h | 4 ++- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/node/blockstorage.h b/src/node/blockstorage.h index c381d1c9226..bef99984644 100644 --- a/src/node/blockstorage.h +++ b/src/node/blockstorage.h @@ -248,7 +248,6 @@ private: ChainstateManager& chainman); RecursiveMutex cs_LastBlockFile; - std::vector m_blockfile_info; //! Since assumedvalid chainstates may be syncing a range of the chain that is very //! far away from the normal/background validation process, we should segment blockfiles @@ -283,12 +282,6 @@ private: const Obfuscation m_obfuscation; - /** Dirty block index entries. */ - std::set m_dirty_blockindex; - - /** Dirty block file entries. */ - std::set m_dirty_fileinfo; - /** * Map from external index name to oldest block that must not be pruned. * @@ -304,6 +297,15 @@ private: const FlatFileSeq m_block_file_seq; const FlatFileSeq m_undo_file_seq; +protected: + std::vector m_blockfile_info; + + /** Dirty block index entries. */ + std::set m_dirty_blockindex; + + /** Dirty block file entries. */ + std::set m_dirty_fileinfo; + public: using Options = kernel::BlockManagerOpts; using ReadRawBlockResult = util::Expected, ReadRawError>; diff --git a/src/test/util/validation.cpp b/src/test/util/validation.cpp index c199d912a38..b9f79f1d57a 100644 --- a/src/test/util/validation.cpp +++ b/src/test/util/validation.cpp @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -11,6 +12,13 @@ using kernel::ChainstateRole; +void TestBlockManager::CleanupForFuzzing() +{ + m_dirty_blockindex.clear(); + m_dirty_fileinfo.clear(); + m_blockfile_info.resize(1); +} + void TestChainstateManager::DisableNextWrite() { struct TestChainstate : public Chainstate { @@ -43,3 +51,43 @@ void ValidationInterfaceTest::BlockConnected( { obj.BlockConnected(role, block, pindex); } +void TestChainstateManager::InvalidBlockFound(CBlockIndex* pindex, const BlockValidationState& state) +{ + struct TestChainstate : public Chainstate { + void CallInvalidBlockFound(CBlockIndex* pindex, const BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(cs_main) + { + InvalidBlockFound(pindex, state); + } + }; + + static_cast(&ActiveChainstate())->CallInvalidBlockFound(pindex, state); +} + +void TestChainstateManager::InvalidChainFound(CBlockIndex* pindexNew) +{ + struct TestChainstate : public Chainstate { + void CallInvalidChainFound(CBlockIndex* pindexNew) EXCLUSIVE_LOCKS_REQUIRED(cs_main) + { + InvalidChainFound(pindexNew); + } + }; + + static_cast(&ActiveChainstate())->CallInvalidChainFound(pindexNew); +} + +CBlockIndex* TestChainstateManager::FindMostWorkChain() +{ + struct TestChainstate : public Chainstate { + CBlockIndex* CallFindMostWorkChain() EXCLUSIVE_LOCKS_REQUIRED(cs_main) + { + return FindMostWorkChain(); + } + }; + + return static_cast(&ActiveChainstate())->CallFindMostWorkChain(); +} + +void TestChainstateManager::ResetBestInvalid() +{ + m_best_invalid = nullptr; +} diff --git a/src/test/util/validation.h b/src/test/util/validation.h index 4a24c97ed24..debad32d8d3 100644 --- a/src/test/util/validation.h +++ b/src/test/util/validation.h @@ -7,8 +7,16 @@ #include +namespace node { +class BlockManager; +} class CValidationInterface; +struct TestBlockManager : public node::BlockManager { + /** Test-only method to clear internal state for fuzzing */ + void CleanupForFuzzing(); +}; + struct TestChainstateManager : public ChainstateManager { /** Disable the next write of all chainstates */ void DisableNextWrite(); @@ -16,6 +24,11 @@ struct TestChainstateManager : public ChainstateManager { void ResetIbd(); /** Toggle IsInitialBlockDownload from true to false */ void JumpOutOfIbd(); + /** Wrappers that avoid making chainstatemanager internals public for tests */ + void InvalidBlockFound(CBlockIndex* pindex, const BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(cs_main); + void InvalidChainFound(CBlockIndex* pindexNew) EXCLUSIVE_LOCKS_REQUIRED(cs_main); + CBlockIndex* FindMostWorkChain() EXCLUSIVE_LOCKS_REQUIRED(cs_main); + void ResetBestInvalid() EXCLUSIVE_LOCKS_REQUIRED(cs_main); }; class ValidationInterfaceTest diff --git a/src/validation.h b/src/validation.h index 77958b53dd1..e4b1e555bdd 100644 --- a/src/validation.h +++ b/src/validation.h @@ -929,7 +929,6 @@ enum class SnapshotCompletionResult { class ChainstateManager { private: - CBlockIndex* m_best_invalid GUARDED_BY(::cs_main){nullptr}; /** The last header for which a headerTip notification was issued. */ CBlockIndex* m_last_notified_header GUARDED_BY(GetMutex()){nullptr}; @@ -983,6 +982,9 @@ private: SteadyClock::duration GUARDED_BY(::cs_main) time_chainstate{}; SteadyClock::duration GUARDED_BY(::cs_main) time_post_connect{}; +protected: + CBlockIndex* m_best_invalid GUARDED_BY(::cs_main){nullptr}; + public: using Options = kernel::ChainstateManagerOpts; From 45f5b2dac330906368352a1c585183f0d75d779d Mon Sep 17 00:00:00 2001 From: Martin Zumsande Date: Wed, 30 Oct 2024 14:59:37 -0400 Subject: [PATCH 2/3] fuzz: Add fuzzer for block index This fuzz target creates arbitrary tree-like structure of indices, simulating the following events: - Adding a header to the block tree db - Receiving the full block (may be valid or not) - Reorging to a new chain tip (possibly encountering invalid blocks on the way) - pruning The test skips all actual validation of header/ block / transaction data by just simulating the outcome, and also doesn't interact with the data directory. The main goal is to test the integrity of the block index tree in all fuzzed constellations, by calling CheckBlockIndex() at the end of each iteration. --- src/test/fuzz/CMakeLists.txt | 1 + src/test/fuzz/block_index_tree.cpp | 209 +++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/test/fuzz/block_index_tree.cpp diff --git a/src/test/fuzz/CMakeLists.txt b/src/test/fuzz/CMakeLists.txt index 607723b978a..3d2ff8f3aa7 100644 --- a/src/test/fuzz/CMakeLists.txt +++ b/src/test/fuzz/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(fuzz block.cpp block_header.cpp block_index.cpp + block_index_tree.cpp blockfilter.cpp bloom_filter.cpp buffered_file.cpp diff --git a/src/test/fuzz/block_index_tree.cpp b/src/test/fuzz/block_index_tree.cpp new file mode 100644 index 00000000000..df5dc658b89 --- /dev/null +++ b/src/test/fuzz/block_index_tree.cpp @@ -0,0 +1,209 @@ +// Copyright (c) 2020-2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +const TestingSetup* g_setup; + +CBlockHeader ConsumeBlockHeader(FuzzedDataProvider& provider, uint256 prev_hash, int& nonce_counter) +{ + CBlockHeader header; + header.nVersion = provider.ConsumeIntegral(); + header.hashPrevBlock = prev_hash; + header.hashMerkleRoot = uint256{}; // never used + header.nTime = provider.ConsumeIntegral(); + header.nBits = Params().GenesisBlock().nBits; // not fuzzed because not used (validation is mocked). + header.nNonce = nonce_counter++; // prevent creating multiple block headers with the same hash + return header; +} + +void initialize_block_index_tree() +{ + static const auto testing_setup = MakeNoLogFileContext(); + g_setup = testing_setup.get(); +} + +FUZZ_TARGET(block_index_tree, .init = initialize_block_index_tree) +{ + FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size()); + SetMockTime(ConsumeTime(fuzzed_data_provider)); + auto& chainman = static_cast(*g_setup->m_node.chainman); + auto& blockman = static_cast(chainman.m_blockman); + CBlockIndex* genesis = chainman.ActiveChainstate().m_chain[0]; + int nonce_counter = 0; + std::vector blocks; + blocks.push_back(genesis); + bool abort_run{false}; + + LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), 1000) + { + if (abort_run) break; + CallOneOf( + fuzzed_data_provider, + [&] { + // Receive a header building on an existing valid one. This assumes headers are valid, so PoW is not relevant here. + LOCK(cs_main); + CBlockIndex* prev_block = PickValue(fuzzed_data_provider, blocks); + if (!(prev_block->nStatus & BLOCK_FAILED_MASK)) { + CBlockHeader header = ConsumeBlockHeader(fuzzed_data_provider, prev_block->GetBlockHash(), nonce_counter); + CBlockIndex* index = blockman.AddToBlockIndex(header, chainman.m_best_header); + assert(index->nStatus & BLOCK_VALID_TREE); + assert(index->pprev == prev_block); + blocks.push_back(index); + } + }, + [&] { + // Receive a full block (valid or invalid) for an existing header, but don't attempt to connect it yet + LOCK(cs_main); + CBlockIndex* index = PickValue(fuzzed_data_provider, blocks); + // Must be new to us and not known to be invalid (e.g. because of an invalid ancestor). + if (index->nTx == 0 && !(index->nStatus & BLOCK_FAILED_MASK)) { + if (fuzzed_data_provider.ConsumeBool()) { // Invalid + BlockValidationState state; + state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "consensus-invalid"); + chainman.InvalidBlockFound(index, state); + } else { + size_t nTx = fuzzed_data_provider.ConsumeIntegralInRange(1, 1000); + CBlock block; // Dummy block, so that ReceivedBlockTransactions can infer a nTx value. + block.vtx = std::vector(nTx); + FlatFilePos pos(0, fuzzed_data_provider.ConsumeIntegralInRange(1, 1000)); + chainman.ReceivedBlockTransactions(block, index, pos); + assert(index->nStatus & BLOCK_VALID_TRANSACTIONS); + assert(index->nStatus & BLOCK_HAVE_DATA); + } + } + }, + [&] { + // Simplified ActivateBestChain(): Try to move to a chain with more work - with the possibility of finding blocks to be invalid on the way + LOCK(cs_main); + auto& chain = chainman.ActiveChain(); + CBlockIndex* old_tip = chain.Tip(); + assert(old_tip); + do { + CBlockIndex* best_tip = chainman.FindMostWorkChain(); + assert(best_tip); // Should at least return current tip + if (best_tip == chain.Tip()) break; // Nothing to do + // Rewind chain to forking point + const CBlockIndex* fork = chain.FindFork(best_tip); + // If we can't go back to the fork point due to pruned data, abort this run. In reality, a pruned node would also currently just crash in this scenario. + // This is very unlikely to happen due to the minimum pruning threshold of 550MiB. + CBlockIndex* it = chain.Tip(); + while (it && it->nHeight != fork->nHeight) { + if (!(it->nStatus & BLOCK_HAVE_UNDO)) { + assert(blockman.m_have_pruned); + abort_run = true; + return; + } + it = it->pprev; + } + chain.SetTip(*chain[fork->nHeight]); + + // Prepare new blocks to connect + std::vector to_connect; + it = best_tip; + while (it && it->nHeight != fork->nHeight) { + to_connect.push_back(it); + it = it->pprev; + } + // Connect blocks, possibly fail + for (CBlockIndex* block : to_connect | std::views::reverse) { + assert(!(block->nStatus & BLOCK_FAILED_MASK)); + assert(block->nStatus & BLOCK_HAVE_DATA); + if (!block->IsValid(BLOCK_VALID_SCRIPTS)) { + if (fuzzed_data_provider.ConsumeBool()) { // Invalid + BlockValidationState state; + state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "consensus-invalid"); + chainman.InvalidBlockFound(block, state); + // This results in duplicate calls to InvalidChainFound, but mirrors the behavior in validation + chainman.InvalidChainFound(to_connect.front()); + break; + } else { + block->RaiseValidity(BLOCK_VALID_SCRIPTS); + block->nStatus |= BLOCK_HAVE_UNDO; + } + } + chain.SetTip(*block); + chainman.ActiveChainstate().PruneBlockIndexCandidates(); + // ActivateBestChainStep may release cs_main / not connect all blocks in one go - but only if we have at least as much chain work as we had at the start. + if (block->nChainWork > old_tip->nChainWork && fuzzed_data_provider.ConsumeBool()) { + break; + } + } + } while (node::CBlockIndexWorkComparator()(chain.Tip(), old_tip)); + assert(chain.Tip()->nChainWork >= old_tip->nChainWork); + }, + [&] { + // Prune chain - dealing with block files is beyond the scope of this test, so just prune random blocks, making no assumptions + // about what blocks are pruned together because they are in the same block file. + // Also don't prune blocks outside of the chain for now - this would make the fuzzer crash because of the problem described in + // https://github.com/bitcoin/bitcoin/issues/31512 + LOCK(cs_main); + auto& chain = chainman.ActiveChain(); + int prune_height = fuzzed_data_provider.ConsumeIntegralInRange(0, chain.Height()); + CBlockIndex* prune_block{chain[prune_height]}; + if (prune_block != chain.Tip() && (prune_block->nStatus & BLOCK_HAVE_DATA)) { + blockman.m_have_pruned = true; + prune_block->nStatus &= ~BLOCK_HAVE_DATA; + prune_block->nStatus &= ~BLOCK_HAVE_UNDO; + prune_block->nFile = 0; + prune_block->nDataPos = 0; + prune_block->nUndoPos = 0; + auto range = blockman.m_blocks_unlinked.equal_range(prune_block->pprev); + while (range.first != range.second) { + std::multimap::iterator _it = range.first; + range.first++; + if (_it->second == prune_block) { + blockman.m_blocks_unlinked.erase(_it); + } + } + } + }); + } + if (!abort_run) { + chainman.CheckBlockIndex(); + } + + // clean up global state changed by last iteration and prepare for next iteration + { + LOCK(cs_main); + genesis->nStatus |= BLOCK_HAVE_DATA; + genesis->nStatus |= BLOCK_HAVE_UNDO; + chainman.m_best_header = genesis; + chainman.ResetBestInvalid(); + chainman.nBlockSequenceId = 2; + chainman.ActiveChain().SetTip(*genesis); + chainman.ActiveChainstate().setBlockIndexCandidates.clear(); + chainman.m_cached_finished_ibd = false; + blockman.m_blocks_unlinked.clear(); + blockman.m_have_pruned = false; + blockman.CleanupForFuzzing(); + // Delete all blocks but Genesis from block index + uint256 genesis_hash = genesis->GetBlockHash(); + for (auto it = blockman.m_block_index.begin(); it != blockman.m_block_index.end();) { + if (it->first != genesis_hash) { + it = blockman.m_block_index.erase(it); + } else { + ++it; + } + } + chainman.ActiveChainstate().TryAddBlockIndexCandidate(genesis); + assert(blockman.m_block_index.size() == 1); + assert(chainman.ActiveChainstate().setBlockIndexCandidates.size() == 1); + assert(chainman.ActiveChain().Height() == 0); + } +} From db2d39f642979f929261e5f1cd67f0c2f2ca045f Mon Sep 17 00:00:00 2001 From: Eugene Siegel Date: Mon, 8 Dec 2025 18:27:04 -0500 Subject: [PATCH 3/3] fuzz: add subtest for re-downloading a previously pruned block This imitates the use of the getblockfrompeer rpc. Note that currently pruning is limited to blocks in the active chain. Co-authored-by: Martin Zumsande --- src/test/fuzz/block_index_tree.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/test/fuzz/block_index_tree.cpp b/src/test/fuzz/block_index_tree.cpp index df5dc658b89..df46ebad37e 100644 --- a/src/test/fuzz/block_index_tree.cpp +++ b/src/test/fuzz/block_index_tree.cpp @@ -50,6 +50,8 @@ FUZZ_TARGET(block_index_tree, .init = initialize_block_index_tree) blocks.push_back(genesis); bool abort_run{false}; + std::vector pruned_blocks; + LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), 1000) { if (abort_run) break; @@ -171,7 +173,24 @@ FUZZ_TARGET(block_index_tree, .init = initialize_block_index_tree) blockman.m_blocks_unlinked.erase(_it); } } + pruned_blocks.push_back(prune_block); } + }, + [&] { + // Download a previously pruned block + LOCK(cs_main); + size_t num_pruned = pruned_blocks.size(); + if (num_pruned == 0) return; + size_t i = fuzzed_data_provider.ConsumeIntegralInRange(0, num_pruned - 1); + CBlockIndex* index = pruned_blocks[i]; + assert(!(index->nStatus & BLOCK_HAVE_DATA)); + CBlock block; + block.vtx = std::vector(index->nTx); // Set the number of tx to the prior value. + FlatFilePos pos(0, fuzzed_data_provider.ConsumeIntegralInRange(1, 1000)); + chainman.ReceivedBlockTransactions(block, index, pos); + assert(index->nStatus & BLOCK_VALID_TRANSACTIONS); + assert(index->nStatus & BLOCK_HAVE_DATA); + pruned_blocks.erase(pruned_blocks.begin() + i); }); } if (!abort_run) {