coinselection_tests.cpp raw
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>
7 #include <wallet/coinselection.h>
8 #include <wallet/test/wallet_test_fixture.h>
9
10 #include <boost/test/unit_test.hpp>
11
12 namespace wallet {
13 BOOST_FIXTURE_TEST_SUITE(coinselection_tests, TestingSetup)
14
15 static int next_lock_time = 0;
16 static FastRandomContext default_rand;
17
18 static const int P2WPKH_INPUT_VSIZE = 68;
19 static const int P2WPKH_OUTPUT_VSIZE = 31;
20
21 /**
22 * This set of feerates is used in the tests to test edge cases around the
23 * default minimum feerate and other potential special cases:
24 * - zero: 0 s/kvB
25 * - minimum non-zero s/kvB: 1 s/kvB
26 * - just below the new default minimum feerate: 99 s/kvB
27 * - new default minimum feerate: 100 s/kvB
28 * - old default minimum feerate: 1000 s/kvB
29 * - a few non-round realistic feerates around default minimum feerate,
30 * dust feerate, and default LTFRE: 315 s/kvB, 2345 s/kvB, and
31 * 10'292 s/kvB
32 * - a high feerate that has been exceeded occasionally: 59'764 s/kvB
33 * - a huge feerate that is extremely uncommon: 1'500'000 s/kvB */
34 static const std::vector<int> FEERATES = {0, 1, 99, 100, 315, 1'000, 2'345, 10'292, 59'764, 1'500'000};
35
36 /** Default coin selection parameters allow us to only explicitly set
37 * parameters when a diverging value is relevant in the context of a test,
38 * without reiterating the defaults in every test. We use P2WPKH input and
39 * output weights for the change weights. */
40 static CoinSelectionParams init_cs_params(int eff_feerate = 5000)
41 {
42 CoinSelectionParams csp{
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
60 static const CoinSelectionParams default_cs_params = init_cs_params();
61
62 /** Make one OutputGroup with a single UTXO that either has a given effective value (default) or a given amount (`is_eff_value = false`). */
63 static 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
66 CMutableTransaction tx;
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
76 /** Make multiple OutputGroups with the given values as their effective value */
77 static 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
84 /** Make multiple coins that share the same effective value */
85 static 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
91 /** Check if SelectionResult a is equivalent to SelectionResult b.
92 * Two results are equivalent if they are composed of the same input values, even if they have different inputs (i.e., same value, different prevout) */
93 static bool HaveEquivalentValues(const SelectionResult& a, const SelectionResult& b)
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
110 static std::string InputAmountsToString(const SelectionResult& selection)
111 {
112 return "[" + util::Join(selection.GetInputSet(), " ", [](const auto& input){ return util::ToString(input->txout.nValue);}) + "]";
113 }
114
115 static void TestBnBSuccess(std::string test_title, std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const std::vector<CAmount>& expected_input_amounts, size_t expected_attempts, 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 {
117 SelectionResult expected_result(CAmount(0), SelectionAlgorithm::BNB);
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 BOOST_CHECK_MESSAGE(result->GetSelectionsEvaluated() == expected_attempts, strprintf("Unexpected number of attempts in BnB-Success: %s. Expected %i attempts, but got %i", test_title, expected_attempts, result->GetSelectionsEvaluated()));
131 }
132
133 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)
134 {
135 const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/cs_params.m_cost_of_change, max_selection_weight);
136 BOOST_CHECK_MESSAGE(!result, "BnB-Fail: " + test_title);
137 bool max_weight_exceeded = util::ErrorString(result).original.find("The inputs size exceeds the maximum weight") != std::string::npos;
138 BOOST_CHECK(expect_max_weight_exceeded == max_weight_exceeded);
139 }
140
141 BOOST_AUTO_TEST_CASE(bnb_test)
142 {
143 for (int feerate : FEERATES) {
144 std::vector<OutputGroup> utxo_pool;
145
146 const CoinSelectionParams cs_params = init_cs_params(feerate);
147
148 TestBnBFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT, cs_params);
149
150 AddCoins(utxo_pool, {1 * CENT, 3 * CENT, 5 * CENT}, cs_params);
151
152 // Simple success cases
153 TestBnBSuccess("Select smallest UTXO", utxo_pool, /*selection_target=*/1 * CENT, /*expected_input_amounts=*/{1 * CENT}, /*expected_attempts=*/3, cs_params);
154 TestBnBSuccess("Select middle UTXO", utxo_pool, /*selection_target=*/3 * CENT, /*expected_input_amounts=*/{3 * CENT}, /*expected_attempts=*/3, cs_params);
155 TestBnBSuccess("Select biggest UTXO", utxo_pool, /*selection_target=*/5 * CENT, /*expected_input_amounts=*/{5 * CENT}, /*expected_attempts=*/2, cs_params);
156 TestBnBSuccess("Select two UTXOs", utxo_pool, /*selection_target=*/4 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, /*expected_attempts=*/4, cs_params);
157 TestBnBSuccess("Select all UTXOs", utxo_pool, /*selection_target=*/9 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT, 5 * CENT}, /*expected_attempts=*/5, cs_params);
158
159 // BnB finds changeless solution while overshooting by up to cost_of_change
160 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);
161
162 // BnB fails to find changeless solution when overshooting by cost_of_change + 1 sat
163 TestBnBFail("Overshoot upper bound", utxo_pool, /*selection_target=*/4 * CENT - cs_params.m_cost_of_change - 1, cs_params);
164
165 TestBnBSuccess("Select max weight", utxo_pool, /*selection_target=*/4 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, /*expected_attempts=*/4, cs_params, /*custom_spending_vsize=*/P2WPKH_INPUT_VSIZE, /*max_selection_weight=*/4 * 2 * P2WPKH_INPUT_VSIZE);
166
167 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);
168
169 // Simple cases without BnB solution
170 TestBnBFail("Smallest combination too big", utxo_pool, /*selection_target=*/0.5 * CENT, cs_params);
171 TestBnBFail("No UTXO combination in target window", utxo_pool, /*selection_target=*/7 * CENT, cs_params);
172 TestBnBFail("Select more than available", utxo_pool, /*selection_target=*/10 * CENT, cs_params);
173
174 // Test skipping of equivalent input sets
175 std::vector<OutputGroup> clone_pool;
176 AddCoins(clone_pool, {2 * CENT, 7 * CENT, 7 * CENT}, cs_params);
177 AddDuplicateCoins(clone_pool, /*count=*/50'000, /*amount=*/5 * CENT, cs_params);
178 TestBnBSuccess("Skip equivalent input sets", clone_pool, /*selection_target=*/16 * CENT, /*expected_input_amounts=*/{2 * CENT, 7 * CENT, 7 * CENT}, /*expected_attempts=*/16, cs_params);
179
180 /* Test BnB attempt limit (`TOTAL_TRIES`)
181 *
182 * Generally, on a diverse UTXO pool BnB will quickly pass over UTXOs bigger than the target and then start
183 * combining small counts of UTXOs that in sum remain under the selection_target+cost_of_change. When there are
184 * multiple UTXOs that have matching amount and cost, combinations with equivalent input sets are skipped. The
185 * UTXO pool for this test is specifically crafted to create as much branching as possible. The selection target
186 * is 8 CENT while all UTXOs are slightly bigger than 1 CENT. The smallest eight are 100,000…100,007 sats, while
187 * the larger nine are 100,368…100,375 (i.e., 100,008…100,016 sats plus cost_of_change (359 sats)).
188 *
189 * Because BnB will only select input sets that fall between selection_target and selection_target +
190 * cost_of_change, and the search traverses the UTXO pool from large to small amounts, the search will visit
191 * every single combination of eight inputs. All except the last combination will overshoot by more than
192 * cost_of_change on the eighth input, because the larger nine inputs each exceed 1 CENT by more than
193 * cost_of_change. Only the last combination consisting of the eight smallest UTXOs falls into the target
194 * window.
195 */
196 std::vector<OutputGroup> doppelganger_pool;
197 std::vector<CAmount> doppelgangers;
198 std::vector<CAmount> expected_inputs;
199 for (int i = 0; i < 17; ++i) {
200 if (i < 8) {
201 // The eight smallest UTXOs can be combined to create expected_result
202 doppelgangers.push_back(1 * CENT + i);
203 expected_inputs.push_back(doppelgangers[i]);
204 } else {
205 // Any eight UTXOs including at least one UTXO with the added cost_of_change will exceed target window
206 doppelgangers.push_back(1 * CENT + cs_params.m_cost_of_change + i);
207 }
208 }
209 AddCoins(doppelganger_pool, doppelgangers, cs_params);
210 // Among 17 unique UTXOs of similar effective value we will find a solution composed of the eight smallest UTXOs
211 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);
212
213 // Among up to 18 unique UTXOs of similar effective value we will find a solution composed of the eight smallest UTXOs
214 AddCoins(doppelganger_pool, {1 * CENT + cs_params.m_cost_of_change + 17}, cs_params);
215 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);
216
217 // Starting with 19 unique UTXOs of similar effective value we will not find the solution due to exceeding the attempt limit
218 AddCoins(doppelganger_pool, {1 * CENT + cs_params.m_cost_of_change + 18}, cs_params);
219 TestBnBFail("Exhaust looking for smallest 8 of 19 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, cs_params);
220 }
221 }
222
223 BOOST_AUTO_TEST_CASE(bnb_feerate_sensitivity_test)
224 {
225 // Create sets of UTXOs with the same effective amounts at different feerates (but different absolute amounts)
226 std::vector<OutputGroup> low_feerate_pool; // 5 sat/vB (default, and lower than long_term_feerate of 10 sat/vB)
227 AddCoins(low_feerate_pool, {2 * CENT, 3 * CENT, 5 * CENT, 10 * CENT});
228 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);
229
230 const CoinSelectionParams high_feerate_params = init_cs_params(/*eff_feerate=*/25'000);
231 std::vector<OutputGroup> high_feerate_pool; // 25 sat/vB (greater than long_term_feerate of 10 sat/vB)
232 AddCoins(high_feerate_pool, {2 * CENT, 3 * CENT, 5 * CENT, 10 * CENT}, high_feerate_params);
233 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);
234
235 // Add heavy inputs {6, 7} to existing {2, 3, 5, 10}
236 low_feerate_pool.push_back(MakeCoin(6 * CENT, true, default_cs_params, /*custom_spending_vsize=*/500));
237 low_feerate_pool.push_back(MakeCoin(7 * CENT, true, default_cs_params, /*custom_spending_vsize=*/500));
238 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);
239
240 high_feerate_pool.push_back(MakeCoin(6 * CENT, true, high_feerate_params, /*custom_spending_vsize=*/500));
241 high_feerate_pool.push_back(MakeCoin(7 * CENT, true, high_feerate_params, /*custom_spending_vsize=*/500));
242 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);
243 }
244
245 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)
246 {
247 CAmount expected_min_amount = selection_target + cs_params.m_change_fee + CHANGE_LOWER;
248
249 const auto result = SelectCoinsSRD(utxo_pool, selection_target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight);
250 BOOST_CHECK_MESSAGE(result, "Falsy result in SRD-Success: " + test_title);
251 const CAmount selected_effective_value = result->GetSelectedEffectiveValue();
252 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));
253 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()));
254 }
255
256 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)
257 {
258 const auto result = SelectCoinsSRD(utxo_pool, selection_target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight);
259 BOOST_CHECK_MESSAGE(!result, "SRD-Fail: " + test_title);
260 bool max_weight_exceeded = util::ErrorString(result).original.find("The inputs size exceeds the maximum weight") != std::string::npos;
261 BOOST_CHECK(expect_max_weight_exceeded == max_weight_exceeded);
262 }
263
264 BOOST_AUTO_TEST_CASE(srd_test)
265 {
266 for (int feerate : FEERATES) {
267 std::vector<OutputGroup> utxo_pool;
268
269 const CoinSelectionParams cs_params = init_cs_params(feerate);
270
271 TestSRDFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT, cs_params);
272
273 AddCoins(utxo_pool, {1 * CENT, 3 * CENT, 5 * CENT}, cs_params);
274
275 TestSRDSuccess("Select 21k sats", utxo_pool, /*selection_target=*/21'000, cs_params);
276 TestSRDSuccess("Select 1 CENT", utxo_pool, /*selection_target=*/1 * CENT, cs_params);
277 TestSRDSuccess("Select 3.125 CENT", utxo_pool, /*selection_target=*/3'125'000, cs_params);
278 TestSRDSuccess("Select 4 CENT", utxo_pool, /*selection_target=*/4 * CENT, cs_params);
279 TestSRDSuccess("Select 7 CENT", utxo_pool, /*selection_target=*/7 * CENT, cs_params);
280
281 // The minimum change amount for SRD is the feerate dependent `change_fee` plus CHANGE_LOWER
282 TestSRDSuccess("Create minimum change", utxo_pool, /*selection_target=*/9 * CENT - cs_params.m_change_fee - CHANGE_LOWER, cs_params);
283 TestSRDFail("Undershoot minimum change by one sat", utxo_pool, /*selection_target=*/9 * CENT - cs_params.m_change_fee - CHANGE_LOWER + 1, cs_params);
284 TestSRDFail("Spend more than available", utxo_pool, /*selection_target=*/9 * CENT + 1, cs_params);
285 TestSRDFail("Spend everything", utxo_pool, /*selection_target=*/9 * CENT, cs_params);
286
287 AddDuplicateCoins(utxo_pool, /*count=*/100, /*amount=*/5 * CENT, cs_params);
288 AddDuplicateCoins(utxo_pool, /*count=*/3, /*amount=*/7 * CENT, cs_params);
289 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 ));
290 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);
291
292 // Create UTXO pool with UTXOs of same effective value but different weights
293 std::vector<OutputGroup> mixed_weight_pool;
294 AddDuplicateCoins(mixed_weight_pool, /*count=*/100, /*amount=*/5 * CENT, cs_params);
295 mixed_weight_pool.push_back(MakeCoin(5 * CENT, true, cs_params, /*custom_spending_vsize=*/P2WPKH_INPUT_VSIZE - 1));
296 TestSRDSuccess("Tie-break same effective value with lower weight", utxo_pool, /*selection_target=*/9 * CENT, cs_params,
297 /*max_selection_weight=*/4 * 3 * (P2WPKH_INPUT_VSIZE - 1));
298 }
299 }
300
301 BOOST_AUTO_TEST_SUITE_END()
302 } // namespace wallet
303