Skip to content

Commit d5d0a5c

Browse files
committed
Merge bitcoin/bitcoin#17526: Add Single Random Draw as an additional coin selection algorithm
3633b66 Use SelectCoinsSRD if it has less waste (Andrew Chow) 8bf789b Add SelectCoinsSRD function (Andrew Chow) 2ad3b5d tests: wallet_basic lock needed unspents (Andrew Chow) b77885f tests: wallet_txn explicilty specify inputs (Andrew Chow) 59ba7d2 tests: rpc_fundrawtx better test for UTXO inclusion with include_unsafe (Andrew Chow) a165bfb tests: rpc_fundrawtx use specific inputs for unavailable change test (Andrew Chow) df765a4 tests: rpc_fundrawtx lock to UTXO types (Andrew Chow) Pull request description: To ease in the use of SRD as our fallback mechanism, this PR adds it as a secondary fallback algorithm in addition to the knapsack solver. Since #22009, the solution with the least waste will be chosen. This pattern is continued with SRD simply being another solution whose waste is compared. ACKs for top commit: glozow: reACK 3633b66 via `git range-diff 981b9d1...3633b66`, thanks for taking the suggestions laanwj: Concept and code review ACK 3633b66 Tree-SHA512: 895659f553fea2230990136565bdf18b1328de8b0ce47f06b64bb4d69301f6dd68cb38debe5c24fb6de1317b735fc020a987c541f00bbea65229de47e53adf92
2 parents f036c35 + 3633b66 commit d5d0a5c

File tree

7 files changed

+135
-19
lines changed

7 files changed

+135
-19
lines changed

src/wallet/coinselection.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
#include <wallet/coinselection.h>
66

77
#include <policy/feerate.h>
8+
#include <util/check.h>
89
#include <util/system.h>
910
#include <util/moneystr.h>
1011

12+
#include <numeric>
1113
#include <optional>
1214

1315
// Descending order comparator
@@ -168,6 +170,30 @@ bool SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selectio
168170
return true;
169171
}
170172

173+
std::optional<std::pair<std::set<CInputCoin>, CAmount>> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value)
174+
{
175+
std::set<CInputCoin> out_set;
176+
CAmount value_ret = 0;
177+
178+
std::vector<size_t> indexes;
179+
indexes.resize(utxo_pool.size());
180+
std::iota(indexes.begin(), indexes.end(), 0);
181+
Shuffle(indexes.begin(), indexes.end(), FastRandomContext());
182+
183+
CAmount selected_eff_value = 0;
184+
for (const size_t i : indexes) {
185+
const OutputGroup& group = utxo_pool.at(i);
186+
Assume(group.GetSelectionAmount() > 0);
187+
selected_eff_value += group.GetSelectionAmount();
188+
value_ret += group.m_value;
189+
util::insert(out_set, group.m_outputs);
190+
if (selected_eff_value >= target_value) {
191+
return std::make_pair(out_set, value_ret);
192+
}
193+
}
194+
return std::nullopt;
195+
}
196+
171197
static void ApproximateBestSubset(const std::vector<OutputGroup>& groups, const CAmount& nTotalLower, const CAmount& nTargetValue,
172198
std::vector<char>& vfBest, CAmount& nBest, int iterations = 1000)
173199
{

src/wallet/coinselection.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
#include <primitives/transaction.h>
1111
#include <random.h>
1212

13+
#include <optional>
14+
1315
//! target minimum change amount
1416
static constexpr CAmount MIN_CHANGE{COIN / 100};
1517
//! final minimum change amount after paying for fees
@@ -185,6 +187,15 @@ struct OutputGroup
185187

186188
bool SelectCoinsBnB(std::vector<OutputGroup>& utxo_pool, const CAmount& selection_target, const CAmount& cost_of_change, std::set<CInputCoin>& out_set, CAmount& value_ret);
187189

190+
/** Select coins by Single Random Draw. OutputGroups are selected randomly from the eligible
191+
* outputs until the target is satisfied
192+
*
193+
* @param[in] utxo_pool The positive effective value OutputGroups eligible for selection
194+
* @param[in] target_value The target value to select for
195+
* @returns If successful, a pair of set of outputs and total selected value, otherwise, std::nullopt
196+
*/
197+
std::optional<std::pair<std::set<CInputCoin>, CAmount>> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value);
198+
188199
// Original coin selection algorithm as a fallback
189200
bool KnapsackSolver(const CAmount& nTargetValue, std::vector<OutputGroup>& groups, std::set<CInputCoin>& setCoinsRet, CAmount& nValueRet);
190201

