test: add fuzz test for private broadcast
What changed, and why it matters
This commit only adds a new automated fuzz test for an existing Bitcoin Core feature called private transaction broadcast. It does not change any production code that runs on the network, so it cannot by itself introduce a security vulnerability or fix one. It is purely extra test coverage.
No security action required. Review as normal test code if desired.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces src/test/fuzz/private_broadcast.cpp and wires it into the fuzz-test CMake build. The fuzz target exercises the PrivateBroadcast class (Add, Remove, PickTxForSend, GetTxForNode, NodeConfirmedReception, DidNodeConfirmReception, HavePendingTransactions, GetStale, GetBroadcastInfo) with random data and asserts consistency against an in-test model. It also refactors PickValue() in src/test/fuzz/util.h by extracting a PickIterator() helper. No consensus, networking, wallet, or mempool production code is modified.
Changed components
src/test/fuzz/private_broadcast.cpp (new fuzz test)src/test/fuzz/CMakeLists.txt (fuzz target registration)src/test/fuzz/util.h (test helper refactor)Inspect captured patch +212 / −5
diff --git a/src/test/fuzz/CMakeLists.txt b/src/test/fuzz/CMakeLists.txt
index a159ef7e..2d6e03ba 100644
--- a/src/test/fuzz/CMakeLists.txt
+++ b/src/test/fuzz/CMakeLists.txt
@@ -94,6 +94,7 @@ add_executable(fuzz
pow.cpp
prevector.cpp
primitives_transaction.cpp
+ private_broadcast.cpp
process_message.cpp
process_messages.cpp
protocol.cpp
diff --git a/src/test/fuzz/private_broadcast.cpp b/src/test/fuzz/private_broadcast.cpp
new file mode 100644
index 00000000..4db24f8e
--- /dev/null
+++ b/src/test/fuzz/private_broadcast.cpp
@@ -0,0 +1,202 @@
+// Copyright (c) 2025-present 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 <consensus/tx_check.h>
+#include <consensus/validation.h>
+#include <net.h>
+#include <primitives/transaction.h>
+#include <private_broadcast.h>
+#include <test/fuzz/FuzzedDataProvider.h>
+#include <test/fuzz/fuzz.h>
+#include <test/fuzz/util.h>
+#include <test/fuzz/util/net.h>
+#include <test/util/setup_common.h>
+#include <test/util/time.h>
+#include <util/overflow.h>
+#include <util/time.h>
+
+#include <unordered_set>
+
+struct CTransactionRefHash {
+ size_t operator()(const CTransactionRef& tx) const
+ {
+ return static_cast<size_t>(tx->GetWitnessHash().ToUint256().GetUint64(0));
+ }
+};
+
+struct CTransactionRefComp {
+ bool operator()(const CTransactionRef& a, const CTransactionRef& b) const
+ {
+ return a->GetWitnessHash() == b->GetWitnessHash();
+ }
+};
+
+FUZZ_TARGET(private_broadcast)
+{
+ SeedRandomStateForTest(SeedRand::ZEROS);
+ FuzzedDataProvider fdp(buffer.data(), buffer.size());
+ FakeNodeClock clock_ctx{ConsumeTime(fdp)};
+
+ PrivateBroadcast pb;
+
+ // Random transaction that the test generated and passed to Add(). Trimmed when Remove() is called.
+ // The values are the number of times a transaction was picked for sending.
+ std::unordered_map<CTransactionRef, size_t, CTransactionRefHash, CTransactionRefComp> transactions;
+
+ // Ids of nodes that were passed to PickTxForSend(). Trimmed when Remove() is called.
+ std::unordered_set<NodeId> nodes_sent_to;
+
+ // A subset of `nodes_sent_to`, node ids passed to NodeConfirmedReception(). Trimmed when Remove() is called.
+ std::unordered_set<NodeId> nodes_that_confirmed_reception;
+
+ NodeId next_nodeid{0}; // Generate unique node ids.
+
+ const auto ExistentOrNewNodeId = [&next_nodeid, &fdp](){
+ if (next_nodeid == 0 || fdp.ConsumeBool()) {
+ return next_nodeid++;
+ }
+ return fdp.ConsumeIntegralInRange<NodeId>(0, next_nodeid - 1);
+ };
+
+ LIMITED_WHILE(fdp.ConsumeBool(), 10000) {
+ CallOneOf(
+ fdp,
+ [&] { // Add()
+ CTransactionRef tx;
+ bool from_transactions{false};
+ if (transactions.empty() || fdp.ConsumeBool()) {
+ tx = MakeTransactionRef(ConsumeTransaction(fdp, std::nullopt));
+ } else {
+ tx = PickIterator(fdp, transactions)->first;
+ from_transactions = true;
+ }
+ if (pb.Add(tx)) {
+ Assert(!from_transactions);
+ transactions.emplace(tx, 0);
+ }
+ },
+ [&] { // Remove()
+ if (transactions.empty()) {
+ return;
+ }
+ const auto transactions_it{PickIterator(fdp, transactions)};
+ const CTransactionRef& tx{transactions_it->first};
+
+ size_t num_nodes_that_confirmed_tx{0};
+
+ // Remove relevant entries from nodes_sent_to[] and nodes_that_confirmed_reception[] if any.
+ for (auto it = nodes_sent_to.begin(); it != nodes_sent_to.end();) {
+ const NodeId nodeid{*it};
+ const auto opt_tx_for_node{pb.GetTxForNode(nodeid)};
+ if (opt_tx_for_node.has_value() && opt_tx_for_node.value() == tx) {
+ it = nodes_sent_to.erase(it);
+ if (nodes_that_confirmed_reception.erase(nodeid) > 0) {
+ ++num_nodes_that_confirmed_tx;
+ }
+ } else {
+ ++it;
+ }
+ }
+
+ const auto opt_num_confirmed{pb.Remove(tx)};
+
+ Assert(opt_num_confirmed.has_value());
+ Assert(opt_num_confirmed.value() == num_nodes_that_confirmed_tx);
+ Assert(!pb.Remove(tx).has_value());
+ transactions.erase(transactions_it);
+ },
+ [&] { // PickTxForSend()
+ // Only give pristine node ids to PickTxForSend() as required.
+ const NodeId will_send_to_nodeid{next_nodeid++};
+ const CService will_send_to_address{ConsumeService(fdp)};
+
+ const auto opt_tx{pb.PickTxForSend(will_send_to_nodeid, will_send_to_address)};
+
+ if (opt_tx.has_value()) {
+ Assert(transactions.contains(opt_tx.value()));
+
+ // "Number of times picked for sending" is the primary key in Priority's comparison
+ // (fewest sends = highest priority), so PickTxForSend() must return a transaction
+ // with the minimum send count of any in the queue. Ties are broken by state we
+ // don't model, so only check this key.
+ const size_t min_picked{std::ranges::min_element(
+ transactions, {}, [](const auto& el) { return el.second; })->second};
+ const auto picked_it{transactions.find(opt_tx.value())};
+ Assert(picked_it != transactions.end());
+ Assert(picked_it->second == min_picked); // picked the least-sent transaction
+ ++picked_it->second; // PickTxForSend() recorded exactly one send
+
+ const auto& [_, inserted]{nodes_sent_to.emplace(will_send_to_nodeid)};
+ Assert(inserted);
+ } else {
+ Assert(transactions.empty());
+ }
+ },
+ [&] { // GetTxForNode()
+ const NodeId nodeid{ExistentOrNewNodeId()};
+
+ const auto opt_tx{pb.GetTxForNode(nodeid)};
+
+ if (nodes_sent_to.contains(nodeid)) {
+ Assert(opt_tx.has_value());
+ Assert(transactions.contains(opt_tx.value()));
+ } else {
+ Assert(!opt_tx.has_value());
+ }
+ },
+ [&] { // NodeConfirmedReception()
+ const NodeId nodeid{ExistentOrNewNodeId()};
+
+ pb.NodeConfirmedReception(nodeid);
+
+ if (nodes_sent_to.contains(nodeid)) {
+ // nodeid was previously passed to PickTxForSend(), so NodeConfirmedReception()
+ // must have changed the internal state. Remember this to later check that
+ // DidNodeConfirmReception() works correctly.
+ nodes_that_confirmed_reception.emplace(nodeid);
+ }
+ },
+ [&] { // DidNodeConfirmReception()
+ const NodeId nodeid{ExistentOrNewNodeId()};
+
+ const bool confirmed{pb.DidNodeConfirmReception(nodeid)};
+
+ if (nodes_that_confirmed_reception.contains(nodeid)) {
+ Assert(confirmed);
+ } else {
+ Assert(!confirmed);
+ }
+ },
+ [&] { // HavePendingTransactions()
+ if (pb.HavePendingTransactions()) {
+ Assert(!transactions.empty());
+ } else {
+ Assert(transactions.empty());
+ }
+ },
+ [&] { // GetStale()
+ const auto stale{pb.GetStale()};
+
+ Assert(stale.size() <= transactions.size());
+
+ for (const auto& stale_tx : stale) {
+ Assert(transactions.contains(stale_tx));
+ }
+ },
+ [&] { // GetBroadcastInfo()
+ const auto all_broadcast_info{pb.GetBroadcastInfo()};
+
+ Assert(all_broadcast_info.size() == transactions.size());
+
+ for (const auto& info : all_broadcast_info) {
+ const auto it{transactions.find(info.tx)};
+ Assert(it != transactions.end());
+ Assert(info.peers.size() == it->second); // exactly the sends we recorded
+ }
+ },
+ [&] {
+ clock_ctx.set(ConsumeTime(fdp));
+ });
+ }
+}
diff --git a/src/test/fuzz/util.h b/src/test/fuzz/util.h
index 335cefb7..04edef0b 100644
--- a/src/test/fuzz/util.h
+++ b/src/test/fuzz/util.h
@@ -46,13 +46,17 @@ size_t CallOneOf(FuzzedDataProvider& fuzzed_data_provider, Callables... callable
}
template <typename Collection>
-auto& PickValue(FuzzedDataProvider& fuzzed_data_provider, Collection& col)
+auto PickIterator(FuzzedDataProvider& fuzzed_data_provider, Collection& col)
{
- auto sz{col.size()};
+ const auto sz{col.size()};
assert(sz >= 1);
- auto it = col.begin();
- std::advance(it, fuzzed_data_provider.ConsumeIntegralInRange<decltype(sz)>(0, sz - 1));
- return *it;
+ return std::next(col.begin(), fuzzed_data_provider.ConsumeIntegralInRange<decltype(sz)>(0, sz - 1));
+}
+
+template <typename Collection>
+auto& PickValue(FuzzedDataProvider& fuzzed_data_provider, Collection& col)
+{
+ return *PickIterator(fuzzed_data_provider, col);
}
template<typename B = uint8_t>
Why this scored 15/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.