Skip to content

Commit 395b932

Browse files
committed
Merge bitcoin/bitcoin#26720: wallet: coin selection, don't return results that exceed the max allowed weight
25ab147 refactor: coinselector_tests, unify wallet creation code (furszy) ba9431c test: coverage for bnb max weight (furszy) 5a2bc45 wallet: clean post coin selection max weight filter (furszy) 2d11258 coin selection: BnB, don't return selection if exceeds max allowed tx weight (furszy) d3a1c09 test: coin selection, add coverage for SRD (furszy) 9d9689e coin selection: heap-ify SRD, don't return selection if exceeds max tx weight (furszy) 6107ec2 coin selection: knapsack, select closest UTXO above target if result exceeds max tx size (furszy) 1284223 wallet: refactor coin selection algos to return util::Result (furszy) Pull request description: Coming from the following comment bitcoin/bitcoin#25729 (comment). The reason why we are adding hundreds of UTXO from different sources when the target amount is covered only by one of them is because only SRD returns a usable result. Context: In the test, we create 1515 UTXOs with 0.033 BTC each, and 1 UTXO with 50 BTC. Then perform Coin Selection to fund 49.5 BTC. As the selection of the 1515 small UTXOs exceeds the max allowed tx size, the expectation here is to receive a selection result that only contain the big UTXO. Which is not happening for the following reason: Knapsack returns a result that exceeds the max allowed transaction size, when it should return the closest utxo above the target, so we fallback to SRD who selects coins randomly up until the target is met. So we end up with a selection result with lot more coins than what is needed. ACKs for top commit: S3RK: ACK 25ab147 achow101: ACK 25ab147 Xekyo: reACK 25ab147 theStack: Code-review ACK 25ab147 Tree-SHA512: 2425de4cc479b4db999b3b2e02eb522a2130a06379cca0418672a51c4076971a1d427191173820db76a0f85a8edfff100114e1c38fb3b5dc51598d07cabe1a60
2 parents 5aa0c82 + 25ab147 commit 395b932

File tree

6 files changed

+287
-103
lines changed

6 files changed

+287
-103
lines changed