src/wallet/spend.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,15 @@ bool AttemptSelection(const CWallet& wallet, const CAmount& nTargetValue, const
387387
results.emplace_back(std::make_tuple(waste, std::move(knapsack_coins), knapsack_value));
388388
}
389389

390+
// We include the minimum final change for SRD as we do want to avoid making really small change.
391+
// KnapsackSolver does not need this because it includes MIN_CHANGE internally.
392+
const CAmount srd_target = nTargetValue + coin_selection_params.m_change_fee + MIN_FINAL_CHANGE;
393+
auto srd_result = SelectCoinsSRD(positive_groups, srd_target);
394+
if (srd_result != std::nullopt) {
395+
const auto waste = GetSelectionWaste(srd_result->first, coin_selection_params.m_cost_of_change, srd_target, !coin_selection_params.m_subtract_fee_outputs);
396+
results.emplace_back(std::make_tuple(waste, std::move(srd_result->first), srd_result->second));
397+
}
398+
390399
if (results.size() == 0) {
391400
// No solution found
392401
return false;

test/functional/rpc_fundrawtransaction.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,40 @@ def setup_network(self):
4747
self.connect_nodes(0, 2)
4848
self.connect_nodes(0, 3)
4949

50+
def lock_outputs_type(self, wallet, outputtype):
51+
"""
52+
Only allow UTXOs of the given type
53+
"""
54+
if outputtype in ["legacy", "p2pkh", "pkh"]:
55+
prefixes = ["pkh(", "sh(multi("]
56+
elif outputtype in ["p2sh-segwit", "sh_wpkh"]:
57+
prefixes = ["sh(wpkh(", "sh(wsh("]
58+
elif outputtype in ["bech32", "wpkh"]:
59+
prefixes = ["wpkh(", "wsh("]
60+
else:
61+
assert False, f"Unknown output type {outputtype}"
62+
63+
to_lock = []
64+
for utxo in wallet.listunspent():
65+
if "desc" in utxo:
66+
for prefix in prefixes:
67+
if utxo["desc"].startswith(prefix):
68+
to_lock.append({"txid": utxo["txid"], "vout": utxo["vout"]})
69+
wallet.lockunspent(False, to_lock)
70+
71+
def unlock_utxos(self, wallet):
72+
"""
73+
Unlock all UTXOs except the watchonly one
74+
"""
75+
to_keep = []
76+
if self.watchonly_txid is not None and self.watchonly_vout is not None:
77+
to_keep.append({"txid": self.watchonly_txid, "vout": self.watchonly_vout})
78+
wallet.lockunspent(True)
79+
wallet.lockunspent(False, to_keep)
80+
5081
def run_test(self):
82+
self.watchonly_txid = None
83+
self.watchonly_vout = None
5184
self.log.info("Connect nodes, set fees, generate blocks, and sync")
5285
self.min_relay_tx_fee = self.nodes[0].getnetworkinfo()['relayfee']
5386
# This test is not meant to test fee estimation and we'd like
@@ -373,6 +406,7 @@ def test_invalid_input(self):
373406
def test_fee_p2pkh(self):
374407
"""Compare fee of a standard pubkeyhash transaction."""
375408
self.log.info("Test fundrawtxn p2pkh fee")
409+
self.lock_outputs_type(self.nodes[0], "p2pkh")
376410
inputs = []
377411
outputs = {self.nodes[1].getnewaddress():1.1}
378412
rawtx = self.nodes[0].createrawtransaction(inputs, outputs)
@@ -386,9 +420,12 @@ def test_fee_p2pkh(self):
386420
feeDelta = Decimal(fundedTx['fee']) - Decimal(signedFee)
387421
assert feeDelta >= 0 and feeDelta <= self.fee_tolerance
388422

423+
self.unlock_utxos(self.nodes[0])
424+
389425
def test_fee_p2pkh_multi_out(self):
390426
"""Compare fee of a standard pubkeyhash transaction with multiple outputs."""
391427
self.log.info("Test fundrawtxn p2pkh fee with multiple outputs")
428+
self.lock_outputs_type(self.nodes[0], "p2pkh")
392429
inputs = []
393430
outputs = {
394431
self.nodes[1].getnewaddress():1.1,
@@ -409,8 +446,11 @@ def test_fee_p2pkh_multi_out(self):
409446
feeDelta = Decimal(fundedTx['fee']) - Decimal(signedFee)
410447
assert feeDelta >= 0 and feeDelta <= self.fee_tolerance
411448

449+
self.unlock_utxos(self.nodes[0])
450+
412451
def test_fee_p2sh(self):
413452
"""Compare fee of a 2-of-2 multisig p2sh transaction."""
453+
self.lock_outputs_type(self.nodes[0], "p2pkh")
414454
# Create 2-of-2 addr.
415455
addr1 = self.nodes[1].getnewaddress()
416456
addr2 = self.nodes[1].getnewaddress()
@@ -433,9 +473,12 @@ def test_fee_p2sh(self):
433473
feeDelta = Decimal(fundedTx['fee']) - Decimal(signedFee)
434474
assert feeDelta >= 0 and feeDelta <= self.fee_tolerance
435475

476+
self.unlock_utxos(self.nodes[0])
477+
436478
def test_fee_4of5(self):
437479
"""Compare fee of a standard pubkeyhash transaction."""
438480
self.log.info("Test fundrawtxn fee with 4-of-5 addresses")
481+
self.lock_outputs_type(self.nodes[0], "p2pkh")
439482

440483
# Create 4-of-5 addr.
441484
addr1 = self.nodes[1].getnewaddress()
@@ -474,6 +517,8 @@ def test_fee_4of5(self):
474517
feeDelta = Decimal(fundedTx['fee']) - Decimal(signedFee)
475518
assert feeDelta >= 0 and feeDelta <= self.fee_tolerance
476519

520+
self.unlock_utxos(self.nodes[0])
521+
477522
def test_spend_2of2(self):
478523
"""Spend a 2-of-2 multisig transaction over fundraw."""
479524
self.log.info("Test fundpsbt spending 2-of-2 multisig")
@@ -542,15 +587,18 @@ def test_locked_wallet(self):
542587
# Drain the keypool.
543588
self.nodes[1].getnewaddress()
544589
self.nodes[1].getrawchangeaddress()
545-
inputs = []
546-
outputs = {self.nodes[0].getnewaddress():1.19999500}
590+
591+
# Choose 2 inputs
592+
inputs = self.nodes[1].listunspent()[0:2]
593+
value = sum(inp["amount"] for inp in inputs) - Decimal("0.00000500") # Pay a 500 sat fee
594+
outputs = {self.nodes[0].getnewaddress():value}
547595
rawtx = self.nodes[1].createrawtransaction(inputs, outputs)
548596
# fund a transaction that does not require a new key for the change output
549597
self.nodes[1].fundrawtransaction(rawtx)
550598

551599
# fund a transaction that requires a new key for the change output
552600
# creating the key must be impossible because the wallet is locked
553-
outputs = {self.nodes[0].getnewaddress():1.1}
601+
outputs = {self.nodes[0].getnewaddress():value - Decimal("0.1")}
554602
rawtx = self.nodes[1].createrawtransaction(inputs, outputs)
555603
assert_raises_rpc_error(-4, "Transaction needs a change address, but we can't generate it.", self.nodes[1].fundrawtransaction, rawtx)
556604

@@ -944,31 +992,31 @@ def test_include_unsafe(self):
944992

945993
# We receive unconfirmed funds from external keys (unsafe outputs).
946994
addr = wallet.getnewaddress()
947-
txid1 = self.nodes[2].sendtoaddress(addr, 6)
948-
txid2 = self.nodes[2].sendtoaddress(addr, 4)
949-
self.sync_all()
950-
vout1 = find_vout_for_address(wallet, txid1, addr)
951-
vout2 = find_vout_for_address(wallet, txid2, addr)
995+
inputs = []
996+
for i in range(0, 2):
997+
txid = self.nodes[2].sendtoaddress(addr, 5)
998+
self.sync_mempools()
999+
vout = find_vout_for_address(wallet, txid, addr)
1000+
inputs.append((txid, vout))
9521001

9531002
# Unsafe inputs are ignored by default.
954-
rawtx = wallet.createrawtransaction([], [{self.nodes[2].getnewaddress(): 5}])
1003+
rawtx = wallet.createrawtransaction([], [{self.nodes[2].getnewaddress(): 7.5}])
9551004
assert_raises_rpc_error(-4, "Insufficient funds", wallet.fundrawtransaction, rawtx)
9561005

9571006
# But we can opt-in to use them for funding.
9581007
fundedtx = wallet.fundrawtransaction(rawtx, {"include_unsafe": True})
9591008
tx_dec = wallet.decoderawtransaction(fundedtx['hex'])
960-
assert any([txin['txid'] == txid1 and txin['vout'] == vout1 for txin in tx_dec['vin']])
1009+
assert all((txin["txid"], txin["vout"]) in inputs for txin in tx_dec["vin"])
9611010
signedtx = wallet.signrawtransactionwithwallet(fundedtx['hex'])
962-
wallet.sendrawtransaction(signedtx['hex'])
1011+
assert wallet.testmempoolaccept([signedtx['hex']])[0]["allowed"]
9631012

9641013
# And we can also use them once they're confirmed.
9651014
self.generate(self.nodes[0], 1)
966-
rawtx = wallet.createrawtransaction([], [{self.nodes[2].getnewaddress(): 3}])
967-
fundedtx = wallet.fundrawtransaction(rawtx, {"include_unsafe": True})
1015+
fundedtx = wallet.fundrawtransaction(rawtx, {"include_unsafe": False})
9681016
tx_dec = wallet.decoderawtransaction(fundedtx['hex'])
969-
assert any([txin['txid'] == txid2 and txin['vout'] == vout2 for txin in tx_dec['vin']])
1017+
assert all((txin["txid"], txin["vout"]) in inputs for txin in tx_dec["vin"])
9701018
signedtx = wallet.signrawtransactionwithwallet(fundedtx['hex'])
971-
wallet.sendrawtransaction(signedtx['hex'])
1019+
assert wallet.testmempoolaccept([signedtx['hex']])[0]["allowed"]
9721020

9731021
def test_22670(self):
9741022
# In issue #22670, it was observed that ApproximateBestSubset may

test/functional/wallet_basic.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
assert_equal,
1414
assert_fee_amount,
1515
assert_raises_rpc_error,
16+
find_vout_for_address,
1617
)
1718
from test_framework.wallet_util import test_address
1819

@@ -463,6 +464,9 @@ def run_test(self):
463464
# 1. Send some coins to generate new UTXO
464465
address_to_import = self.nodes[2].getnewaddress()
465466
txid = self.nodes[0].sendtoaddress(address_to_import, 1)
467+
self.sync_mempools(self.nodes[0:3])
468+
vout = find_vout_for_address(self.nodes[2], txid, address_to_import)
469+
self.nodes[2].lockunspent(False, [{"txid": txid, "vout": vout}])
466470
self.generate(self.nodes[0], 1)
467471
self.sync_all(self.nodes[0:3])
468472

test/functional/wallet_txn_clone.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from test_framework.test_framework import BitcoinTestFramework
88
from test_framework.util import (
99
assert_equal,
10+
find_vout_for_address
1011
)
1112
from test_framework.messages import (
1213
COIN,
@@ -33,6 +34,13 @@ def setup_network(self):
3334
super().setup_network()
3435
self.disconnect_nodes(1, 2)
3536

37+
def spend_txid(self, txid, vout, outputs):
38+
inputs = [{"txid": txid, "vout": vout}]
39+
tx = self.nodes[0].createrawtransaction(inputs, outputs)
40+
tx = self.nodes[0].fundrawtransaction(tx)
41+
tx = self.nodes[0].signrawtransactionwithwallet(tx['hex'])
42+
return self.nodes[0].sendrawtransaction(tx['hex'])
43+
3644
def run_test(self):
3745
if self.options.segwit:
3846
output_type = "p2sh-segwit"
@@ -49,6 +57,7 @@ def run_test(self):
4957
node0_address1 = self.nodes[0].getnewaddress(address_type=output_type)
5058
node0_txid1 = self.nodes[0].sendtoaddress(node0_address1, 1219)
5159
node0_tx1 = self.nodes[0].gettransaction(node0_txid1)
60+
self.nodes[0].lockunspent(False, [{"txid":node0_txid1, "vout": find_vout_for_address(self.nodes[0], node0_txid1, node0_address1)}])
5261

5362
node0_address2 = self.nodes[0].getnewaddress(address_type=output_type)
5463
node0_txid2 = self.nodes[0].sendtoaddress(node0_address2, 29)
@@ -61,8 +70,8 @@ def run_test(self):
6170
node1_address = self.nodes[1].getnewaddress()
6271

6372
# Send tx1, and another transaction tx2 that won't be cloned
64-
txid1 = self.nodes[0].sendtoaddress(node1_address, 40)
65-
txid2 = self.nodes[0].sendtoaddress(node1_address, 20)
73+
txid1 = self.spend_txid(node0_txid1, find_vout_for_address(self.nodes[0], node0_txid1, node0_address1), {node1_address: 40})
74+
txid2 = self.spend_txid(node0_txid2, find_vout_for_address(self.nodes[0], node0_txid2, node0_address2), {node1_address: 20})
6675

6776
# Construct a clone of tx1, to be malleated
6877
rawtx1 = self.nodes[0].getrawtransaction(txid1, 1)

test/functional/wallet_txn_doublespend.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from test_framework.util import (
1010
assert_equal,
1111
find_output,
12+
find_vout_for_address
1213
)
1314

1415

@@ -29,6 +30,13 @@ def setup_network(self):
2930
super().setup_network()
3031
self.disconnect_nodes(1, 2)
3132

33+
def spend_txid(self, txid, vout, outputs):
34+
inputs = [{"txid": txid, "vout": vout}]
35+
tx = self.nodes[0].createrawtransaction(inputs, outputs)
36+
tx = self.nodes[0].fundrawtransaction(tx)
37+
tx = self.nodes[0].signrawtransactionwithwallet(tx['hex'])
38+
return self.nodes[0].sendrawtransaction(tx['hex'])
39+
3240
def run_test(self):
3341
# All nodes should start with 1,250 BTC:
3442
starting_balance = 1250
@@ -47,6 +55,7 @@ def run_test(self):
4755
node0_address_foo = self.nodes[0].getnewaddress()
4856
fund_foo_txid = self.nodes[0].sendtoaddress(node0_address_foo, 1219)
4957
fund_foo_tx = self.nodes[0].gettransaction(fund_foo_txid)
58+
self.nodes[0].lockunspent(False, [{"txid":fund_foo_txid, "vout": find_vout_for_address(self.nodes[0], fund_foo_txid, node0_address_foo)}])
5059

5160
node0_address_bar = self.nodes[0].getnewaddress()
5261
fund_bar_txid = self.nodes[0].sendtoaddress(node0_address_bar, 29)
@@ -77,8 +86,8 @@ def run_test(self):
7786
assert_equal(doublespend["complete"], True)
7887

7988
# Create two spends using 1 50 BTC coin each
80-
txid1 = self.nodes[0].sendtoaddress(node1_address, 40)
81-
txid2 = self.nodes[0].sendtoaddress(node1_address, 20)
89+
txid1 = self.spend_txid(fund_foo_txid, find_vout_for_address(self.nodes[0], fund_foo_txid, node0_address_foo), {node1_address: 40})
90+
txid2 = self.spend_txid(fund_bar_txid, find_vout_for_address(self.nodes[0], fund_bar_txid, node0_address_bar), {node1_address: 20})
8291

8392
# Have node0 mine a block:
8493
if (self.options.mine_block):

0 commit comments

Comments
 (0)