diff --git a/src/wallet/test/fuzz/coinselection.cpp b/src/wallet/test/fuzz/coinselection.cpp index a0b2142e604..012bc6cb424 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(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(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 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(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(1, MAX_MONEY + group_pos.size() - max_spendable - max_output_groups)}; + const CAmount amount{eff_value + input_fee}; + std::vector 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(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(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 solutions; + int best_waste{std::numeric_limits::max()}; + int best_weight{std::numeric_limits::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(best_weight, std::numeric_limits::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,