Fix floating point rounding in wallet balance properties (#2583)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
k9ert 2026-04-02 14:02:00 +02:00 committed by GitHub
parent 8ebf175b4b
commit 15d5c414b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 67 additions and 3 deletions

View file

@ -1473,7 +1473,7 @@ class Wallet(AbstractWallet):
for utxo in self.locked_utxo:
if utxo["txid"] in frozen_txid:
amount += utxo["amount"]
return amount
return round(amount, 8)
@property
def amount_locked_unsigned(self):
@ -1481,7 +1481,7 @@ class Wallet(AbstractWallet):
amount = 0
for psbt in self.pending_psbts.values():
amount += sum([inp.float_amount for inp in psbt.inputs])
return amount
return round(amount, 8)
@property
def amount_immature(self):
@ -1496,7 +1496,9 @@ class Wallet(AbstractWallet):
@property
def amount_available(self):
"""All outputs minus UTXO locked in unsigned transactions and frozen outputs"""
return self.amount_total - self.amount_locked_unsigned - self.amount_frozen
return round(
self.amount_total - self.amount_locked_unsigned - self.amount_frozen, 8
)
@property
def fullbalance(self):

View file

@ -367,3 +367,65 @@ def test_multiple_outputs_in_one_tx(
assert full_utxo[1]["amount"] == 2
assert full_utxo[2]["amount"] == 3
assert full_utxo[3]["amount"] == 20
@pytest.mark.slow
def test_amount_available_floating_point_precision(
bitcoin_regtest, unfunded_hot_wallet_1: Wallet
):
"""Regression: balance properties must round to satoshi precision.
Without rounding, IEEE 754 float subtraction can produce values like
0.09055627999... instead of 0.09055628, causing false "insufficient
funds" errors when sending exactly the available balance.
"""
wallet = unfunded_hot_wallet_1
amounts = [0.09055628, 0.09317884, 0.14306287]
# Sanity: these amounts trigger IEEE 754 drift when subtracted
assert sum(amounts) - amounts[1] - amounts[2] != amounts[0]
for amt in amounts:
bitcoin_regtest.testcoin_faucet(wallet.getnewaddress(), amount=amt)
wallet.update()
assert wallet.amount_total == round(sum(amounts), 8)
# Freeze 2 UTXOs — their amounts will be subtracted from available
unspents = wallet.rpc.listunspent()
assert len(unspents) == 3
utxos_to_freeze = [
u for u in unspents if u["amount"] in (amounts[1], amounts[2])
]
wallet.toggle_freeze_utxo(
[f"{u['txid']}:{u['vout']}" for u in utxos_to_freeze]
)
wallet.check_utxo()
assert wallet.amount_frozen == round(amounts[1] + amounts[2], 8)
assert wallet.amount_available == amounts[0]
# Create a PSBT locking 2 UTXOs instead of freezing
wallet.toggle_freeze_utxo(
[f"{u['txid']}:{u['vout']}" for u in utxos_to_freeze]
)
wallet.check_utxo()
assert wallet.amount_frozen == 0.0
selected_coins = [
{"txid": u["txid"], "vout": u["vout"]} for u in utxos_to_freeze
]
random_address = "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg"
psbt = wallet.createpsbt(
[random_address],
[round(amounts[1] + amounts[2], 8)],
True,
0,
1,
selected_coins=selected_coins,
)
assert wallet.amount_locked_unsigned == round(amounts[1] + amounts[2], 8)
assert wallet.amount_available == amounts[0]
wallet.delete_pending_psbt(psbt.to_dict()["tx"]["txid"])