Skip to content

Commit b55b11f

Browse files
committed
Merge bitcoin/bitcoin#25375: rpc: add minconf/maxconf options to sendall and fund transaction calls
cfe5aeb rpc: add minconf and maxconf options to sendall (ishaanam) a07a413 Wallet/RPC: Allow specifying min & max chain depth for inputs used by fund calls (Juan Pablo Civile) Pull request description: This PR adds a "minconf" option to `fundrawtransaction`, `walletcreatefundedpsbt`, and `sendall`. Alternative implementation of #14641 Fixes #14542 Edit: This PR now also adds this option to `send` ACKs for top commit: achow101: ACK cfe5aeb Xekyo: ACK cfe5aeb furszy: diff ACK cfe5aeb, only a non-blocking nit. Tree-SHA512: 836e610926eec3a62308fba88ddbd6a13d8f4dac37352d0309599f893cde9c1df5e9c298fda6e076493068e4d213e4afa7290a9e3bdb5a95a5d507da3f7b59e8
2 parents 599e941 + cfe5aeb commit b55b11f

File tree

6 files changed

+261
-2
lines changed

6 files changed

+261
-2
lines changed

doc/release-notes-25375.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Updated RPCs
2+
--------
3+
4+
The `minconf` option, which allows a user to specify the minimum number
5+
of confirmations a UTXO being spent has, and the `maxconf` option,
6+
which allows specifying the maximum number of confirmations, have been
7+
added to the following RPCs:
8+
- `fundrawtransaction`
9+
- `send`
10+
- `walletcreatefundedpsbt`
11+
- `sendall`