src/bench/coin_selection.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <bench/bench.h>
66
#include <interfaces/chain.h>
77
#include <node/context.h>
8+
#include <policy/policy.h>
89
#include <wallet/coinselection.h>
910
#include <wallet/spend.h>
1011
#include <wallet/wallet.h>
@@ -115,7 +116,7 @@ static void BnBExhaustion(benchmark::Bench& bench)
115116
bench.run([&] {
116117
// Benchmark
117118
CAmount target = make_hard_case(17, utxo_pool);
118-
SelectCoinsBnB(utxo_pool, target, 0); // Should exhaust
119+
SelectCoinsBnB(utxo_pool, target, 0, MAX_STANDARD_TX_WEIGHT); // Should exhaust
119120

120121
// Cleanup
121122
utxo_pool.clear();

src/wallet/coinselection.cpp

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@
1313

1414
#include <numeric>
1515
#include <optional>
16+
#include <queue>
1617

1718
namespace wallet {
19+
// Common selection error across the algorithms
20+
static util::Result<SelectionResult> ErrorMaxWeightExceeded()
21+
{
22+
return util::Error{_("The inputs size exceeds the maximum weight. "
23+
"Please try sending a smaller amount or manually consolidating your wallet's UTXOs")};
24+
}
25+
1826
// Descending order comparator
1927
struct {
2028
bool operator()(const OutputGroup& a, const OutputGroup& b) const
@@ -63,11 +71,13 @@ struct {
6371

6472
static const size_t TOTAL_TRIES = 100000;
6573

66-
std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CAmount& cost_of_change)
74+
util::Result<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CAmount& cost_of_change,
75+
int max_weight)
6776
{
6877
SelectionResult result(selection_target, SelectionAlgorithm::BNB);
6978
CAmount curr_value = 0;
7079
std::vector<size_t> curr_selection; // selected utxo indexes
80+
int curr_selection_weight = 0; // sum of selected utxo weight
7181

7282
// Calculate curr_available_value
7383
CAmount curr_available_value = 0;
@@ -78,7 +88,7 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
7888
curr_available_value += utxo.GetSelectionAmount();
7989
}
8090
if (curr_available_value < selection_target) {
81-
return std::nullopt;
91+
return util::Error();
8292
}
8393

8494
// Sort the utxo_pool
@@ -89,6 +99,7 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
8999
CAmount best_waste = MAX_MONEY;
90100

91101
bool is_feerate_high = utxo_pool.at(0).fee > utxo_pool.at(0).long_term_fee;
102+
bool max_tx_weight_exceeded = false;
92103

93104
// Depth First search loop for choosing the UTXOs
94105
for (size_t curr_try = 0, utxo_pool_index = 0; curr_try < TOTAL_TRIES; ++curr_try, ++utxo_pool_index) {
@@ -98,6 +109,9 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
98109
curr_value > selection_target + cost_of_change || // Selected value is out of range, go back and try other branch
99110
(curr_waste > best_waste && is_feerate_high)) { // Don't select things which we know will be more wasteful if the waste is increasing
100111
backtrack = true;
112+
} else if (curr_selection_weight > max_weight) { // Exceeding weight for standard tx, cannot find more solutions by adding more inputs
113+
max_tx_weight_exceeded = true; // at least one selection attempt exceeded the max weight
114+
backtrack = true;
101115
} else if (curr_value >= selection_target) { // Selected value is within range
102116
curr_waste += (curr_value - selection_target); // This is the excess value which is added to the waste for the below comparison
103117
// Adding another UTXO after this check could bring the waste down if the long term fee is higher than the current fee.
@@ -127,6 +141,7 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
127141
OutputGroup& utxo = utxo_pool.at(utxo_pool_index);
128142
curr_value -= utxo.GetSelectionAmount();
129143
curr_waste -= utxo.fee - utxo.long_term_fee;
144+
curr_selection_weight -= utxo.m_weight;
130145
curr_selection.pop_back();
131146
} else { // Moving forwards, continuing down this branch
132147
OutputGroup& utxo = utxo_pool.at(utxo_pool_index);
@@ -146,13 +161,14 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
146161
curr_selection.push_back(utxo_pool_index);
147162
curr_value += utxo.GetSelectionAmount();
148163
curr_waste += utxo.fee - utxo.long_term_fee;
164+
curr_selection_weight += utxo.m_weight;
149165
}
150166
}
151167
}
152168

153169
// Check for solution
154170
if (best_selection.empty()) {
155-
return std::nullopt;
171+
return max_tx_weight_exceeded ? ErrorMaxWeightExceeded() : util::Error();
156172
}
157173

158174
// Set output set
@@ -165,9 +181,20 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
165181
return result;
166182
}
167183

168-
std::optional<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value, FastRandomContext& rng)
184+
class MinOutputGroupComparator
185+
{
186+
public:
187+
int operator() (const OutputGroup& group1, const OutputGroup& group2) const
188+
{
189+
return group1.GetSelectionAmount() > group2.GetSelectionAmount();
190+
}
191+
};
192+
193+
util::Result<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value, FastRandomContext& rng,
194+
int max_weight)
169195
{
170196
SelectionResult result(target_value, SelectionAlgorithm::SRD);
197+
std::priority_queue<OutputGroup, std::vector<OutputGroup>, MinOutputGroupComparator> heap;
171198

172199
// Include change for SRD as we want to avoid making really small change if the selection just
173200
// barely meets the target. Just use the lower bound change target instead of the randomly
@@ -181,16 +208,40 @@ std::optional<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& ut
181208
Shuffle(indexes.begin(), indexes.end(), rng);
182209

183210
CAmount selected_eff_value = 0;
211+
int weight = 0;
212+
bool max_tx_weight_exceeded = false;
184213
for (const size_t i : indexes) {
185214
const OutputGroup& group = utxo_pool.at(i);
186215
Assume(group.GetSelectionAmount() > 0);
216+
217+
// Add group to selection
218+
heap.push(group);
187219
selected_eff_value += group.GetSelectionAmount();
188-
result.AddInput(group);
220+
weight += group.m_weight;
221+
222+
// If the selection weight exceeds the maximum allowed size, remove the least valuable inputs until we
223+
// are below max weight.
224+
if (weight > max_weight) {
225+
max_tx_weight_exceeded = true; // mark it in case we don't find any useful result.
226+
do {
227+
const OutputGroup& to_remove_group = heap.top();
228+
selected_eff_value -= to_remove_group.GetSelectionAmount();
229+
weight -= to_remove_group.m_weight;
230+
heap.pop();
231+
} while (!heap.empty() && weight > max_weight);
232+
}
233+
234+
// Now check if we are above the target
189235
if (selected_eff_value >= target_value) {
236+
// Result found, add it.
237+
while (!heap.empty()) {
238+
result.AddInput(heap.top());
239+
heap.pop();
240+
}
190241
return result;
191242
}
192243
}
193-
return std::nullopt;
244+
return max_tx_weight_exceeded ? ErrorMaxWeightExceeded() : util::Error();
194245
}
195246

