Skip to content

Commit 04afe55

Browse files
committed
Merge bitcoin/bitcoin#26467: bumpfee: Allow the user to choose which output is change
e8c31f1 tests: Test for bumping single output transaction (Andrew Chow) 4f4d440 test: Test bumpfee reduce_output (Andrew Chow) 7d83502 bumpfee: Allow original change position to be specified (Andrew Chow) Pull request description: When bumping the transaction fee, we will try to find the change output of the transaction in order to have an output whose value we can modify so that we can meet the target feerate. However we do not always find the change output which can cause us to unnecessarily add an additional output to the transaction. We can avoid this issue by outsourcing the determination of change to the user if they so desire. This PR adds a `orig_change_pos` option to bumpfee which the user can use to specify the index of the change output. Fixes #11233 Fixes #20795 ACKs for top commit: ismaelsadeeq: re ACK e8c31f1 pinheadmz: re-ACK e8c31f1 furszy: Code review ACK e8c31f1 Tree-SHA512: 3a230655934af17f7c1a5953fafb5ef0d687c21355cf284d5e98fece411f589cd69ea505f06d6bdcf82836b08d268c366ad2dd30ae3d71541c9cdf94d1f698ee
2 parents 5608e1d + e8c31f1 commit 04afe55

File tree

5 files changed

+113
-9
lines changed

5 files changed

+113
-9
lines changed

src/rpc/client.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,13 @@ static const CRPCConvertParam vRPCConvertParams[] =
259259
{ "bumpfee", 1, "fee_rate"},
260260
{ "bumpfee", 1, "replaceable"},
261261
{ "bumpfee", 1, "outputs"},
262+
{ "bumpfee", 1, "reduce_output"},
262263
{ "psbtbumpfee", 1, "options" },
263264
{ "psbtbumpfee", 1, "conf_target"},
264265
{ "psbtbumpfee", 1, "fee_rate"},
265266
{ "psbtbumpfee", 1, "replaceable"},
266267
{ "psbtbumpfee", 1, "outputs"},
268+
{ "psbtbumpfee", 1, "reduce_output"},
267269
{ "logging", 0, "include" },
268270
{ "logging", 1, "exclude" },
269271
{ "disconnectnode", 1, "nodeid" },

src/wallet/feebumper.cpp

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,14 @@ bool TransactionCanBeBumped(const CWallet& wallet, const uint256& txid)
152152
}
153153

154154
Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCoinControl& coin_control, std::vector<bilingual_str>& errors,
155-
CAmount& old_fee, CAmount& new_fee, CMutableTransaction& mtx, bool require_mine, const std::vector<CTxOut>& outputs)
155+
CAmount& old_fee, CAmount& new_fee, CMutableTransaction& mtx, bool require_mine, const std::vector<CTxOut>& outputs, std::optional<uint32_t> reduce_output)
156156
{
157+
// Cannot both specify new outputs and an output to reduce
158+
if (!outputs.empty() && reduce_output.has_value()) {
159+
errors.push_back(Untranslated("Cannot specify both new outputs to use and an output index to reduce"));
160+
return Result::INVALID_PARAMETER;
161+
}
162+
157163
// We are going to modify coin control later, copy to re-use
158164
CCoinControl new_coin_control(coin_control);
159165

@@ -166,6 +172,12 @@ Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCo
166172
}
167173
const CWalletTx& wtx = it->second;
168174

