FUZZ: Test that BnB finds best solution
What changed, and why it matters
This commit adds a new automated fuzz test for Bitcoin Core's coin selection algorithm. It does not change any production wallet code, network behavior, or consensus rules. The test brute-forces small coin-selection problems and checks that the Branch-and-Bound (BnB) solver finds a minimum-waste solution. It is purely a testing/quality-assurance addition.
No security action required. Treat as a normal QA/test-harness addition. Reviewers may optionally verify that the fuzz test's brute-force oracle correctly models BnB's waste metric and superset-exclusion rule.
Security signals we found
No production code changes
New fuzz test only
No input parsing, networking, consensus, or cryptography changes
No bug fix or vulnerability remediation evident in the diff
Evidence from the diff
The diff introduces a new fuzz target, FUZZ_TARGET(bnb_finds_min_waste), in src/wallet/test/fuzz/coinselection.cpp. It randomly generates up to 16 OutputGroups, a target amount, and coin-selection parameters, then compares the result of SelectCoinsBnB against an exhaustive brute-force search. Assertions verify that BnB returns a valid solution when one exists, that its waste score is no worse than the brute-force optimum, and that if BnB completes the search space it is exactly optimal. No wallet logic is modified.
Changed components
src/wallet/test/fuzz/coinselection.cppInspect captured patch +126 / −0
diff --git a/src/wallet/test/fuzz/coinselection.cpp b/src/wallet/test/fuzz/coinselection.cpp
index a0b2142e..012bc6cb 100644
--- a/src/wallet/test/fuzz/coinselection.cpp
+++ b/src/wallet/test/fuzz/coinselection.cpp
@@ -217,6 +217,132 @@ FUZZ_TARGET(coin_grinder_is_optimal)
assert(!result_cg);
}
+FUZZ_TARGET(bnb_finds_min_waste)
+{
+ FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()};
+
+ FastRandomContext fast_random_context{ConsumeUInt256(fuzzed_data_provider)};
+ CoinSelectionParams coin_params{fast_random_context};
+ coin_params.m_subtract_fee_outputs = false;
+ // Set effective feerate up to 10'000'000 sats per kvB (10'000 sat/vB).
+ coin_params.m_effective_feerate = CFeeRate{ConsumeMoney(fuzzed_data_provider, 10'000'000), 1'000};
+ coin_params.m_long_term_feerate = CFeeRate{ConsumeMoney(fuzzed_data_provider, 10'000'000), 1'000};
+ coin_params.m_discard_feerate = CFeeRate{ConsumeMoney(fuzzed_data_provider, 10'000'000), 1'000};
+ coin_params.m_cost_of_change = ConsumeMoney(fuzzed_data_provider);
+
+ coin_params.change_output_size = fuzzed_data_provider.ConsumeIntegralInRange(1, MAX_SCRIPT_SIZE);
+ coin_params.m_change_fee = coin_params.m_effective_feerate.GetFee(coin_params.change_output_size);
+ coin_params.change_spend_size = fuzzed_data_provider.ConsumeIntegralInRange<int>(41, 1000);
+ const auto change_spend_fee{coin_params.m_discard_feerate.GetFee(coin_params.change_spend_size)};
+ coin_params.m_cost_of_change = coin_params.m_change_fee + change_spend_fee;
+ CScript change_out_script = CScript() << std::vector<unsigned char>(coin_params.change_output_size, OP_TRUE);
+ const auto dust{GetDustThreshold(CTxOut{/*nValueIn=*/0, change_out_script}, coin_params.m_discard_feerate)};
+ coin_params.min_viable_change = std::max(change_spend_fee + 1, dust);
+
+ // Create some coins
+ CAmount max_spendable{0};
+ int next_locktime{0};
+ // Too many output groups (>17?) would make it possible to generate UTXO
+ // pool and target combinations that cannot be completely searched by BnB
+ // before running into the attempt limit (see BnB "Exhaust..." test). The
+ // brute force search also gets exponentially more expensive with bigger
+ // UTXO pools.
+ // Choose 1–16 of 16 provides ample fuzzing space.
+ static constexpr unsigned max_output_groups{16};
+ std::vector<OutputGroup> group_pos;
+ LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), max_output_groups)
+ {
+ // With maximum m_effective_feerate 10'000 s/vB and n_input_bytes = 20'000 B, input_fee <= MAX_MONEY.
+ const int n_input_bytes{fuzzed_data_provider.ConsumeIntegralInRange<int>(1, 20'000)};
+ const CAmount input_fee = coin_params.m_effective_feerate.GetFee(n_input_bytes);
+ // Ensure that each UTXO has at least an effective value of 1 sat
+ const CAmount eff_value{fuzzed_data_provider.ConsumeIntegralInRange<CAmount>(1, MAX_MONEY + group_pos.size() - max_spendable - max_output_groups)};
+ const CAmount amount{eff_value + input_fee};
+ std::vector<COutput> temp_utxo_pool;
+
+ AddCoin(amount, /*n_input=*/0, n_input_bytes, ++next_locktime, temp_utxo_pool, coin_params.m_effective_feerate);
+ max_spendable += eff_value;
+
+ auto output_group = OutputGroup(coin_params);
+ output_group.Insert(std::make_shared<COutput>(temp_utxo_pool.at(0)), /*ancestors=*/0, /*descendants=*/0);
+ group_pos.push_back(output_group);
+ }
+ size_t num_groups = group_pos.size();
+ assert(num_groups <= max_output_groups);
+
+ // Only choose targets below max_spendable
+ const CAmount target{fuzzed_data_provider.ConsumeIntegralInRange<CAmount>(1, std::max(CAmount{1}, max_spendable - coin_params.m_cost_of_change))};
+
+ // Brute force optimal solution (lowest waste, but cannot be superset of another solution)
+ std::vector<uint32_t> solutions;
+ int best_waste{std::numeric_limits<int>::max()};
+ int best_weight{std::numeric_limits<int>::max()};
+ for (uint32_t pattern = 1; (pattern >> num_groups) == 0; ++pattern) {
+ // BnB does not permit adding more inputs to a solution, i.e. a superset of a solution cannot ever be a solution.
+ // The search pattern guarantees that any superset will only be visited after all its subsets have been traversed.
+ bool is_superset = false;
+ for (uint32_t sol : solutions) {
+ if ((pattern & sol) == sol) {
+ is_superset = true;
+ break;
+ }
+ }
+ if (is_superset) {
+ continue;
+ }
+
+ CAmount subset_amount{0};
+ CAmount subset_waste{0};
+ int subset_weight{0};
+ for (unsigned i = 0; i < num_groups; ++i) {
+ if ((pattern >> i) & 1) {
+ subset_amount += group_pos[i].GetSelectionAmount();
+ subset_waste += group_pos[i].fee - group_pos[i].long_term_fee;
+ subset_weight += group_pos[i].m_weight;
+ }
+ }
+ if (subset_amount >= target && subset_amount <= target + coin_params.m_cost_of_change) {
+ solutions.push_back(pattern);
+ // Add the excess (overselection that gets dropped to fees) to waste score
+ CAmount excess = subset_amount - target;
+ subset_waste += excess;
+ SelectionResult result_bf(target, SelectionAlgorithm::MANUAL);
+
+ for (unsigned i = 0; i < num_groups; ++i) {
+ if ((pattern >> i) & 1) {
+ result_bf.AddInput(group_pos[i]);
+ }
+ }
+ if (subset_waste < best_waste) {
+ best_waste = subset_waste;
+ result_bf.RecalculateWaste(coin_params.min_viable_change, coin_params.m_cost_of_change, coin_params.m_change_fee);
+ assert(result_bf.GetWaste() == best_waste);
+ best_weight = subset_weight;
+ }
+ }
+ }
+
+ int high_max_selection_weight = fuzzed_data_provider.ConsumeIntegralInRange<int>(best_weight, std::numeric_limits<int>::max());
+ auto result_bnb = SelectCoinsBnB(group_pos, target, coin_params.m_cost_of_change, high_max_selection_weight);
+
+ if (!solutions.size() || !result_bnb) {
+ // Either both BnB and Brute Force find a solution or neither does.
+ assert(!result_bnb == !solutions.size());
+ } else {
+ // If brute forcing found a solution with an acceptable weight, BnB must find at least one solution with at most 16 output groups
+ assert(result_bnb);
+ result_bnb->RecalculateWaste(coin_params.min_viable_change, coin_params.m_cost_of_change, coin_params.m_change_fee);
+ assert(result_bnb->GetWeight() <= high_max_selection_weight);
+ assert(result_bnb->GetSelectedEffectiveValue() >= target);
+ assert(result_bnb->GetSelectedEffectiveValue() <= target + coin_params.m_cost_of_change);
+ assert(best_waste <= result_bnb->GetWaste());
+ if (result_bnb->GetAlgoCompleted()) {
+ // If BnB exhausted the search space, it must return an optimal solution (tied on waste score)
+ assert(best_waste == result_bnb->GetWaste());
+ }
+ }
+}
+
enum class CoinSelectionAlgorithm {
BNB,
SRD,
Why this scored 14/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.