diff --git a/src/wallet/test/coinselection_tests.cpp b/src/wallet/test/coinselection_tests.cpp index 4a0eed8f090..6e60af1c0e3 100644 --- a/src/wallet/test/coinselection_tests.cpp +++ b/src/wallet/test/coinselection_tests.cpp @@ -18,30 +18,46 @@ static FastRandomContext default_rand; static const int P2WPKH_INPUT_VSIZE = 68; static const int P2WPKH_OUTPUT_VSIZE = 31; -/** Default coin selection parameters (dcsp) allow us to only explicitly set - * parameters when a diverging value is relevant in the context of a test. - * We use P2WPKH input and output weights for the change weights. */ -static CoinSelectionParams init_default_params() +/** + * This set of feerates is used in the tests to test edge cases around the + * default minimum feerate and other potential special cases: + * - zero: 0 s/kvB + * - minimum non-zero s/kvB: 1 s/kvB + * - just below the new default minimum feerate: 99 s/kvB + * - new default minimum feerate: 100 s/kvB + * - old default minimum feerate: 1000 s/kvB + * - a few non-round realistic feerates around default minimum feerate, + * dust feerate, and default LTFRE: 315 s/kvB, 2345 s/kvB, and + * 10'292 s/kvB + * - a high feerate that has been exceeded occasionally: 59'764 s/kvB + * - a huge feerate that is extremely uncommon: 1'500'000 s/kvB */ +static const std::vector FEERATES = {0, 1, 99, 100, 315, 1'000, 2'345, 10'292, 59'764, 1'500'000}; + +/** Default coin selection parameters allow us to only explicitly set + * parameters when a diverging value is relevant in the context of a test, + * without reiterating the defaults in every test. We use P2WPKH input and + * output weights for the change weights. */ +static CoinSelectionParams init_cs_params(int eff_feerate = 5000) { - CoinSelectionParams dcsp{ + CoinSelectionParams csp{ /*rng_fast=*/default_rand, /*change_output_size=*/P2WPKH_OUTPUT_VSIZE, /*change_spend_size=*/P2WPKH_INPUT_VSIZE, /*min_change_target=*/50'000, - /*effective_feerate=*/CFeeRate(5000), + /*effective_feerate=*/CFeeRate(eff_feerate), /*long_term_feerate=*/CFeeRate(10'000), /*discard_feerate=*/CFeeRate(3000), /*tx_noinputs_size=*/11 + P2WPKH_OUTPUT_VSIZE, //static header size + output size /*avoid_partial=*/false, }; - dcsp.m_change_fee = /*155 sats=*/dcsp.m_effective_feerate.GetFee(dcsp.change_output_size); - dcsp.min_viable_change = /*204 sats=*/dcsp.m_discard_feerate.GetFee(dcsp.change_spend_size); - dcsp.m_cost_of_change = /*204 + 155 sats=*/dcsp.min_viable_change + dcsp.m_change_fee; - dcsp.m_subtract_fee_outputs = false; - return dcsp; + csp.m_change_fee = csp.m_effective_feerate.GetFee(csp.change_output_size); // 155 sats for default feerate of 5000 s/kvB + csp.min_viable_change = /*204 sats=*/csp.m_discard_feerate.GetFee(csp.change_spend_size); + csp.m_cost_of_change = csp.min_viable_change + csp.m_change_fee; // 204 + 155 sats for default feerate of 5000 s/kvB + csp.m_subtract_fee_outputs = false; + return csp; } -static const CoinSelectionParams default_cs_params = init_default_params(); +static const CoinSelectionParams default_cs_params = init_cs_params(); /** Make one OutputGroup with a single UTXO that either has a given effective value (default) or a given amount (`is_eff_value = false`). */ static OutputGroup MakeCoin(const CAmount& amount, bool is_eff_value = true, CoinSelectionParams cs_params = default_cs_params, int custom_spending_vsize = P2WPKH_INPUT_VSIZE) @@ -106,15 +122,16 @@ static void TestBnBSuccess(std::string test_title, std::vector& utx expected_result.AddInput(group); } - const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/default_cs_params.m_cost_of_change, max_selection_weight); + const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/cs_params.m_cost_of_change, max_selection_weight); BOOST_CHECK_MESSAGE(result, "Falsy result in BnB-Success: " + test_title); 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))); 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())); + 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())); } -static void TestBnBFail(std::string test_title, std::vector& utxo_pool, const CAmount& selection_target, int max_selection_weight = MAX_STANDARD_TX_WEIGHT, const bool expect_max_weight_exceeded = false) +static void TestBnBFail(std::string test_title, std::vector& 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) { - const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/default_cs_params.m_cost_of_change, max_selection_weight); + const auto result = SelectCoinsBnB(utxo_pool, selection_target, /*cost_of_change=*/cs_params.m_cost_of_change, max_selection_weight); BOOST_CHECK_MESSAGE(!result, "BnB-Fail: " + test_title); bool max_weight_exceeded = util::ErrorString(result).original.find("The inputs size exceeds the maximum weight") != std::string::npos; BOOST_CHECK(expect_max_weight_exceeded == max_weight_exceeded); @@ -122,16 +139,12 @@ static void TestBnBFail(std::string test_title, std::vector& utxo_p BOOST_AUTO_TEST_CASE(bnb_test) { - std::vector feerates = {0, 1, 5'000, 10'000, 25'000, 59'764, 500'000, 999'000, 1'500'000}; - - for (int feerate : feerates) { + for (int feerate : FEERATES) { std::vector utxo_pool; - CoinSelectionParams cs_params = init_default_params(); - cs_params.m_effective_feerate = CFeeRate{feerate}; + const CoinSelectionParams cs_params = init_cs_params(feerate); - // Fail for empty UTXO pool - TestBnBFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT); + TestBnBFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT, cs_params); AddCoins(utxo_pool, {1 * CENT, 3 * CENT, 5 * CENT}, cs_params); @@ -143,24 +156,24 @@ BOOST_AUTO_TEST_CASE(bnb_test) TestBnBSuccess("Select all UTXOs", utxo_pool, /*selection_target=*/9 * CENT, /*expected_input_amounts=*/{1 * CENT, 3 * CENT, 5 * CENT}, cs_params); // BnB finds changeless solution while overshooting by up to cost_of_change - TestBnBSuccess("Select upper bound", utxo_pool, /*selection_target=*/4 * CENT - default_cs_params.m_cost_of_change, /*expected_input_amounts=*/{1 * CENT, 3 * CENT}, cs_params); + 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); // BnB fails to find changeless solution when overshooting by cost_of_change + 1 sat - TestBnBFail("Overshoot upper bound", utxo_pool, /*selection_target=*/4 * CENT - default_cs_params.m_cost_of_change - 1); + TestBnBFail("Overshoot upper bound", utxo_pool, /*selection_target=*/4 * CENT - cs_params.m_cost_of_change - 1, cs_params); 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); - TestBnBFail("Exceed max weight", utxo_pool, /*selection_target=*/4 * CENT, /*max_selection_weight=*/4 * 2 * P2WPKH_INPUT_VSIZE - 1, /*expect_max_weight_exceeded=*/true); + 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); // Simple cases without BnB solution - TestBnBFail("Smallest combination too big", utxo_pool, /*selection_target=*/0.5 * CENT); - TestBnBFail("No UTXO combination in target window", utxo_pool, /*selection_target=*/7 * CENT); - TestBnBFail("Select more than available", utxo_pool, /*selection_target=*/10 * CENT); + TestBnBFail("Smallest combination too big", utxo_pool, /*selection_target=*/0.5 * CENT, cs_params); + TestBnBFail("No UTXO combination in target window", utxo_pool, /*selection_target=*/7 * CENT, cs_params); + TestBnBFail("Select more than available", utxo_pool, /*selection_target=*/10 * CENT, cs_params); // Test skipping of equivalent input sets std::vector clone_pool; AddCoins(clone_pool, {2 * CENT, 7 * CENT, 7 * CENT}, cs_params); - AddDuplicateCoins(clone_pool, 50'000, 5 * CENT, cs_params); + AddDuplicateCoins(clone_pool, /*count=*/50'000, /*amount=*/5 * CENT, cs_params); TestBnBSuccess("Skip equivalent input sets", clone_pool, /*selection_target=*/16 * CENT, /*expected_input_amounts=*/{2 * CENT, 7 * CENT, 7 * CENT}, cs_params); /* Test BnB attempt limit (`TOTAL_TRIES`) @@ -189,7 +202,7 @@ BOOST_AUTO_TEST_CASE(bnb_test) expected_inputs.push_back(doppelgangers[i]); } else { // Any eight UTXOs including at least one UTXO with the added cost_of_change will exceed target window - doppelgangers.push_back(1 * CENT + default_cs_params.m_cost_of_change + i); + doppelgangers.push_back(1 * CENT + cs_params.m_cost_of_change + i); } } AddCoins(doppelganger_pool, doppelgangers, cs_params); @@ -197,8 +210,8 @@ BOOST_AUTO_TEST_CASE(bnb_test) TestBnBSuccess("Combine smallest 8 of 17 unique UTXOs", doppelganger_pool, /*selection_target=*/8 * CENT, /*expected_input_amounts=*/expected_inputs, cs_params); // Starting with 18 unique UTXOs of similar effective value we will not find the solution due to exceeding the attempt limit - AddCoins(doppelganger_pool, {1 * CENT + default_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); + 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); } } @@ -209,8 +222,7 @@ BOOST_AUTO_TEST_CASE(bnb_feerate_sensitivity_test) 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}); - CoinSelectionParams high_feerate_params = init_default_params(); - high_feerate_params.m_effective_feerate = CFeeRate{25'000}; + const CoinSelectionParams high_feerate_params = init_cs_params(/*eff_feerate=*/25'000); std::vector 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}, high_feerate_params); @@ -225,5 +237,54 @@ BOOST_AUTO_TEST_CASE(bnb_feerate_sensitivity_test) 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); } +static void TestSRDSuccess(std::string test_title, std::vector& utxo_pool, const CAmount& selection_target, const CoinSelectionParams& cs_params = default_cs_params, const int max_selection_weight = MAX_STANDARD_TX_WEIGHT) +{ + CAmount expected_min_amount = selection_target + cs_params.m_change_fee + CHANGE_LOWER; + + const auto result = SelectCoinsSRD(utxo_pool, selection_target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight); + BOOST_CHECK_MESSAGE(result, "Falsy result in SRD-Success: " + test_title); + const CAmount selected_effective_value = result->GetSelectedEffectiveValue(); + 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)); + 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())); +} + +static void TestSRDFail(std::string test_title, std::vector& 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) +{ + const auto result = SelectCoinsSRD(utxo_pool, selection_target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight); + BOOST_CHECK_MESSAGE(!result, "SRD-Fail: " + test_title); + bool max_weight_exceeded = util::ErrorString(result).original.find("The inputs size exceeds the maximum weight") != std::string::npos; + BOOST_CHECK(expect_max_weight_exceeded == max_weight_exceeded); +} + +BOOST_AUTO_TEST_CASE(srd_test) +{ + for (int feerate : FEERATES) { + std::vector utxo_pool; + + const CoinSelectionParams cs_params = init_cs_params(feerate); + + TestSRDFail("Empty UTXO pool", utxo_pool, /*selection_target=*/1 * CENT, cs_params); + + AddCoins(utxo_pool, {1 * CENT, 3 * CENT, 5 * CENT}, cs_params); + + TestSRDSuccess("Select 21k sats", utxo_pool, /*selection_target=*/21'000, cs_params); + TestSRDSuccess("Select 1 CENT", utxo_pool, /*selection_target=*/1 * CENT, cs_params); + TestSRDSuccess("Select 3.125 CENT", utxo_pool, /*selection_target=*/3'125'000, cs_params); + TestSRDSuccess("Select 4 CENT", utxo_pool, /*selection_target=*/4 * CENT, cs_params); + TestSRDSuccess("Select 7 CENT", utxo_pool, /*selection_target=*/7 * CENT, cs_params); + + // The minimum change amount for SRD is the feerate dependent `change_fee` plus CHANGE_LOWER + TestSRDSuccess("Create minimum change", utxo_pool, /*selection_target=*/9 * CENT - cs_params.m_change_fee - CHANGE_LOWER, cs_params); + TestSRDFail("Undershoot minimum change by one sat", utxo_pool, /*selection_target=*/9 * CENT - cs_params.m_change_fee - CHANGE_LOWER + 1, cs_params); + TestSRDFail("Spend more than available", utxo_pool, /*selection_target=*/9 * CENT + 1, cs_params); + TestSRDFail("Spend everything", utxo_pool, /*selection_target=*/9 * CENT, cs_params); + + AddDuplicateCoins(utxo_pool, /*count=*/100, /*amount=*/5 * CENT, cs_params); + AddDuplicateCoins(utxo_pool, /*count=*/3, /*amount=*/7 * CENT, cs_params); + 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 )); + 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); + } +} + BOOST_AUTO_TEST_SUITE_END() } // namespace wallet diff --git a/src/wallet/test/coinselector_tests.cpp b/src/wallet/test/coinselector_tests.cpp index fe33e9347e7..ee6a639a43d 100644 --- a/src/wallet/test/coinselector_tests.cpp +++ b/src/wallet/test/coinselector_tests.cpp @@ -1185,96 +1185,6 @@ BOOST_AUTO_TEST_CASE(coin_grinder_tests) } } -static util::Result SelectCoinsSRD(const CAmount& target, - const CoinSelectionParams& cs_params, - const node::NodeContext& m_node, - int max_selection_weight, - std::function coin_setup) -{ - std::unique_ptr wallet = NewWallet(m_node); - CoinEligibilityFilter filter(0, 0, 0); // accept all coins without ancestors - Groups group = GroupOutputs(*wallet, coin_setup(*wallet), cs_params, {{filter}})[filter].all_groups; - return SelectCoinsSRD(group.positive_group, target, cs_params.m_change_fee, cs_params.rng_fast, max_selection_weight); -} - -BOOST_AUTO_TEST_CASE(srd_tests) -{ - // Test SRD: - // 1) Insufficient funds, select all provided coins and fail. - // 2) Exceeded max weight, coin selection always surpasses the max allowed weight. - // 3) Select coins without surpassing the max weight (some coins surpasses the max allowed weight, some others not) - - FastRandomContext rand; - CoinSelectionParams dummy_params{ // Only used to provide the 'avoid_partial' flag. - rand, - /*change_output_size=*/34, - /*change_spend_size=*/68, - /*min_change_target=*/CENT, - /*effective_feerate=*/CFeeRate(0), - /*long_term_feerate=*/CFeeRate(0), - /*discard_feerate=*/CFeeRate(0), - /*tx_noinputs_size=*/10 + 34, // static header size + output size - /*avoid_partial=*/false, - }; - - { - // ######################################################### - // 1) Insufficient funds, select all provided coins and fail - // ######################################################### - CAmount target = 49.5L * COIN; - int max_selection_weight = 10000; // high enough to not fail for this reason. - const auto& res = SelectCoinsSRD(target, dummy_params, m_node, max_selection_weight, [&](CWallet& wallet) { - CoinsResult available_coins; - for (int j = 0; j < 10; ++j) { - add_coin(available_coins, wallet, CAmount(1 * COIN)); - add_coin(available_coins, wallet, CAmount(2 * COIN)); - } - return available_coins; - }); - BOOST_CHECK(!res); - BOOST_CHECK(util::ErrorString(res).empty()); // empty means "insufficient funds" - } - - { - // ########################### - // 2) Test max weight exceeded - // ########################### - CAmount target = 49.5L * COIN; - int max_selection_weight = 3000; - const auto& res = SelectCoinsSRD(target, dummy_params, m_node, max_selection_weight, [&](CWallet& wallet) { - CoinsResult available_coins; - for (int j = 0; j < 10; ++j) { - /* 10 × 1 BTC + 10 × 2 BTC = 30 BTC. 20 × 272 WU = 5440 WU */ - add_coin(available_coins, wallet, CAmount(1 * COIN), CFeeRate(0), 144, false, 0, true); - add_coin(available_coins, wallet, CAmount(2 * COIN), CFeeRate(0), 144, false, 0, true); - } - return available_coins; - }); - BOOST_CHECK(!res); - BOOST_CHECK(util::ErrorString(res).original.find("The inputs size exceeds the maximum weight") != std::string::npos); - } - - { - // ################################################################################################################ - // 3) Test that SRD result does not exceed the max weight - // ################################################################################################################ - CAmount target = 25.33L * COIN; - int max_selection_weight = 10000; // WU - const auto& res = SelectCoinsSRD(target, dummy_params, m_node, max_selection_weight, [&](CWallet& wallet) { - CoinsResult available_coins; - for (int j = 0; j < 60; ++j) { // 60 UTXO --> 19,8 BTC total --> 60 × 272 WU = 16320 WU - add_coin(available_coins, wallet, CAmount(0.33 * COIN), CFeeRate(0), 144, false, 0, true); - } - for (int i = 0; i < 10; i++) { // 10 UTXO --> 20 BTC total --> 10 × 272 WU = 2720 WU - add_coin(available_coins, wallet, CAmount(2 * COIN), CFeeRate(0), 144, false, 0, true); - } - return available_coins; - }); - BOOST_CHECK(res); - BOOST_CHECK(res->GetWeight() <= max_selection_weight); - } -} - static util::Result select_coins(const CAmount& target, const CoinSelectionParams& cs_params, const CCoinControl& cc, std::function coin_setup, const node::NodeContext& m_node) { std::unique_ptr wallet = NewWallet(m_node);