175+
// Make sure that reduce_output is valid
176+
if (reduce_output.has_value() && reduce_output.value() >= wtx.tx->vout.size()) {
177+
errors.push_back(Untranslated("Change position is out of range"));
178+
return Result::INVALID_PARAMETER;
179+
}
180+
169181
// Retrieve all of the UTXOs and add them to coin control
170182
// While we're here, calculate the input amount
171183
std::map<COutPoint, Coin> coins;
@@ -233,14 +245,15 @@ Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCo
233245
std::vector<CRecipient> recipients;
234246
CAmount new_outputs_value = 0;
235247
const auto& txouts = outputs.empty() ? wtx.tx->vout : outputs;
236-
for (const auto& output : txouts) {
237-
if (!OutputIsChange(wallet, output)) {
238-
CRecipient recipient = {output.scriptPubKey, output.nValue, false};
239-
recipients.push_back(recipient);
240-
} else {
248+
for (size_t i = 0; i < txouts.size(); ++i) {
249+
const CTxOut& output = txouts.at(i);
250+
if (reduce_output.has_value() ? reduce_output.value() == i : OutputIsChange(wallet, output)) {
241251
CTxDestination change_dest;
242252
ExtractDestination(output.scriptPubKey, change_dest);
243253
new_coin_control.destChange = change_dest;
254+
} else {
255+
CRecipient recipient = {output.scriptPubKey, output.nValue, false};
256+
recipients.push_back(recipient);
244257
}
245258
new_outputs_value += output.nValue;
246259
}

src/wallet/feebumper.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ bool TransactionCanBeBumped(const CWallet& wallet, const uint256& txid);
4343
* @param[out] new_fee the fee that the bump transaction pays
4444
* @param[out] mtx The bump transaction itself
4545
* @param[in] require_mine Whether the original transaction must consist of inputs that can be spent by the wallet
46+
* @param[in] outputs Vector of new outputs to replace the bumped transaction's outputs
47+
* @param[in] reduce_output The position of the change output to deduct the fee from in the transaction being bumped
4648
*/
4749
Result CreateRateBumpTransaction(CWallet& wallet,
4850
const uint256& txid,
@@ -52,7 +54,8 @@ Result CreateRateBumpTransaction(CWallet& wallet,
5254
CAmount& new_fee,
5355
CMutableTransaction& mtx,
5456
bool require_mine,
55-
const std::vector<CTxOut>& outputs);
57+
const std::vector<CTxOut>& outputs,
58+
std::optional<uint32_t> reduce_output = std::nullopt);
5659

5760
//! Sign the new transaction,
5861
//! @return false if the tx couldn't be found or if it was

src/wallet/rpc/spend.cpp

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,9 +1015,11 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
10151015
"\"" + FeeModes("\"\n\"") + "\""},
10161016
{"outputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "New outputs (key-value pairs) which will replace\n"
10171017
"the original ones, if provided. Each address can only appear once and there can\n"
1018-
"only be one \"data\" object.\n",
1018+
"only be one \"data\" object.\n"
1019+
"Cannot be provided if 'reduce_output' is specified.",
10191020
OutputsDoc(),
10201021
RPCArgOptions{.skip_type_check = true}},
1022+
{"reduce_output", RPCArg::Type::NUM, RPCArg::DefaultHint{"not set, detect change automatically"}, "The 0-based index of the output from which the additional fees will be deducted. In general, this should be the position of change output. Cannot be provided if 'outputs' is specified."},
10211023
},
10221024
RPCArgOptions{.oneline_description="options"}},
10231025
},
@@ -1056,6 +1058,8 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
10561058
coin_control.m_signal_bip125_rbf = true;
10571059
std::vector<CTxOut> outputs;
10581060

1061+
std::optional<uint32_t> reduce_output;
1062+
10591063
if (!request.params[1].isNull()) {
10601064
UniValue options = request.params[1];
10611065
RPCTypeCheckObj(options,
@@ -1066,6 +1070,7 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
10661070
{"replaceable", UniValueType(UniValue::VBOOL)},
10671071
{"estimate_mode", UniValueType(UniValue::VSTR)},
10681072
{"outputs", UniValueType()}, // will be checked by AddOutputs()
1073+
{"reduce_output", UniValueType(UniValue::VNUM)},
10691074
},
10701075
true, true);
10711076

@@ -1089,6 +1094,10 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
10891094
AddOutputs(tempTx, options["outputs"]);
10901095
outputs = tempTx.vout;
10911096
}
1097+
1098+
if (options.exists("reduce_output")) {
1099+
reduce_output = options["reduce_output"].getInt<uint32_t>();
1100+
}
10921101
}
10931102

