coinselection: Track effective_value lookahead

Introduces a dedicated data structure to track the total
effective_value available in the remaining UTXOs at each index of the
UTXO pool. In contrast to the original approach in BnB, this allows us
to immediately jump to a lower index instead of visiting every UTXO to
add back their eff_value to the lookahead.
This commit is contained in:
Murch
2025-03-26 18:24:22 -07:00
parent fa226ab902
commit ba1807b981
3 changed files with 33 additions and 18 deletions

View File

@@ -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;

View File

@@ -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; // 5sat/vB (default, and lower than long_term_feerate of 10sat/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; // 25sat/vB (greater than long_term_feerate of 10sat/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)

View File

@@ -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()));
}
}