wallet: introduce "tx amount exceeds balance when fees are included" error
What changed, and why it matters
This change improves the error message shown when a Bitcoin wallet user tries to send an amount that fits their balance but leaves no room for the network transaction fee. Previously the wallet just said 'Insufficient funds.' Now it explains that the total exceeds the balance once the fee is included. It is a user-experience and diagnostic improvement, not a security fix.
No security action required. Treat as a normal user-experience improvement; review the new tests and message localization during routine code review.
Security signals we found
No memory safety, cryptographic, consensus, or network changes
Only error-message branching in wallet coin-selection failure path
Adds assertions on effective-value availability but no new trust assumptions
Functional tests confirm intended behavior change
Evidence from the diff
The commit adds a specific error path in CreateTransactionInternal after coin selection fails. If the wallet’s available balance covers the recipient outputs but the effective balance (after accounting for fees to spend the inputs) is below the selection target, it returns a new localized error: ‘The total exceeds your balance when the
Changed components
src/wallet/spend.cpptest/functional/wallet_bumpfee.pytest/functional/wallet_fundrawtransaction.pyInspect captured patch +63 / −3
diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp
index 675cf623..f9cc8595 100644
--- a/src/wallet/spend.cpp
+++ b/src/wallet/spend.cpp
@@ -1208,7 +1208,25 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
if (!select_coins_res) {
// 'SelectCoins' either returns a specific error message or, if empty, means a general "Insufficient funds".
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;
TRACEPOINT(coin_selection, selected_coins,
diff --git a/test/functional/wallet_bumpfee.py b/test/functional/wallet_bumpfee.py
index 044ddc96..1a30f0c3 100755
--- a/test/functional/wallet_bumpfee.py
+++ b/test/functional/wallet_bumpfee.py
@@ -801,7 +801,7 @@ def test_no_more_inputs_fails(self, rbf_node, dest_address):
self.generatetoaddress(rbf_node, 1, dest_address)
# spend all funds, no change output
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()
diff --git a/test/functional/wallet_fundrawtransaction.py b/test/functional/wallet_fundrawtransaction.py
index 3577a2f4..de0aafc6 100755
--- a/test/functional/wallet_fundrawtransaction.py
+++ b/test/functional/wallet_fundrawtransaction.py
@@ -155,6 +155,7 @@ class RawTransactionsTest(BitcoinTestFramework):
self.test_input_confs_control()
self.test_duplicate_outputs()
self.test_watchonly_cannot_grind_r()
+ self.test_cannot_cover_fees()
def test_duplicate_outputs(self):
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
# fail with insufficient funds rather than bitcoind asserting.
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):
self.nodes[0].createwallet("minconf")
@@ -1542,5 +1544,45 @@ class RawTransactionsTest(BitcoinTestFramework):
watchonly_funded = watchonly.fundrawtransaction(hexstring=tx, fee_rate=10)
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__':
RawTransactionsTest(__file__).main()
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.