Bitcoin Core 31.99.0
P2P Digital Currency
coinselection_tests.cpp
Go to the documentation of this file.
1// Copyright (c) 2024-present The Bitcoin Core developers
2// Distributed under the MIT software license, see the accompanying
3// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4
5#include <consensus/amount.h>
6#include <policy/policy.h>
9
10#include <boost/test/unit_test.hpp>
11
12namespace wallet {
13BOOST_FIXTURE_TEST_SUITE(coinselection_tests, TestingSetup)
14
15static int next_lock_time = 0;
17
18static const int P2WPKH_INPUT_VSIZE = 68;
19static const int P2WPKH_OUTPUT_VSIZE = 31;
20
34static const std::vector<int> FEERATES = {0, 1, 99, 100, 315, 1'000, 2'345, 10'292, 59'764, 1'500'000};
35
40static CoinSelectionParams init_cs_params(int eff_feerate = 5000)
41{
43 /*rng_fast=*/default_rand,
44 /*change_output_size=*/P2WPKH_OUTPUT_VSIZE,
45 /*change_spend_size=*/P2WPKH_INPUT_VSIZE,
46 /*min_change_target=*/50'000,
47 /*effective_feerate=*/CFeeRate(eff_feerate),
48 /*long_term_feerate=*/CFeeRate(10'000),
49 /*discard_feerate=*/CFeeRate(3000),
50 /*tx_noinputs_size=*/11 + P2WPKH_OUTPUT_VSIZE, //static header size + output size
51 /*avoid_partial=*/false,
52 };
53 csp.m_change_fee = csp.m_effective_feerate.GetFee(csp.change_output_size); // 155 sats for default feerate of 5000 s/kvB
54 csp.min_viable_change = /*204 sats=*/csp.m_discard_feerate.GetFee(csp.change_spend_size);
55 csp.m_cost_of_change = csp.min_viable_change + csp.m_change_fee; // 204 + 155 sats for default feerate of 5000 s/kvB
56 csp.m_subtract_fee_outputs = false;
57 return csp;
58}
59
61
63static OutputGroup MakeCoin(const CAmount& amount, bool is_eff_value = true, CoinSelectionParams cs_params = default_cs_params, int custom_spending_vsize = P2WPKH_INPUT_VSIZE)
64{
65 // Always assume that we only have one input
67 tx.vout.resize(1);
68 CAmount fees = cs_params.m_effective_feerate.GetFee(custom_spending_vsize);
69 tx.vout[0].nValue = amount + int(is_eff_value) * fees;
70 tx.nLockTime = next_lock_time++; // so all transactions get different hashes
71 OutputGroup group(cs_params);
72 group.Insert(std::make_shared<COutput>(COutPoint(tx.GetHash(), 0), tx.vout.at(0), /*depth=*/1, /*input_bytes=*/custom_spending_vsize, /*solvable=*/true, /*safe=*/true, /*time=*/0, /*from_me=*/false, /*fees=*/fees), /*ancestors=*/0, /*cluster_count=*/0);
73 return group;
74}
75
77static void AddCoins(std::vector<OutputGroup>& utxo_pool, std::vector<CAmount> coins, CoinSelectionParams cs_params = default_cs_params)
78{
79 for (CAmount c : coins) {
80 utxo_pool.push_back(MakeCoin(c, true, cs_params));
81 }
82}
83
85static void AddDuplicateCoins(std::vector<OutputGroup>& utxo_pool, int count, int amount, CoinSelectionParams cs_params = default_cs_params) {
86 for (int i = 0 ; i < count; ++i) {
87 utxo_pool.push_back(MakeCoin(amount, true, cs_params));
88 }
89}
90
94{
95 std::vector<CAmount> a_amts;
96 std::vector<CAmount> b_amts;
97 for (const auto& coin : a.GetInputSet()) {
98 a_amts.push_back(coin->txout.nValue);
99 }
100 for (const auto& coin : b.GetInputSet()) {
101 b_amts.push_back(coin->txout.nValue);
102 }
103 std::sort(a_amts.begin(), a_amts.end());
104 std::sort(b_amts.begin(), b_amts.end());
105
106 auto ret = std::mismatch(a_amts.begin(), a_amts.end(), b_amts.begin());
107 return ret.first == a_amts.end() && ret.second == b_amts.end();
108}
109
110static std::string InputAmountsToString(const SelectionResult& selection)
111{
112 return "[" + util::Join(selection.GetInputSet(), " ", [](const auto& input){ return util::ToString(input->txout.nValue);}) + "]";
113}
114
115static void TestBnBSuccess(std::string test_title, std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const std::vector<CAmount>& expected_input_amounts, const CoinSelectionParams& cs_params = default_cs_params, const int custom_spending_vsize = P2WPKH_INPUT_VSIZE, const int max_selection_weight = MAX_STANDARD_TX_WEIGHT)
116{
118 CAmount expected_amount = 0;
119 for (CAmount input_amount : expected_input_amounts) {
120 OutputGroup group = MakeCoin(input_amount, true, cs_params, custom_spending_vsize);
121 expected_amount += group.m_value;
122 expected_result.AddInput(group);
123 }
124
125 const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/cs_params.m_cost_of_change, max_selection_weight);
126 BOOST_CHECK_MESSAGE(result, "Falsy result in BnB-Success: " + test_title);
127 BOOST_CHECK_MESSAGE(HaveEquivalentValues(expected_result, *result), strprintf("Result mismatch in BnB-Success: %s. Expected %s, but got %s", test_title, InputAmountsToString(expected_result), InputAmountsToString(*result)));
128 BOOST_CHECK_MESSAGE(result->GetSelectedValue() == expected_amount, strprintf("Selected amount mismatch in BnB-Success: %s. Expected %d, but got %d", test_title, expected_amount, result->GetSelectedValue()));
129 BOOST_CHECK_MESSAGE(result->GetWeight() <= max_selection_weight, strprintf("Selected weight is higher than permitted in BnB-Success: %s. Expected %d, but got %d", test_title, max_selection_weight, result->GetWeight()));
130}
131
132static void TestBnBFail(std::string test_title, std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CoinSelectionParams& cs_params = default_cs_params, int max_selection_weight = MAX_STANDARD_TX_WEIGHT, const bool expect_max_weight_exceeded = false)
133{
134 const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/cs_params.m_cost_of_change, max_selection_weight);
135 BOOST_CHECK_MESSAGE(!result, "BnB-Fail: " + test_title);
136 bool max_weight_exceeded = util::ErrorString(result).original.find("The inputs size exceeds the maximum weight") != std::string::npos;
137 BOOST_CHECK(expect_max_weight_exceeded == max_weight_exceeded);
138}
139
141{
142 for (int feerate : FEERATES) {
143 std::vector<OutputGroup> utxo_pool;
144
145 const CoinSelectionParams cs_params = init_cs_params(feerate);
146
147 TestBnBFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT, cs_params);
148
149 AddCoins(utxo_pool, {1 * CENT, 3 * CENT, 5 * CENT}, cs_params);
150
151 // Simple success cases
152 TestBnBSuccess("Select smallest UTXO", utxo_pool, /*selection_target=*/1 * CENT, /*expected_input_amounts=*/{1 * CENT}, cs_params);
153 TestBnBSuccess("Select middle UTXO", utxo_pool, /*selection_target=*/3 * CENT, /*expected_input_amounts=*/{3 * CENT}, cs_params);
154 TestBnBSuccess("Select biggest UTXO", utxo_pool, /*selection_target=*/5 * CENT, /*expected_input_amounts=*/{5 * CENT}, cs_params);
155 TestBnBSuccess("Select two UTXOs", utxo_pool, /*selection_target=*/4 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, cs_params);
156 TestBnBSuccess("Select all UTXOs", utxo_pool, /*selection_target=*/9 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT, 5 * CENT}, cs_params);
157
158 // BnB finds changeless solution while overshooting by up to cost_of_change
159 TestBnBSuccess("Select upper bound", utxo_pool, /*selection_target=*/4 * CENT - cs_params.m_cost_of_change, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, cs_params);
160
161 // BnB fails to find changeless solution when overshooting by cost_of_change + 1 sat
162 TestBnBFail("Overshoot upper bound", utxo_pool, /*selection_target=*/4 * CENT - cs_params.m_cost_of_change - 1, cs_params);
163
164 TestBnBSuccess("Select max weight", utxo_pool, /*selection_target=*/4 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, cs_params, /*custom_spending_vsize=*/P2WPKH_INPUT_VSIZE, /*max_selection_weight=*/4 * 2 * P2WPKH_INPUT_VSIZE);
165
166 TestBnBFail("Exceed max weight", utxo_pool, /*selection_target=*/4 * CENT, cs_params, /*max_selection_weight=*/4 * 2 * P2WPKH_INPUT_VSIZE - 1, /*expect_max_weight_exceeded=*/true);
167
168 // Simple cases without BnB solution
169 TestBnBFail("Smallest combination too big", utxo_pool, /*selection_target=*/0.5 * CENT, cs_params);
170 TestBnBFail("No UTXO combination in target window", utxo_pool, /*selection_target=*/7 * CENT, cs_params);
171 TestBnBFail("Select more than available", utxo_pool, /*selection_target=*/10 * CENT, cs_params);
172
173 // Test skipping of equivalent input sets
174 std::vector<OutputGroup> clone_pool;
175 AddCoins(clone_pool, {2 * CENT, 7 * CENT, 7 * CENT}, cs_params);
176 AddDuplicateCoins(clone_pool, /*count=*/50'000, /*amount=*/5 * CENT, cs_params);
177 TestBnBSuccess("Skip equivalent input sets", clone_pool, /*selection_target=*/16 * CENT, /*expected_input_amounts=*/{2 * CENT, 7 * CENT, 7 * CENT}, cs_params);
178
179 /* Test BnB attempt limit (`TOTAL_TRIES`)
180 *
181 * Generally, on a diverse UTXO pool BnB will quickly pass over UTXOs bigger than the target and then start
182 * combining small counts of UTXOs that in sum remain under the selection_target+cost_of_change. When there are
183 * multiple UTXOs that have matching amount and cost, combinations with equivalent input sets are skipped. The
184 * UTXO pool for this test is specifically crafted to create as much branching as possible. The selection target
185 * is 8 CENT while all UTXOs are slightly bigger than 1 CENT. The smallest eight are 100,000…100,007 sats, while
186 * the larger nine are 100,368…100,375 (i.e., 100,008…100,016 sats plus cost_of_change (359 sats)).
187 *
188 * Because BnB will only select input sets that fall between selection_target and selection_target +
189 * cost_of_change, and the search traverses the UTXO pool from large to small amounts, the search will visit
190 * every single combination of eight inputs. All except the last combination will overshoot by more than
191 * cost_of_change on the eighth input, because the larger nine inputs each exceed 1 CENT by more than
192 * cost_of_change. Only the last combination consisting of the eight smallest UTXOs falls into the target
193 * window.
194 */
195 std::vector<OutputGroup> doppelganger_pool;
196 std::vector<CAmount> doppelgangers;
197 std::vector<CAmount> expected_inputs;
198 for (int i = 0; i < 17; ++i) {
199 if (i < 8) {
200 // The eight smallest UTXOs can be combined to create expected_result
201 doppelgangers.push_back(1 * CENT + i);
202 expected_inputs.push_back(doppelgangers[i]);
203 } else {
204 // Any eight UTXOs including at least one UTXO with the added cost_of_change will exceed target window
205 doppelgangers.push_back(1 * CENT + cs_params.m_cost_of_change + i);
206 }
207 }
208 AddCoins(doppelganger_pool, doppelgangers, cs_params);
209 // Among up to 17 unique UTXOs of similar effective value we will find a solution composed of the eight smallest UTXOs
210 TestBnBSuccess("Combine smallest 8 of 17 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, /*expected_input_amounts=*/expected_inputs, cs_params);
211
212 // Starting with 18 unique UTXOs of similar effective value we will not find the solution due to exceeding the attempt limit
213 AddCoins(doppelganger_pool, {1 * CENT + cs_params.m_cost_of_change + 17}, cs_params);
214 TestBnBFail("Exhaust looking for smallest 8 of 18 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, cs_params);
215 }
216}
217
218BOOST_AUTO_TEST_CASE(bnb_feerate_sensitivity_test)
219{
220 // Create sets of UTXOs with the same effective amounts at different feerates (but different absolute amounts)
221 std::vector<OutputGroup> low_feerate_pool; // 5 sat/vB (default, and lower than long_term_feerate of 10 sat/vB)
222 AddCoins(low_feerate_pool, {2 * CENT, 3 * CENT, 5 * CENT, 10 * CENT});
223 TestBnBSuccess("Select many inputs at low feerates", low_feerate_pool, /*selection_target=*/10 * CENT, /*expected_input_amounts=*/{2 * CENT, 3 * CENT, 5 * CENT});
224
225 const CoinSelectionParams high_feerate_params = init_cs_params(/*eff_feerate=*/25'000);
226 std::vector<OutputGroup> high_feerate_pool; // 25 sat/vB (greater than long_term_feerate of 10 sat/vB)
227 AddCoins(high_feerate_pool, {2 * CENT, 3 * CENT, 5 * CENT, 10 * CENT}, high_feerate_params);
228 TestBnBSuccess("Select one input at high feerates", high_feerate_pool, /*selection_target=*/10 * CENT, /*expected_input_amounts=*/{10 * CENT}, high_feerate_params);
229
230 // Add heavy inputs {6, 7} to existing {2, 3, 5, 10}
231 low_feerate_pool.push_back(MakeCoin(6 * CENT, true, default_cs_params, /*custom_spending_vsize=*/500));
232 low_feerate_pool.push_back(MakeCoin(7 * CENT, true, default_cs_params, /*custom_spending_vsize=*/500));
233 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}, default_cs_params, /*custom_spending_vsize=*/500);
234
235 high_feerate_pool.push_back(MakeCoin(6 * CENT, true, high_feerate_params, /*custom_spending_vsize=*/500));
236 high_feerate_pool.push_back(MakeCoin(7 * CENT, true, high_feerate_params, /*custom_spending_vsize=*/500));
237 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}, high_feerate_params);
238}
239
240static 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)
241{
242 CAmount expected_min_amount = selection_target + cs_params.m_change_fee + CHANGE_LOWER;
243
244 const auto result = SelectCoinsSRD(utxo_pool, selection_target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight);
245 BOOST_CHECK_MESSAGE(result, "Falsy result in SRD-Success: " + test_title);
246 const CAmount selected_effective_value = result->GetSelectedEffectiveValue();
247 BOOST_CHECK_MESSAGE(selected_effective_value >= expected_min_amount, strprintf("Selected effective value is lower than expected in SRD-Success: %s. Expected %d, but got %d", test_title, expected_min_amount, selected_effective_value));
248 BOOST_CHECK_MESSAGE(result->GetWeight() <= max_selection_weight, strprintf("Selected weight is higher than permitted in SRD-Success: %s. Expected %d, but got %d", test_title, max_selection_weight, result->GetWeight()));
249}
250
251static void TestSRDFail(std::string test_title, std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CoinSelectionParams& cs_params = default_cs_params, int max_selection_weight = MAX_STANDARD_TX_WEIGHT, const bool expect_max_weight_exceeded = false)
252{
253 const auto result = SelectCoinsSRD(utxo_pool, selection_target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight);
254 BOOST_CHECK_MESSAGE(!result, "SRD-Fail: " + test_title);
255 bool max_weight_exceeded = util::ErrorString(result).original.find("The inputs size exceeds the maximum weight") != std::string::npos;
256 BOOST_CHECK(expect_max_weight_exceeded == max_weight_exceeded);
257}
258
260{
261 for (int feerate : FEERATES) {
262 std::vector<OutputGroup> utxo_pool;
263
264 const CoinSelectionParams cs_params = init_cs_params(feerate);
265
266 TestSRDFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT, cs_params);
267
268 AddCoins(utxo_pool, {1 * CENT, 3 * CENT, 5 * CENT}, cs_params);
269
270 TestSRDSuccess("Select 21k sats", utxo_pool, /*selection_target=*/21'000, cs_params);
271 TestSRDSuccess("Select 1 CENT", utxo_pool, /*selection_target=*/1 * CENT, cs_params);
272 TestSRDSuccess("Select 3.125 CENT", utxo_pool, /*selection_target=*/3'125'000, cs_params);
273 TestSRDSuccess("Select 4 CENT", utxo_pool, /*selection_target=*/4 * CENT, cs_params);
274 TestSRDSuccess("Select 7 CENT", utxo_pool, /*selection_target=*/7 * CENT, cs_params);
275
276 // The minimum change amount for SRD is the feerate dependent `change_fee` plus CHANGE_LOWER
277 TestSRDSuccess("Create minimum change", utxo_pool, /*selection_target=*/9 * CENT - cs_params.m_change_fee - CHANGE_LOWER, cs_params);
278 TestSRDFail("Undershoot minimum change by one sat", utxo_pool, /*selection_target=*/9 * CENT - cs_params.m_change_fee - CHANGE_LOWER + 1, cs_params);
279 TestSRDFail("Spend more than available", utxo_pool, /*selection_target=*/9 * CENT + 1, cs_params);
280 TestSRDFail("Spend everything", utxo_pool, /*selection_target=*/9 * CENT, cs_params);
281
282 AddDuplicateCoins(utxo_pool, /*count=*/100, /*amount=*/5 * CENT, cs_params);
283 AddDuplicateCoins(utxo_pool, /*count=*/3, /*amount=*/7 * CENT, cs_params);
284 TestSRDSuccess("Select most valuable UTXOs for acceptable weight", utxo_pool, /*selection_target=*/20 * CENT, cs_params, /*max_selection_weight=*/4 * 4 * (P2WPKH_INPUT_VSIZE - 1 ));
285 TestSRDFail("No acceptable weight possible", utxo_pool, /*selection_target=*/25 * CENT, cs_params, /*max_selection_weight=*/4 * 3 * P2WPKH_INPUT_VSIZE, /*expect_max_weight_exceeded=*/true);
286 }
287}
288
290} // namespace wallet
int64_t CAmount
Amount in satoshis (Can be negative)
Definition: amount.h:12
int ret
Fee rate in satoshis per virtualbyte: CAmount / vB the feerate is represented internally as FeeFrac.
Definition: feerate.h:32
An outpoint - a combination of a transaction hash and an index n into its vout.
Definition: transaction.h:29
Fast randomness source.
Definition: random.h:386
BOOST_FIXTURE_TEST_SUITE(cuckoocache_tests, BasicTestingSetup)
Test Suite for CuckooCache.
BOOST_AUTO_TEST_SUITE_END()
Definition: common.h:30
bilingual_str ErrorString(const Result< T > &result)
Definition: result.h:93
auto Join(const C &container, const S &separator, UnaryOp unary_op)
Join all container items.
Definition: string.h:206
static constexpr CAmount CHANGE_LOWER
lower bound for randomly-chosen target change amount
Definition: coinselection.h:23
util::Result< SelectionResult > SelectCoinsBnB(std::vector< OutputGroup > &utxo_pool, const CAmount &selection_target, const CAmount &cost_of_change, int max_selection_weight)
static void TestBnBFail(std::string test_title, std::vector< OutputGroup > &utxo_pool, const CAmount &selection_target, const CoinSelectionParams &cs_params=default_cs_params, int max_selection_weight=MAX_STANDARD_TX_WEIGHT, const bool expect_max_weight_exceeded=false)
static CoinSelectionParams init_cs_params(int eff_feerate=5000)
Default coin selection parameters allow us to only explicitly set parameters when a diverging value i...
static const int P2WPKH_OUTPUT_VSIZE
static bool HaveEquivalentValues(const SelectionResult &a, const SelectionResult &b)
Check if SelectionResult a is equivalent to SelectionResult b.
static const int P2WPKH_INPUT_VSIZE
static void TestSRDFail(std::string test_title, std::vector< OutputGroup > &utxo_pool, const CAmount &selection_target, const CoinSelectionParams &cs_params=default_cs_params, int max_selection_weight=MAX_STANDARD_TX_WEIGHT, const bool expect_max_weight_exceeded=false)
static void AddDuplicateCoins(std::vector< OutputGroup > &utxo_pool, int count, int amount, CoinSelectionParams cs_params=default_cs_params)
Make multiple coins that share the same effective value.
BOOST_AUTO_TEST_CASE(bnb_test)
static int next_lock_time
static const CoinSelectionParams default_cs_params
static void AddCoins(std::vector< OutputGroup > &utxo_pool, std::vector< CAmount > coins, CoinSelectionParams cs_params=default_cs_params)
Make multiple OutputGroups with the given values as their effective value.
static void TestBnBSuccess(std::string test_title, std::vector< OutputGroup > &utxo_pool, const CAmount &selection_target, const std::vector< CAmount > &expected_input_amounts, const CoinSelectionParams &cs_params=default_cs_params, const int custom_spending_vsize=P2WPKH_INPUT_VSIZE, const int max_selection_weight=MAX_STANDARD_TX_WEIGHT)
static OutputGroup MakeCoin(const CAmount &amount, bool is_eff_value=true, CoinSelectionParams cs_params=default_cs_params, int custom_spending_vsize=P2WPKH_INPUT_VSIZE)
Make one OutputGroup with a single UTXO that either has a given effective value (default) or a given ...
static FastRandomContext default_rand
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)
static const std::vector< int > FEERATES
This set of feerates is used in the tests to test edge cases around the default minimum feerate and o...
util::Result< SelectionResult > SelectCoinsSRD(const std::vector< OutputGroup > &utxo_pool, CAmount target_value, CAmount change_fee, FastRandomContext &rng, int max_selection_weight)
Select coins by Single Random Draw (SRD).
static std::string InputAmountsToString(const SelectionResult &selection)
#define BOOST_CHECK(expr)
Definition: object.cpp:16
static constexpr int32_t MAX_STANDARD_TX_WEIGHT
The maximum weight for transactions we're willing to relay/mine.
Definition: policy.h:38
static constexpr CAmount CENT
Definition: setup_common.h:44
A mutable version of CTransaction.
Definition: transaction.h:358
std::vector< CTxOut > vout
Definition: transaction.h:360
Txid GetHash() const
Compute the hash of this CMutableTransaction.
Definition: transaction.cpp:69
Testing setup that configures a complete environment.
Definition: setup_common.h:118
std::string original
Definition: translation.h:25
Parameters for one iteration of Coin Selection.
CAmount m_cost_of_change
Cost of creating the change output + cost of spending the change output in the future.
CAmount m_change_fee
Cost of creating the change output.
A group of UTXOs paid to the same output script.
const OutputSet & GetInputSet() const
Get m_selected_inputs.
static int count
#define strprintf
Format arguments and return the string or write to given std::ostream (see tinyformat::format doc for...
Definition: tinyformat.h:1172