196247
/** Find a subset of the OutputGroups that is at least as large as, but as close as possible to, the
@@ -252,8 +303,8 @@ static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::v
252303
}
253304
}
254305

255-
std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue,
256-
CAmount change_target, FastRandomContext& rng)
306+
util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue,
307+
CAmount change_target, FastRandomContext& rng, int max_weight)
257308
{
258309
SelectionResult result(nTargetValue, SelectionAlgorithm::KNAPSACK);
259310

@@ -286,7 +337,7 @@ std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups,
286337
}
287338

288339
if (nTotalLower < nTargetValue) {
289-
if (!lowest_larger) return std::nullopt;
340+
if (!lowest_larger) return util::Error();
290341
result.AddInput(*lowest_larger);
291342
return result;
292343
}
@@ -313,6 +364,16 @@ std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups,
313364
}
314365
}
315366

367+
// If the result exceeds the maximum allowed size, return closest UTXO above the target
368+
if (result.GetWeight() > max_weight) {
369+
// No coin above target, nothing to do.
370+
if (!lowest_larger) return ErrorMaxWeightExceeded();
371+
372+
// Return closest UTXO above target
373+
result.Clear();
374+
result.AddInput(*lowest_larger);
375+
}
376+
316377
if (LogAcceptCategory(BCLog::SELECTCOINS, BCLog::Level::Debug)) {
317378
std::string log_message{"Coin selection best subset: "};
318379
for (unsigned int i = 0; i < applicable_groups.size(); i++) {

src/wallet/coinselection.h

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <random.h>
1414
#include <util/system.h>
1515
#include <util/check.h>
16+
#include <util/result.h>
1617

1718
#include <optional>
1819

@@ -408,20 +409,24 @@ struct SelectionResult
408409
int GetWeight() const { return m_weight; }
409410
};
410411

411-
std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CAmount& cost_of_change);
412+
util::Result<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CAmount& cost_of_change,
413+
int max_weight);
412414

413415
/** Select coins by Single Random Draw. OutputGroups are selected randomly from the eligible
414416
* outputs until the target is satisfied
415417
*
416418
* @param[in] utxo_pool The positive effective value OutputGroups eligible for selection
417419
* @param[in] target_value The target value to select for
418-
* @returns If successful, a SelectionResult, otherwise, std::nullopt
420+
* @param[in] rng The randomness source to shuffle coins
421+
* @param[in] max_weight The maximum allowed weight for a selection result to be valid
422+
* @returns If successful, a valid SelectionResult, otherwise, util::Error
419423
*/
420-
std::optional<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value, FastRandomContext& rng);
424+
util::Result<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value, FastRandomContext& rng,
425+
int max_weight);
421426

422427
// Original coin selection algorithm as a fallback
423-
std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue,
424-
CAmount change_target, FastRandomContext& rng);
428+
util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue,
429+
CAmount change_target, FastRandomContext& rng, int max_weight);
425430
} // namespace wallet
426431

427432
#endif // BITCOIN_WALLET_COINSELECTION_H

