coinselection: Track effective_value lookahead
What changed, and why it matters
This commit is a performance optimization to Bitcoin Core's coin-selection algorithm. It pre-computes a running total ('lookahead') of remaining coin values so the Branch-and-Bound search can skip dead-end branches faster. The change is purely algorithmic and does not appear to introduce or fix a security vulnerability. Test expectations are updated only to reflect fewer search attempts.
No security action required. Review as normal code-quality/performance change; verify that the lookahead invariant and sorting order remain correct under all edge cases.
Security signals we found
No security-relevant keywords in commit title or message
No input validation, parsing, or memory-safety changes
Algorithmic optimization only; same selection semantics intended
Test updates are consistent with reduced search-space traversal
No vendor security disclosure or CVE references present
Evidence from the diff
The patch modifies SelectCoinsBnB in src/wallet/coinselection.cpp. Instead of accumulating total_available in a forward pass and then sorting, it sorts first, walks the UTXO pool backwards, and builds a lookahead vector where lookahead[i] equals the sum of effective values of all UTXOs after index i. During search, if curr_amount + lookahead[curr_selection.back()] < selection_target, the branch is cut immediately. This reduces the number of evaluated selections, which is why unit tests lower expected_attempts and one previously-failing 18-UTXO case now succeeds within the attempt limit. No cryptographic, consensus, or authorization logic is touched.
Changed components
src/wallet/coinselection.cppsrc/wallet/test/coinselection_tests.cppsrc/wallet/test/coinselector_tests.cppInspect captured patch +33 / −18
diff --git a/src/wallet/coinselection.cpp b/src/wallet/coinselection.cpp
index cce4c59c..febac429 100644
--- a/src/wallet/coinselection.cpp
+++ b/src/wallet/coinselection.cpp
@@ -68,6 +68,10 @@ struct {
*
* waste = selectionTotal - target + inputs × (currentFeeRate - longTermFeeRate)
*
+ * The algorithm uses one additional optimization: a lookahead keeps track of the total value of
+ * the unexplored UTXOs. A subtree is not explored if the lookahead indicates that the target range
+ * cannot be reached.
+ *
* The Branch and Bound algorithm is described in detail in Murch's Master Thesis:
* https://murch.one/wp-content/uploads/2016/11/erhardt2016coinselection.pdf
*
@@ -87,12 +91,17 @@ static const size_t TOTAL_TRIES = 100000;
util::Result<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CAmount& cost_of_change,
int max_selection_weight)
{
- // Check that there are sufficient funds
+ std::sort(utxo_pool.begin(), utxo_pool.end(), descending);
+ // The sum of UTXO amounts after this UTXO index, e.g. lookahead[5] = Σ(UTXO[6+].amount)
+ std::vector<CAmount> lookahead(utxo_pool.size());
+
+ // Calculate lookahead values, and check that there are sufficient funds
CAmount total_available = 0;
- for (const OutputGroup& utxo : utxo_pool) {
- // Assert UTXOs with non-positive effective value have been filtered
- Assume(utxo.GetSelectionAmount() > 0);
- total_available += utxo.GetSelectionAmount();
+ for (int index = static_cast<int>(utxo_pool.size()) - 1; index >= 0; --index) {
+ lookahead[index] = total_available;
+ // UTXOs with non-positive effective value must have been filtered
+ Assume(utxo_pool[index].GetSelectionAmount() > 0);
+ total_available += utxo_pool[index].GetSelectionAmount();
}
if (total_available < selection_target) {
@@ -100,7 +109,6 @@ util::Result<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool
return util::Error();
}
- std::sort(utxo_pool.begin(), utxo_pool.end(), descending);
// The current selection and the best input set found so far, stored as the utxo_pool indices of the UTXOs forming them
std::vector<size_t> curr_selection;
@@ -146,7 +154,10 @@ util::Result<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool
++curr_try;
// EVALUATE current selection: check for solutions and see whether we can CUT or SHIFT before EXPLORING further
- if (curr_weight > max_selection_weight) {
+ if (curr_amount + lookahead[curr_selection.back()] < selection_target) {
+ // Insufficient funds with lookahead: CUT
+ should_cut = true;
+ } else if (curr_weight > max_selection_weight) {
// max_weight exceeded: SHIFT
max_tx_weight_exceeded = true;
should_shift = true;
diff --git a/src/wallet/test/coinselection_tests.cpp b/src/wallet/test/coinselection_tests.cpp
index 6a0d5e48..e1b1b074 100644
--- a/src/wallet/test/coinselection_tests.cpp
+++ b/src/wallet/test/coinselection_tests.cpp
@@ -152,9 +152,9 @@ BOOST_AUTO_TEST_CASE(bnb_test)
// Simple success cases
TestBnBSuccess("Select smallest UTXO", utxo_pool, /*selection_target=*/1 * CENT, /*expected_input_amounts=*/{1 * CENT}, /*expected_attempts=*/3, cs_params);
TestBnBSuccess("Select middle UTXO", utxo_pool, /*selection_target=*/3 * CENT, /*expected_input_amounts=*/{3 * CENT}, /*expected_attempts=*/3, cs_params);
- TestBnBSuccess("Select biggest UTXO", utxo_pool, /*selection_target=*/5 * CENT, /*expected_input_amounts=*/{5 * CENT}, /*expected_attempts=*/4, cs_params);
+ TestBnBSuccess("Select biggest UTXO", utxo_pool, /*selection_target=*/5 * CENT, /*expected_input_amounts=*/{5 * CENT}, /*expected_attempts=*/2, cs_params);
TestBnBSuccess("Select two UTXOs", utxo_pool, /*selection_target=*/4 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, /*expected_attempts=*/4, cs_params);
- TestBnBSuccess("Select all UTXOs", utxo_pool, /*selection_target=*/9 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT, 5 * CENT}, /*expected_attempts=*/7, cs_params);
+ TestBnBSuccess("Select all UTXOs", utxo_pool, /*selection_target=*/9 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT, 5 * CENT}, /*expected_attempts=*/5, cs_params);
// BnB finds changeless solution while overshooting by up to cost_of_change
TestBnBSuccess("Select upper bound", utxo_pool, /*selection_target=*/4 * CENT - cs_params.m_cost_of_change, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, /*expected_attempts=*/4, cs_params);
@@ -207,12 +207,16 @@ BOOST_AUTO_TEST_CASE(bnb_test)
}
}
AddCoins(doppelganger_pool, doppelgangers, cs_params);
- // Among up to 17 unique UTXOs of similar effective value we will find a solution composed of the eight smallest UTXOs
- TestBnBSuccess("Combine smallest 8 of 17 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, /*expected_input_amounts=*/expected_inputs, /*expected_attempts=*/65'535, cs_params);
+ // Among 17 unique UTXOs of similar effective value we will find a solution composed of the eight smallest UTXOs
+ TestBnBSuccess("Combine smallest 8 of 17 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, /*expected_input_amounts=*/expected_inputs, /*expected_attempts=*/51'765, cs_params);
- // Starting with 18 unique UTXOs of similar effective value we will not find the solution due to exceeding the attempt limit
+ // Among up to 18 unique UTXOs of similar effective value we will find a solution composed of the eight smallest UTXOs
AddCoins(doppelganger_pool, {1 * CENT + cs_params.m_cost_of_change + 17}, cs_params);
- TestBnBFail("Exhaust looking for smallest 8 of 18 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, cs_params);
+ TestBnBSuccess("Combine smallest 8 of 18 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, /*expected_input_amounts=*/expected_inputs, /*expected_attempts=*/87'957, cs_params);
+
+ // Starting with 19 unique UTXOs of similar effective value we will not find the solution due to exceeding the attempt limit
+ AddCoins(doppelganger_pool, {1 * CENT + cs_params.m_cost_of_change + 18}, cs_params);
+ TestBnBFail("Exhaust looking for smallest 8 of 19 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, cs_params);
}
}
@@ -221,21 +225,21 @@ BOOST_AUTO_TEST_CASE(bnb_feerate_sensitivity_test)
// Create sets of UTXOs with the same effective amounts at different feerates (but different absolute amounts)
std::vector<OutputGroup> low_feerate_pool; // 5 sat/vB (default, and lower than long_term_feerate of 10 sat/vB)
AddCoins(low_feerate_pool, {2 * CENT, 3 * CENT, 5 * CENT, 10 * CENT});
- TestBnBSuccess("Select many inputs at low feerates", low_feerate_pool, /*selection_target=*/10 * CENT, /*expected_input_amounts=*/{2 * CENT, 3 * CENT, 5 * CENT}, /*expected_attempts=*/8);
+ TestBnBSuccess("Select many inputs at low feerates", low_feerate_pool, /*selection_target=*/10 * CENT, /*expected_input_amounts=*/{2 * CENT, 3 * CENT, 5 * CENT}, /*expected_attempts=*/6);
const CoinSelectionParams high_feerate_params = init_cs_params(/*eff_feerate=*/25'000);
std::vector<OutputGroup> high_feerate_pool; // 25 sat/vB (greater than long_term_feerate of 10 sat/vB)
AddCoins(high_feerate_pool, {2 * CENT, 3 * CENT, 5 * CENT, 10 * CENT}, high_feerate_params);
- TestBnBSuccess("Select one input at high feerates", high_feerate_pool, /*selection_target=*/10 * CENT, /*expected_input_amounts=*/{10 * CENT}, /*expected_attempts=*/7, high_feerate_params);
+ TestBnBSuccess("Select one input at high feerates", high_feerate_pool, /*selection_target=*/10 * CENT, /*expected_input_amounts=*/{10 * CENT}, /*expected_attempts=*/5, high_feerate_params);
// Add heavy inputs {6, 7} to existing {2, 3, 5, 10}
low_feerate_pool.push_back(MakeCoin(6 * CENT, true, default_cs_params, /*custom_spending_vsize=*/500));
low_feerate_pool.push_back(MakeCoin(7 * CENT, true, default_cs_params, /*custom_spending_vsize=*/500));
- TestBnBSuccess("Prefer two heavy inputs over two light inputs at low feerates", low_feerate_pool, /*selection_target=*/13 * CENT, /*expected_input_amounts=*/{6 * CENT, 7 * CENT}, /*expected_attempts=*/28, default_cs_params, /*custom_spending_vsize=*/500);
+ TestBnBSuccess("Prefer two heavy inputs over two light inputs at low feerates", low_feerate_pool, /*selection_target=*/13 * CENT, /*expected_input_amounts=*/{6 * CENT, 7 * CENT}, /*expected_attempts=*/18, default_cs_params, /*custom_spending_vsize=*/500);
high_feerate_pool.push_back(MakeCoin(6 * CENT, true, high_feerate_params, /*custom_spending_vsize=*/500));
high_feerate_pool.push_back(MakeCoin(7 * CENT, true, high_feerate_params, /*custom_spending_vsize=*/500));
- TestBnBSuccess("Prefer two light inputs over two heavy inputs at high feerates", high_feerate_pool, /*selection_target=*/13 * CENT, /*expected_input_amounts=*/{3 * CENT, 10 * CENT}, /*expected_attempts=*/15, high_feerate_params);
+ TestBnBSuccess("Prefer two light inputs over two heavy inputs at high feerates", high_feerate_pool, /*selection_target=*/13 * CENT, /*expected_input_amounts=*/{3 * CENT, 10 * CENT}, /*expected_attempts=*/9, high_feerate_params);
}
static void TestSRDSuccess(std::string test_title, std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CoinSelectionParams& cs_params = default_cs_params, const int max_selection_weight = MAX_STANDARD_TX_WEIGHT)
diff --git a/src/wallet/test/coinselector_tests.cpp b/src/wallet/test/coinselector_tests.cpp
index f393b473..126c1883 100644
--- a/src/wallet/test/coinselector_tests.cpp
+++ b/src/wallet/test/coinselector_tests.cpp
@@ -297,7 +297,7 @@ BOOST_AUTO_TEST_CASE(bnb_search_test)
add_coin(5 * CENT, 2, expected_result);
add_coin(3 * CENT, 2, expected_result);
BOOST_CHECK(EquivalentResult(expected_result, *res));
- expected_attempts = 39;
+ expected_attempts = 25;
BOOST_CHECK_MESSAGE(res->GetSelectionsEvaluated() == expected_attempts, strprintf("Expected %i attempts, but got %i", expected_attempts, res->GetSelectionsEvaluated()));
}
}
Why this scored 20/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.