10941103
// Make sure the results are valid at least up to the most recent block
@@ -1106,7 +1115,7 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
11061115
CMutableTransaction mtx;
11071116
feebumper::Result res;
11081117
// Targeting feerate bump.
1109-
res = feebumper::CreateRateBumpTransaction(*pwallet, hash, coin_control, errors, old_fee, new_fee, mtx, /*require_mine=*/ !want_psbt, outputs);
1118+
res = feebumper::CreateRateBumpTransaction(*pwallet, hash, coin_control, errors, old_fee, new_fee, mtx, /*require_mine=*/ !want_psbt, outputs, reduce_output);
11101119
if (res != feebumper::Result::OK) {
11111120
switch(res) {
11121121
case feebumper::Result::INVALID_ADDRESS_OR_KEY:

test/functional/wallet_bumpfee.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@
2424
from test_framework.test_framework import BitcoinTestFramework
2525
from test_framework.util import (
2626
assert_equal,
27+
assert_fee_amount,
2728
assert_greater_than,
2829
assert_raises_rpc_error,
2930
get_fee,
31+
find_vout_for_address,
3032
)
3133
from test_framework.wallet import MiniWallet
3234

@@ -109,6 +111,8 @@ def run_test(self):
109111
test_small_output_with_feerate_succeeds(self, rbf_node, dest_address)
110112
test_no_more_inputs_fails(self, rbf_node, dest_address)
111113
self.test_bump_back_to_yourself()
114+
self.test_provided_change_pos(rbf_node)
115+
self.test_single_output()
112116

113117
# Context independent tests
114118
test_feerate_checks_replaced_outputs(self, rbf_node, peer_node)
@@ -174,6 +178,13 @@ def test_invalid_parameters(self, rbf_node, peer_node, dest_address):
174178
assert_raises_rpc_error(-8, "Invalid parameter, duplicate key: data",
175179
rbf_node.bumpfee, rbfid, {"outputs": [{"data": "deadbeef"}, {"data": "deadbeef"}]})
176180

181+
self.log.info("Test reduce_output option")
182+
assert_raises_rpc_error(-1, "JSON integer out of range", rbf_node.bumpfee, rbfid, {"reduce_output": -1})
183+
assert_raises_rpc_error(-8, "Change position is out of range", rbf_node.bumpfee, rbfid, {"reduce_output": 2})
184+
185+
self.log.info("Test outputs and reduce_output cannot both be provided")
186+
assert_raises_rpc_error(-8, "Cannot specify both new outputs to use and an output index to reduce", rbf_node.bumpfee, rbfid, {"reduce_output": 2, "outputs": [{dest_address: 0.1}]})
187+
177188
self.clear_mempool()
178189

179190
def test_bump_back_to_yourself(self):
@@ -225,6 +236,72 @@ def test_bump_back_to_yourself(self):
225236

226237
node.unloadwallet("back_to_yourself")
227238

239+
def test_provided_change_pos(self, rbf_node):
240+
self.log.info("Test the reduce_output option")
241+
242+
change_addr = rbf_node.getnewaddress()
243+
dest_addr = rbf_node.getnewaddress()
244+
assert_equal(rbf_node.getaddressinfo(change_addr)["ischange"], False)
245+
assert_equal(rbf_node.getaddressinfo(dest_addr)["ischange"], False)
246+
247+
send_res = rbf_node.send(outputs=[{dest_addr: 1}], options={"change_address": change_addr})
248+
assert send_res["complete"]
249+
txid = send_res["txid"]
250+
251+
tx = rbf_node.gettransaction(txid=txid, verbose=True)
252+
assert_equal(len(tx["decoded"]["vout"]), 2)
253+
254+
change_pos = find_vout_for_address(rbf_node, txid, change_addr)
255+
change_value = tx["decoded"]["vout"][change_pos]["value"]
256+
257+
bumped = rbf_node.bumpfee(txid, {"reduce_output": change_pos})
258+
new_txid = bumped["txid"]
259+
260+
new_tx = rbf_node.gettransaction(txid=new_txid, verbose=True)
261+
assert_equal(len(new_tx["decoded"]["vout"]), 2)
262+
new_change_pos = find_vout_for_address(rbf_node, new_txid, change_addr)
263+
new_change_value = new_tx["decoded"]["vout"][new_change_pos]["value"]
264+
265+
assert_greater_than(change_value, new_change_value)
266+
267+
268+
def test_single_output(self):
269+
self.log.info("Test that single output txs can be bumped")
270+
node = self.nodes[1]
271+
272+
node.createwallet("single_out_rbf")
273+
wallet = node.get_wallet_rpc("single_out_rbf")
274+
275+
addr = wallet.getnewaddress()
276+
amount = Decimal("0.001")
277+
# Make 2 UTXOs
278+
self.nodes[0].sendtoaddress(addr, amount)
279+
self.nodes[0].sendtoaddress(addr, amount)
280+
self.generate(self.nodes[0], 1)
281+
utxos = wallet.listunspent()
282+
283+
tx = wallet.sendall(recipients=[wallet.getnewaddress()], fee_rate=2, options={"inputs": [utxos[0]]})
284+
285+
# Reduce the only output with a crazy high feerate, should fail as the output would be dust
286+
assert_raises_rpc_error(-4, "The transaction amount is too small to pay the fee", wallet.bumpfee, txid=tx["txid"], options={"fee_rate": 1100, "reduce_output": 0})
287+
288+
# Reduce the only output successfully
289+
bumped = wallet.bumpfee(txid=tx["txid"], options={"fee_rate": 10, "reduce_output": 0})
290+
bumped_tx = wallet.gettransaction(txid=bumped["txid"], verbose=True)
291+
assert_equal(len(bumped_tx["decoded"]["vout"]), 1)
292+
assert_equal(len(bumped_tx["decoded"]["vin"]), 1)
293+
assert_equal(bumped_tx["decoded"]["vout"][0]["value"] + bumped["fee"], amount)
294+
assert_fee_amount(bumped["fee"], bumped_tx["decoded"]["vsize"], Decimal(10) / Decimal(1e8) * 1000)
295+
296+
# Bumping without reducing adds a new input and output
297+
bumped = wallet.bumpfee(txid=bumped["txid"], options={"fee_rate": 20})
298+
bumped_tx = wallet.gettransaction(txid=bumped["txid"], verbose=True)
299+
assert_equal(len(bumped_tx["decoded"]["vout"]), 2)
300+
assert_equal(len(bumped_tx["decoded"]["vin"]), 2)
301+
assert_fee_amount(bumped["fee"], bumped_tx["decoded"]["vsize"], Decimal(20) / Decimal(1e8) * 1000)
302+
303+
wallet.unloadwallet()
304+
228305
def test_simple_bumpfee_succeeds(self, mode, rbf_node, peer_node, dest_address):
229306
self.log.info('Test simple bumpfee: {}'.format(mode))
230307
rbfid = spend_one_input(rbf_node, dest_address)

0 commit comments

Comments
 (0)