src/wallet/rpc/spend.cpp

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,8 @@ void FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& fee_out,
528528
{"replaceable", UniValueType(UniValue::VBOOL)},
529529
{"conf_target", UniValueType(UniValue::VNUM)},
530530
{"estimate_mode", UniValueType(UniValue::VSTR)},
531+
{"minconf", UniValueType(UniValue::VNUM)},
532+
{"maxconf", UniValueType(UniValue::VNUM)},
531533
{"input_weights", UniValueType(UniValue::VARR)},
532534
},
533535
true, true);
@@ -593,6 +595,22 @@ void FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& fee_out,
593595
if (options.exists("replaceable")) {
594596
coinControl.m_signal_bip125_rbf = options["replaceable"].get_bool();
595597
}
598+
599+
if (options.exists("minconf")) {
600+
coinControl.m_min_depth = options["minconf"].getInt<int>();
601+
602+
if (coinControl.m_min_depth < 0) {
603+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Negative minconf");
604+
}
605+
}
606+
607+
if (options.exists("maxconf")) {
608+
coinControl.m_max_depth = options["maxconf"].getInt<int>();
609+
610+
if (coinControl.m_max_depth < coinControl.m_min_depth) {
611+
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("maxconf can't be lower than minconf: %d < %d", coinControl.m_max_depth, coinControl.m_min_depth));
612+
}
613+
}
596614
SetFeeEstimateMode(wallet, coinControl, options["conf_target"], options["estimate_mode"], options["fee_rate"], override_min_fee);
597615
}
598616
} else {
@@ -744,6 +762,8 @@ RPCHelpMan fundrawtransaction()
744762
{"include_unsafe", RPCArg::Type::BOOL, RPCArg::Default{false}, "Include inputs that are not safe to spend (unconfirmed transactions from outside keys and unconfirmed replacement transactions).\n"
745763
"Warning: the resulting transaction may become invalid if one of the unsafe inputs disappears.\n"
746764
"If that happens, you will need to fund the transaction with different inputs and republish it."},
765+
{"minconf", RPCArg::Type::NUM, RPCArg::Default{0}, "If add_inputs is specified, require inputs with at least this many confirmations."},
766+
{"maxconf", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "If add_inputs is specified, require inputs with at most this many confirmations."},
747767
{"changeAddress", RPCArg::Type::STR, RPCArg::DefaultHint{"automatic"}, "The bitcoin address to receive the change"},
748768
{"changePosition", RPCArg::Type::NUM, RPCArg::DefaultHint{"random"}, "The index of the change output"},
749769
{"change_type", RPCArg::Type::STR, RPCArg::DefaultHint{"set by -changetype"}, "The output type to use. Only valid if changeAddress is not specified. Options are \"legacy\", \"p2sh-segwit\", \"bech32\", and \"bech32m\"."},
@@ -1147,6 +1167,8 @@ RPCHelpMan send()
11471167
{"include_unsafe", RPCArg::Type::BOOL, RPCArg::Default{false}, "Include inputs that are not safe to spend (unconfirmed transactions from outside keys and unconfirmed replacement transactions).\n"
11481168
"Warning: the resulting transaction may become invalid if one of the unsafe inputs disappears.\n"
11491169
"If that happens, you will need to fund the transaction with different inputs and republish it."},
1170+
{"minconf", RPCArg::Type::NUM, RPCArg::Default{0}, "If add_inputs is specified, require inputs with at least this many confirmations."},
1171+
{"maxconf", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "If add_inputs is specified, require inputs with at most this many confirmations."},
11501172
{"add_to_wallet", RPCArg::Type::BOOL, RPCArg::Default{true}, "When false, returns a serialized transaction which will not be added to the wallet or broadcast"},
11511173
{"change_address", RPCArg::Type::STR, RPCArg::DefaultHint{"automatic"}, "The bitcoin address to receive the change"},
11521174
{"change_position", RPCArg::Type::NUM, RPCArg::DefaultHint{"random"}, "The index of the change output"},
@@ -1270,7 +1292,7 @@ RPCHelpMan sendall()
12701292
{"include_watching", RPCArg::Type::BOOL, RPCArg::DefaultHint{"true for watch-only wallets, otherwise false"}, "Also select inputs which are watch-only.\n"
12711293
"Only solvable inputs can be used. Watch-only destinations are solvable if the public key and/or output script was imported,\n"
12721294
"e.g. with 'importpubkey' or 'importmulti' with the 'pubkeys' or 'desc' field."},
1273-
{"inputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Use exactly the specified inputs to build the transaction. Specifying inputs is incompatible with send_max.",
1295+
{"inputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Use exactly the specified inputs to build the transaction. Specifying inputs is incompatible with the send_max, minconf, and maxconf options.",
12741296
{
12751297
{"", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED, "",
12761298
{
@@ -1285,6 +1307,8 @@ RPCHelpMan sendall()
12851307
{"lock_unspents", RPCArg::Type::BOOL, RPCArg::Default{false}, "Lock selected unspent outputs"},
12861308
{"psbt", RPCArg::Type::BOOL, RPCArg::DefaultHint{"automatic"}, "Always return a PSBT, implies add_to_wallet=false."},
12871309
{"send_max", RPCArg::Type::BOOL, RPCArg::Default{false}, "When true, only use UTXOs that can pay for their own fees to maximize the output amount. When 'false' (default), no UTXO is left behind. send_max is incompatible with providing specific inputs."},
1310+
{"minconf", RPCArg::Type::NUM, RPCArg::Default{0}, "Require inputs with at least this many confirmations."},
1311+
{"maxconf", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "Require inputs with at most this many confirmations."},
12881312
},
12891313
FundTxDoc()
12901314
),
@@ -1359,6 +1383,23 @@ RPCHelpMan sendall()
13591383

13601384
coin_control.fAllowWatchOnly = ParseIncludeWatchonly(options["include_watching"], *pwallet);
13611385

1386+
if (options.exists("minconf")) {
1387+
if (options["minconf"].getInt<int>() < 0)
1388+
{
1389+
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Invalid minconf (minconf cannot be negative): %s", options["minconf"].getInt<int>()));
1390+
}
1391+
1392+
coin_control.m_min_depth = options["minconf"].getInt<int>();
1393+
}
1394+
1395+
if (options.exists("maxconf")) {
1396+
coin_control.m_max_depth = options["maxconf"].getInt<int>();
1397+
1398+
if (coin_control.m_max_depth < coin_control.m_min_depth) {
1399+
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("maxconf can't be lower than minconf: %d < %d", coin_control.m_max_depth, coin_control.m_min_depth));
1400+
}
1401+
}
1402+
13621403
const bool rbf{options.exists("replaceable") ? options["replaceable"].get_bool() : pwallet->m_signal_rbf};
13631404

13641405
FeeCalculation fee_calc_out;
@@ -1380,6 +1421,8 @@ RPCHelpMan sendall()
13801421
bool send_max{options.exists("send_max") ? options["send_max"].get_bool() : false};
13811422
if (options.exists("inputs") && options.exists("send_max")) {
13821423
throw JSONRPCError(RPC_INVALID_PARAMETER, "Cannot combine send_max with specific inputs.");
1424+
} else if (options.exists("inputs") && (options.exists("minconf") || options.exists("maxconf"))) {
1425+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Cannot combine minconf or maxconf with specific inputs.");
13831426
} else if (options.exists("inputs")) {
13841427
for (const CTxIn& input : rawTx.vin) {
13851428
if (pwallet->IsSpent(input.prevout)) {
@@ -1603,6 +1646,8 @@ RPCHelpMan walletcreatefundedpsbt()
16031646
{"include_unsafe", RPCArg::Type::BOOL, RPCArg::Default{false}, "Include inputs that are not safe to spend (unconfirmed transactions from outside keys and unconfirmed replacement transactions).\n"
16041647
"Warning: the resulting transaction may become invalid if one of the unsafe inputs disappears.\n"
16051648
"If that happens, you will need to fund the transaction with different inputs and republish it."},
1649+
{"minconf", RPCArg::Type::NUM, RPCArg::Default{0}, "If add_inputs is specified, require inputs with at least this many confirmations."},
1650+
{"maxconf", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "If add_inputs is specified, require inputs with at most this many confirmations."},
16061651
{"changeAddress", RPCArg::Type::STR, RPCArg::DefaultHint{"automatic"}, "The bitcoin address to receive the change"},
16071652
{"changePosition", RPCArg::Type::NUM, RPCArg::DefaultHint{"random"}, "The index of the change output"},
16081653
{"change_type", RPCArg::Type::STR, RPCArg::DefaultHint{"set by -changetype"}, "The output type to use. Only valid if changeAddress is not specified. Options are \"legacy\", \"p2sh-segwit\", \"bech32\", and \"bech32m\"."},

test/functional/rpc_psbt.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
assert_approx,
3737
assert_equal,
3838
assert_greater_than,
39+
assert_greater_than_or_equal,
3940
assert_raises_rpc_error,
4041
find_output,
4142
find_vout_for_address,
@@ -106,6 +107,65 @@ def test_utxo_conversion(self):
106107
self.connect_nodes(0, 1)
107108
self.connect_nodes(0, 2)
108109

110+
def test_input_confs_control(self):
111+
self.nodes[0].createwallet("minconf")
112+
wallet = self.nodes[0].get_wallet_rpc("minconf")
113+
114+
# Fund the wallet with different chain heights
115+
for _ in range(2):
116+
self.nodes[1].sendmany("", {wallet.getnewaddress():1, wallet.getnewaddress():1})
117+
self.generate(self.nodes[1], 1)
118+
119+
unconfirmed_txid = wallet.sendtoaddress(wallet.getnewaddress(), 0.5)
120+
121+
self.log.info("Crafting PSBT using an unconfirmed input")
122+
target_address = self.nodes[1].getnewaddress()
123+
psbtx1 = wallet.walletcreatefundedpsbt([], {target_address: 0.1}, 0, {'fee_rate': 1, 'maxconf': 0})['psbt']
124+
125+
# Make sure we only had the one input
126+
tx1_inputs = self.nodes[0].decodepsbt(psbtx1)['tx']['vin']
127+
assert_equal(len(tx1_inputs), 1)
128+
129+
utxo1 = tx1_inputs[0]
130+
assert_equal(unconfirmed_txid, utxo1['txid'])
131+
132+
signed_tx1 = wallet.walletprocesspsbt(psbtx1)['psbt']
133+
final_tx1 = wallet.finalizepsbt(signed_tx1)['hex']
134+
txid1 = self.nodes[0].sendrawtransaction(final_tx1)
135+
136+
mempool = self.nodes[0].getrawmempool()
137+
assert txid1 in mempool
138+
139+
self.log.info("Fail to craft a new PSBT that sends more funds with add_inputs = False")
140+
assert_raises_rpc_error(-4, "The preselected coins total amount does not cover the transaction target. Please allow other inputs to be automatically selected or include more coins manually", wallet.walletcreatefundedpsbt, [{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': False})
141+
142+
self.log.info("Fail to craft a new PSBT with minconf above highest one")
143+
assert_raises_rpc_error(-4, "Insufficient funds", wallet.walletcreatefundedpsbt, [{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': True, 'minconf': 3, 'fee_rate': 10})
144+
145+
self.log.info("Fail to broadcast a new PSBT with maxconf 0 due to BIP125 rules to verify it actually chose unconfirmed outputs")
146+
psbt_invalid = wallet.walletcreatefundedpsbt([{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': True, 'maxconf': 0, 'fee_rate': 10})['psbt']
147+
signed_invalid = wallet.walletprocesspsbt(psbt_invalid)['psbt']
148+
final_invalid = wallet.finalizepsbt(signed_invalid)['hex']
149+
assert_raises_rpc_error(-26, "bad-txns-spends-conflicting-tx", self.nodes[0].sendrawtransaction, final_invalid)
150+
151+
self.log.info("Craft a replacement adding inputs with highest confs possible")
152+
psbtx2 = wallet.walletcreatefundedpsbt([{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': True, 'minconf': 2, 'fee_rate': 10})['psbt']
153+
tx2_inputs = self.nodes[0].decodepsbt(psbtx2)['tx']['vin']
154+
assert_greater_than_or_equal(len(tx2_inputs), 2)
155+
for vin in tx2_inputs:
156+
if vin['txid'] != unconfirmed_txid:
157+
assert_greater_than_or_equal(self.nodes[0].gettxout(vin['txid'], vin['vout'])['confirmations'], 2)
158+
159+
signed_tx2 = wallet.walletprocesspsbt(psbtx2)['psbt']
160+
final_tx2 = wallet.finalizepsbt(signed_tx2)['hex']
161+
txid2 = self.nodes[0].sendrawtransaction(final_tx2)
162+
163+
mempool = self.nodes[0].getrawmempool()
164+
assert txid1 not in mempool
165+
assert txid2 in mempool
166+
167+
wallet.unloadwallet()
168+
109169
def assert_change_type(self, psbtx, expected_type):
110170
"""Assert that the given PSBT has a change output with the given type."""
111171

@@ -514,6 +574,8 @@ def run_test(self):
514574
# TODO: Re-enable this for segwit v1
515575
# self.test_utxo_conversion()
516576

577+
self.test_input_confs_control()
578+
517579
# Test that psbts with p2pkh outputs are created properly
518580
p2pkh = self.nodes[0].getnewaddress(address_type='legacy')
519581
psbt = self.nodes[1].walletcreatefundedpsbt([], [{p2pkh : 1}], 0, {"includeWatching" : True}, True)

test/functional/wallet_fundrawtransaction.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def run_test(self):
148148
self.test_external_inputs()
149149
self.test_22670()
150150
self.test_feerate_rounding()
151+
self.test_input_confs_control()
151152

152153
def test_change_position(self):
153154
"""Ensure setting changePosition in fundraw with an exact match is handled properly."""
@@ -1403,6 +1404,66 @@ def test_feerate_rounding(self):
14031404
rawtx = w.createrawtransaction(inputs=[], outputs=[{self.nodes[0].getnewaddress(address_type="bech32"): 1 - 0.00000202}])
14041405
assert_raises_rpc_error(-4, "Insufficient funds", w.fundrawtransaction, rawtx, {"fee_rate": 1.85})
14051406

1407+
def test_input_confs_control(self):
1408+
self.nodes[0].createwallet("minconf")
1409+
wallet = self.nodes[0].get_wallet_rpc("minconf")
1410+
1411+
# Fund the wallet with different chain heights
1412+
for _ in range(2):
1413+
self.nodes[2].sendmany("", {wallet.getnewaddress():1, wallet.getnewaddress():1})
1414+
self.generate(self.nodes[2], 1)
1415+
1416+
unconfirmed_txid = wallet.sendtoaddress(wallet.getnewaddress(), 0.5)
1417+
1418+
self.log.info("Crafting TX using an unconfirmed input")
1419+
target_address = self.nodes[2].getnewaddress()
1420+
raw_tx1 = wallet.createrawtransaction([], {target_address: 0.1}, 0, True)
1421+
funded_tx1 = wallet.fundrawtransaction(raw_tx1, {'fee_rate': 1, 'maxconf': 0})['hex']
1422+
1423+
# Make sure we only had the one input
1424+
tx1_inputs = self.nodes[0].decoderawtransaction(funded_tx1)['vin']
1425+
assert_equal(len(tx1_inputs), 1)
1426+
1427+
utxo1 = tx1_inputs[0]
1428+
assert unconfirmed_txid == utxo1['txid']
1429+
1430+
final_tx1 = wallet.signrawtransactionwithwallet(funded_tx1)['hex']
1431+
txid1 = self.nodes[0].sendrawtransaction(final_tx1)
1432+
1433+
mempool = self.nodes[0].getrawmempool()
1434+
assert txid1 in mempool
1435+
1436+
self.log.info("Fail to craft a new TX with minconf above highest one")
1437+
# Create a replacement tx to 'final_tx1' that has 1 BTC target instead of 0.1.
1438+
raw_tx2 = wallet.createrawtransaction([{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1})
1439+
assert_raises_rpc_error(-4, "Insufficient funds", wallet.fundrawtransaction, raw_tx2, {'add_inputs': True, 'minconf': 3, 'fee_rate': 10})
1440+
1441+
self.log.info("Fail to broadcast a new TX with maxconf 0 due to BIP125 rules to verify it actually chose unconfirmed outputs")
1442+
# Now fund 'raw_tx2' to fulfill the total target (1 BTC) by using all the wallet unconfirmed outputs.
1443+
# As it was created with the first unconfirmed output, 'raw_tx2' only has 0.1 BTC covered (need to fund 0.9 BTC more).
1444+
# So, the selection process, to cover the amount, will pick up the 'final_tx1' output as well, which is an output of the tx that this
1445+
# new tx is replacing!. So, once we send it to the mempool, it will return a "bad-txns-spends-conflicting-tx"
1446+
# because the input will no longer exist once the first tx gets replaced by this new one).
1447+
funded_invalid = wallet.fundrawtransaction(raw_tx2, {'add_inputs': True, 'maxconf': 0, 'fee_rate': 10})['hex']
1448+
final_invalid = wallet.signrawtransactionwithwallet(funded_invalid)['hex']
1449+
assert_raises_rpc_error(-26, "bad-txns-spends-conflicting-tx", self.nodes[0].sendrawtransaction, final_invalid)
1450+
1451+
self.log.info("Craft a replacement adding inputs with highest depth possible")
1452+
funded_tx2 = wallet.fundrawtransaction(raw_tx2, {'add_inputs': True, 'minconf': 2, 'fee_rate': 10})['hex']
1453+
tx2_inputs = self.nodes[0].decoderawtransaction(funded_tx2)['vin']
1454+
assert_greater_than_or_equal(len(tx2_inputs), 2)
1455+
for vin in tx2_inputs:
1456+
if vin['txid'] != unconfirmed_txid:
1457+
assert_greater_than_or_equal(self.nodes[0].gettxout(vin['txid'], vin['vout'])['confirmations'], 2)
1458+
1459+
final_tx2 = wallet.signrawtransactionwithwallet(funded_tx2)['hex']
1460+
txid2 = self.nodes[0].sendrawtransaction(final_tx2)
1461+
1462+
mempool = self.nodes[0].getrawmempool()
1463+
assert txid1 not in mempool
1464+
assert txid2 in mempool
1465+
1466+
wallet.unloadwallet()
14061467

14071468
if __name__ == '__main__':
14081469
RawTransactionsTest().main()

0 commit comments

Comments
 (0)