FUZZ: Test that BnB finds best solution

BnB’s solution is the input set with the lowest waste score, excluding
any supersets of other solution candidates.
This fuzz test compares a brute force search with the BnB result to
ensure that BnB succeeds.
This commit is contained in:
Murch
2025-07-03 15:01:10 -07:00
parent 51ccc71b1b
commit 54d0393058

View File

@@ -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 116 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,