Merge bitcoin/bitcoin#34299: wallet: remove PreSelectedInputs and re-activate "AmountWithFeeExceedsBalance" error

48161f6a05 wallet: introduce "tx amount exceeds balance when fees are included" error (stratospher)
b7fa609ed1 wallet: remove PreSelectedInputs (stratospher)
7819da2c16 walllet: use CoinsResult instead of PreSelectedInputs (stratospher)
e5474079f1 wallet: introduce GetAppropriateTotal() in CoinsResult (stratospher)
d8ea921d01 wallet: correctly reserve in CoinsResult::All() (stratospher)
7072d825e3 wallet: ensure COutput added in set are unique (stratospher)
fefa3be782 wallet: fix, make 'total_effective_amount' optional actually optional (stratospher)

Pull request description:

  picks up https://github.com/bitcoin/bitcoin/pull/25269.

  This PR re-implements the code path so that an error message is thrown when a transaction's total amount (including fees) exceeds the available balance. It also refactors the wallet's coin selection code.

  1. the first 3 commits are unrelated to the code but few small bug fixes which are nice to fix. but also kind of impacts the remaining logic. (could PR separately if reviewers wish)
  1. c467325aaf187d7f056bb1ea1cec6b7c4250af2e: make `total_effective_amount` optional actually optional
  2. 2202ab597596c84fc49f8784e823372b7a9efcbe: ensure `set<shared_ptr<COutput>>` has unique COutput
  3. a5ffbbf122d66fc4ad9b2e7c6d7d1dfa1816388e: Correctly reserve size when flattening `CoinsResult.coins` map to vector

  3. the next 3 commits from 4745d5480ca5c3809edd51140e4d2c0433582844 replace the `PreSelectedInputs` struct with `CoinsResult` and removes `PreSelectedInputs`.

  4. the last commit (e664484a6d34c1795ebb0925ab31faea5d64ab00) deals with the error message - `AmountWithFeeExceedsBalance` error inside `WalletModel::prepareTransaction` is never thrown and remains an unused code path. This is because `createTransaction` does not retrieve the fee when the process fails. The fee return arg is set only at the end of the process, when the transaction is successfully created. Therefore, if the transaction creation fails, the fee is not available inside `WalletModel::prepareTransaction` to trigger the `AmountWithFeeExceedsBalance` error.

  This PR re-implements the feature inside `CreateTransactionInternal` and adds test coverage for it.

  | on master | on PR |
  |-----------|-------|
  | <img src="https://github.com/user-attachments/assets/a903e687-2466-42c7-b898-5dec24bfe515" width="750" alt="Insufficient funds" /> | <img src="https://github.com/user-attachments/assets/74bb3c83-6132-4c09-91f0-0a446618b3c8" width="750" alt="AmountWithFeeExceedsBalance" /> |

  the unreachable code path is removed in https://github.com/bitcoin-core/gui/pull/807 which requires this PR.

ACKs for top commit:
  achow101:
    ACK 48161f6a05
  furszy:
    utACK 48161f6

Tree-SHA512: a963fac8d6714f76571df8cf9aff70601536dc6faa4326fbb5892c3f080dc393f0d7c6e2d21879c7a2c898bf0092adb154376d9b0a8929b31575ce9d1d47dec2
This commit is contained in:
Ava Chow
2026-02-06 14:30:20 -08:00
10 changed files with 119 additions and 59 deletions

View File

@@ -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()

View File

@@ -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()