wallet: introduce "tx amount exceeds balance when fees are included" error

This was previously implemented at the GUI level but we never hit that
code path.

Co-authored-by: furszy <matiasfurszyfer@protonmail.com>
This commit is contained in:
stratospher
2026-01-14 14:17:54 +05:30
parent b7fa609ed1
commit 48161f6a05
3 changed files with 63 additions and 3 deletions

View File

@@ -1208,7 +1208,25 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
if (!select_coins_res) { if (!select_coins_res) {
// 'SelectCoins' either returns a specific error message or, if empty, means a general "Insufficient funds". // 'SelectCoins' either returns a specific error message or, if empty, means a general "Insufficient funds".
const bilingual_str& err = util::ErrorString(select_coins_res); const bilingual_str& err = util::ErrorString(select_coins_res);
return util::Error{err.empty() ?_("Insufficient funds") : err}; if (!err.empty()) return util::Error{err};
// Check if we have enough balance but cannot cover the fees
CAmount available_balance = preset_inputs.GetTotalAmount() + available_coins.GetTotalAmount();
// Note: if SelectCoins() fails when SFFO is enabled (recipients_sum = selection_target with SFFO),
// then recipients_sum > available_balance and we wouldn't enter into the if condition below.
if (available_balance >= recipients_sum) {
// If we have coins with balance, they should have effective values since we constructed them with valid feerate.
assert(!preset_inputs.Size() || preset_inputs.GetEffectiveTotalAmount().has_value());
assert(!available_coins.Size() || available_coins.GetEffectiveTotalAmount().has_value());
CAmount available_effective_balance = preset_inputs.GetEffectiveTotalAmount().value_or(0) + available_coins.GetEffectiveTotalAmount().value_or(0);
if (available_effective_balance < selection_target) {
Assume(!coin_selection_params.m_subtract_fee_outputs);
return util::Error{strprintf(_("The total exceeds your balance when the %s transaction fee is included."), FormatMoney(selection_target - recipients_sum))};
}
}
// General failure description
return util::Error{_("Insufficient funds")};
} }
const SelectionResult& result = *select_coins_res; const SelectionResult& result = *select_coins_res;
TRACEPOINT(coin_selection, selected_coins, TRACEPOINT(coin_selection, selected_coins,

View File

@@ -801,7 +801,7 @@ def test_no_more_inputs_fails(self, rbf_node, dest_address):
self.generatetoaddress(rbf_node, 1, dest_address) self.generatetoaddress(rbf_node, 1, dest_address)
# spend all funds, no change output # spend all funds, no change output
rbfid = rbf_node.sendall(recipients=[rbf_node.getnewaddress()])['txid'] rbfid = rbf_node.sendall(recipients=[rbf_node.getnewaddress()])['txid']
assert_raises_rpc_error(-4, "Unable to create transaction. Insufficient funds", rbf_node.bumpfee, rbfid) assert_raises_rpc_error(-4, "Unable to create transaction. The total exceeds your balance when the 0.00001051 transaction fee is included.", rbf_node.bumpfee, rbfid)
self.clear_mempool() self.clear_mempool()

View File

@@ -155,6 +155,7 @@ class RawTransactionsTest(BitcoinTestFramework):
self.test_input_confs_control() self.test_input_confs_control()
self.test_duplicate_outputs() self.test_duplicate_outputs()
self.test_watchonly_cannot_grind_r() self.test_watchonly_cannot_grind_r()
self.test_cannot_cover_fees()
def test_duplicate_outputs(self): def test_duplicate_outputs(self):
self.log.info("Test deserializing and funding a transaction with duplicate outputs") self.log.info("Test deserializing and funding a transaction with duplicate outputs")
@@ -1456,7 +1457,8 @@ class RawTransactionsTest(BitcoinTestFramework):
# To test this does not happen, we subtract 202 sats from the input value. If working correctly, this should # To test this does not happen, we subtract 202 sats from the input value. If working correctly, this should
# fail with insufficient funds rather than bitcoind asserting. # fail with insufficient funds rather than bitcoind asserting.
rawtx = w.createrawtransaction(inputs=[], outputs=[{self.nodes[0].getnewaddress(address_type="bech32"): 1 - 0.00000202}]) rawtx = w.createrawtransaction(inputs=[], outputs=[{self.nodes[0].getnewaddress(address_type="bech32"): 1 - 0.00000202}])
assert_raises_rpc_error(-4, "Insufficient funds", w.fundrawtransaction, rawtx, fee_rate=1.85) expected_err_msg = "The total exceeds your balance when the 0.00000078 transaction fee is included."
assert_raises_rpc_error(-4, expected_err_msg, w.fundrawtransaction, rawtx, fee_rate=1.85)
def test_input_confs_control(self): def test_input_confs_control(self):
self.nodes[0].createwallet("minconf") self.nodes[0].createwallet("minconf")
@@ -1542,5 +1544,45 @@ class RawTransactionsTest(BitcoinTestFramework):
watchonly_funded = watchonly.fundrawtransaction(hexstring=tx, fee_rate=10) watchonly_funded = watchonly.fundrawtransaction(hexstring=tx, fee_rate=10)
assert_greater_than(watchonly_funded["fee"], funded["fee"]) assert_greater_than(watchonly_funded["fee"], funded["fee"])
def test_cannot_cover_fees(self):
self.log.info("Test error message when transaction amount exceeds available balance when fees are included")
default_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
self.nodes[1].createwallet("cannot_cover_fees")
wallet = self.nodes[1].get_wallet_rpc("cannot_cover_fees")
# Set up wallet with 2 utxos: 0.3 BTC and 0.15 BTC
default_wallet.sendtoaddress(wallet.getnewaddress(), 0.3)
txid2 = default_wallet.sendtoaddress(wallet.getnewaddress(), 0.15)
self.generate(self.nodes[0], 1)
vout2 = next(utxo["vout"] for utxo in wallet.listunspent() if utxo["txid"] == txid2)
amount_with_fee_err_msg = "The total exceeds your balance when the {} transaction fee is included."
self.log.info("Test without preselected inputs")
self.log.info("Attempt to send 0.45 BTC without SFFO")
rawtx = wallet.createrawtransaction(inputs=[], outputs=[{default_wallet.getnewaddress(): 0.45}])
assert_raises_rpc_error(-4, amount_with_fee_err_msg.format("0.00000042"), wallet.fundrawtransaction, rawtx, options={"fee_rate":1})
self.log.info("Send 0.45 BTC with SFFO")
wallet.fundrawtransaction(rawtx, options={"subtractFeeFromOutputs":[0]})
self.log.info("Attempt to send 0.45 BTC by restricting coin selection with minconf=6")
assert_raises_rpc_error(-4, "Insufficient funds", wallet.fundrawtransaction, rawtx, options={"minconf":6})
self.log.info("Test with preselected inputs")
self.log.info("Attempt to send 0.45 BTC preselecting 0.15 BTC utxo")
rawtx = wallet.createrawtransaction(inputs=[{"txid": txid2, "vout": vout2}], outputs=[{default_wallet.getnewaddress(): 0.45}])
assert_raises_rpc_error(-4, amount_with_fee_err_msg.format("0.00000042"), wallet.fundrawtransaction, rawtx, options={"fee_rate":1})
self.log.info("Send 0.45 BTC preselecting 0.15 BTC utxo with SFFO")
wallet.fundrawtransaction(hexstring=rawtx, options={"subtractFeeFromOutputs":[0]})
self.log.info("Attempt to send 0.15 BTC using only the 0.15 BTC preselected utxo")
rawtx = wallet.createrawtransaction(inputs=[{"txid": txid2, "vout": vout2}], outputs=[{default_wallet.getnewaddress(): 0.15}])
assert_raises_rpc_error(-4, ERR_NOT_ENOUGH_PRESET_INPUTS, wallet.fundrawtransaction, rawtx, options={"fee_rate":1, "add_inputs":False})
self.log.info("Send 0.15 BTC using only the 0.15 BTC preselected utxo with SFFO")
wallet.fundrawtransaction(hexstring=rawtx, options={"subtractFeeFromOutputs":[0], "add_inputs":False})
if __name__ == '__main__': if __name__ == '__main__':
RawTransactionsTest(__file__).main() RawTransactionsTest(__file__).main()