src/wallet/spend.cpp

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -559,42 +559,45 @@ util::Result<SelectionResult> ChooseSelectionResult(const CAmount& nTargetValue,
559559
{
560560
// Vector of results. We will choose the best one based on waste.
561561
std::vector<SelectionResult> results;
562+
std::vector<util::Result<SelectionResult>> errors;
563+
auto append_error = [&] (const util::Result<SelectionResult>& result) {
564+
// If any specific error message appears here, then something different from a simple "no selection found" happened.
565+
// Let's save it, so it can be retrieved to the user if no other selection algorithm succeeded.
566+
if (HasErrorMsg(result)) {
567+
errors.emplace_back(result);
568+
}
569+
};
570+
571+
// Maximum allowed weight
572+
int max_inputs_weight = MAX_STANDARD_TX_WEIGHT - (coin_selection_params.tx_noinputs_size * WITNESS_SCALE_FACTOR);
562573

563-
if (auto bnb_result{SelectCoinsBnB(groups.positive_group, nTargetValue, coin_selection_params.m_cost_of_change)}) {
574+
if (auto bnb_result{SelectCoinsBnB(groups.positive_group, nTargetValue, coin_selection_params.m_cost_of_change, max_inputs_weight)}) {
564575
results.push_back(*bnb_result);
565-
}
576+
} else append_error(bnb_result);
577+
578+
// As Knapsack and SRD can create change, also deduce change weight.
579+
max_inputs_weight -= (coin_selection_params.change_output_size * WITNESS_SCALE_FACTOR);
566580

567581
// The knapsack solver has some legacy behavior where it will spend dust outputs. We retain this behavior, so don't filter for positive only here.
568-
if (auto knapsack_result{KnapsackSolver(groups.mixed_group, nTargetValue, coin_selection_params.m_min_change_target, coin_selection_params.rng_fast)}) {
582+
if (auto knapsack_result{KnapsackSolver(groups.mixed_group, nTargetValue, coin_selection_params.m_min_change_target, coin_selection_params.rng_fast, max_inputs_weight)}) {
569583
knapsack_result->ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
570584
results.push_back(*knapsack_result);
571-
}
585+
} else append_error(knapsack_result);
572586

573-
if (auto srd_result{SelectCoinsSRD(groups.positive_group, nTargetValue, coin_selection_params.rng_fast)}) {
587+
if (auto srd_result{SelectCoinsSRD(groups.positive_group, nTargetValue, coin_selection_params.rng_fast, max_inputs_weight)}) {
574588
srd_result->ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
575589
results.push_back(*srd_result);
576-
}
590+
} else append_error(srd_result);
577591

578592
if (results.empty()) {
579-
// No solution found
580-
return util::Error();
581-
}
582-
583-
std::vector<SelectionResult> eligible_results;
584-
std::copy_if(results.begin(), results.end(), std::back_inserter(eligible_results), [coin_selection_params](const SelectionResult& result) {
585-
const auto initWeight{coin_selection_params.tx_noinputs_size * WITNESS_SCALE_FACTOR};
586-
return initWeight + result.GetWeight() <= static_cast<int>(MAX_STANDARD_TX_WEIGHT);
587-
});
588-
589-
if (eligible_results.empty()) {
590-
return util::Error{_("The inputs size exceeds the maximum weight. "
591-
"Please try sending a smaller amount or manually consolidating your wallet's UTXOs")};
593+
// No solution found, retrieve the first explicit error (if any).
594+
// future: add 'severity level' to errors so the worst one can be retrieved instead of the first one.
595+
return errors.empty() ? util::Error() : errors.front();
592596
}
593597

594598
// Choose the result with the least waste
595599
// If the waste is the same, choose the one which spends more inputs.
596-
auto& best_result = *std::min_element(eligible_results.begin(), eligible_results.end());
597-
return best_result;
600+
return *std::min_element(results.begin(), results.end());
598601
}
599602

600603
util::Result<SelectionResult> SelectCoins(const CWallet& wallet, CoinsResult& available_coins, const PreSelectedInputs& pre_set_inputs,

0 commit comments

Comments
 (0)