specter-desktop/tests/test_wallet.py
k9ert 15d5c414b5
Fix floating point rounding in wallet balance properties (#2583)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:02:00 +02:00

431 lines
15 KiB
Python

from asyncio.streams import FlowControlMixin
import random
import time
from typing import List
import pytest, logging
from cryptoadvance.specter.util.psbt import SpecterPSBT
from cryptoadvance.specter.commands.psbt_creator import PsbtCreator
from cryptoadvance.specter.wallet.txlist import WalletAwareTxItem
from cryptoadvance.specter.device import Device
from cryptoadvance.specter.specter import Specter
from cryptoadvance.specter.wallet import Wallet
from cryptoadvance.specter.process_controller.bitcoind_controller import (
BitcoindPlainController,
)
from cryptoadvance.specter.specter_error import SpecterError
from fix_devices_and_wallets import create_hot_wallet_device, create_hot_segwit_wallet
logger = logging.getLogger(__name__)
def send_helper(specter_regtest_configured: Specter, wallet: Wallet, device: Device):
"""A small heper method to send some fund with a hot-wallet. Maybe this should be in wallet.py ?
To understand der PSBT-workflow, i like this article:
https://github.com/bitcoin/bitcoin/blob/master/doc/psbt.md
"""
# sending some funds is quite complicated:
request_json = """
{
"recipients" : [
{
"address": "bcrt1q3kfetuxpxvujasww6xas94nawklvpz0e52uw8a",
"amount": 0.2,
"unit": "btc",
"label": "someLabel"
}
],
"rbf_tx_id": "",
"subtract_from": "0",
"fee_rate": "64",
"rbf": true
}
"""
psbt_creator: PsbtCreator = PsbtCreator(
specter_regtest_configured, wallet, "json", request_json=request_json
)
psbt_dict = psbt_creator.create_psbt(wallet)
psbt = psbt_creator.psbt_as_object
b64psbt = str(psbt_dict["base64"])
signed_psbt = specter_regtest_configured.device_manager.get_by_alias(
device.alias
).sign_psbt(b64psbt, wallet, "")
if signed_psbt["complete"]:
raw = wallet.rpc.finalizepsbt(signed_psbt["psbt"])
psbt.update(signed_psbt["psbt"], raw)
specter_regtest_configured.broadcast(raw["hex"])
def test_txlist(
bitcoin_regtest: BitcoindPlainController,
specter_regtest_configured: Specter,
hot_wallet_device_1: Device,
hot_ghost_machine_device,
caplog,
):
"""this is very similiar of what you can find in fix_devices_and_wallets.py but here you get a failure and nor an error
(Always search for failures first before checking errors)
so this should speed up the fixing process although it's a duplication.
"""
caplog.set_level(logging.DEBUG)
logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO)
wallet = create_hot_segwit_wallet(
specter_regtest_configured,
hot_wallet_device_1,
f"a_hotwallet_{random.randint(0, 999999)}",
)
assert len(wallet.txlist()) == 0
for i in range(0, 10):
bitcoin_regtest.testcoin_faucet(wallet.getnewaddress(), amount=1)
# Send some funds somewhere
send_helper(specter_regtest_configured, wallet, hot_wallet_device_1)
wallet.update()
bitcoin_regtest.get_rpc().generatetoaddress(1, wallet.getnewaddress())
for i in range(0, 2):
bitcoin_regtest.testcoin_faucet(
wallet.getnewaddress(),
amount=2.5,
confirm_payment=False,
)
time.sleep(5) # needed for tx to propagate
wallet.update()
wallet.fetch_transactions()
txlist: List(WalletAwareTxItem) = wallet.txlist()
assert txlist[0].__class__ == WalletAwareTxItem
tx: WalletAwareTxItem = txlist[0]
psbt = tx.psbt
print(psbt)
print(
"mine \t category \t flow_amount \t blockh \t time \t conflicts \t #conf \t vsize \t fee"
)
print("-" * 100)
tx: WalletAwareTxItem
for tx in txlist:
print(
f"{tx.ismine} \t {tx.category:<8} \t {tx.flow_amount: {2}.{3}} \t\t {tx.blockheight} \
\t {tx.time} \t {tx.conflicts} \t\t {tx.confirmations} \t {tx.vsize} \t {tx['fee']}"
)
assert tx.ismine
assert tx.category in ["receive", "generate", "send"]
assert tx.flow_amount > 0.9 or tx.flow_amount < 0.2
assert tx.blockheight is None or tx.blockheight > 200
assert tx.time > 1673512597
assert tx.conflicts == None
assert tx.confirmations >= 0
# 12 txs
assert len(wallet.txlist()) == 14
# two of them are unconfirmed
assert len([tx for tx in wallet.txlist() if tx["confirmations"] == 0]) == 2
assert wallet.address_index == 15
assert wallet._addresses.max_used_index(change=False) == 14
# -1 is a magic value which is returned if no change addresses has been used
assert wallet._addresses.max_used_index(change=True) == -1
number_of_additional_txs = 200
for i in range(0, number_of_additional_txs):
bitcoin_regtest.testcoin_faucet(
wallet.getnewaddress(),
amount=2.5,
confirm_payment=False,
)
wallet.fetch_transactions()
assert wallet.address_index == 15 + number_of_additional_txs
assert (
wallet._addresses.max_used_index(change=False)
== 14 + number_of_additional_txs + 1
)
assert wallet._addresses.max_used_index(change=True) == -1
@pytest.mark.slow
def test_createpsbt(
bitcoin_regtest: BitcoindPlainController,
funded_ghost_machine_wallet: Wallet,
funded_taproot_wallet: Wallet,
):
wallet = funded_ghost_machine_wallet
wallet.address_index = 0
assert (
wallet.getnewaddress() == "bcrt1qgzmq6e3tn67kveryf2je6nd3nv4txef4sl8wre"
) # Address #1
assert wallet.amount_total == 20
# Spending more coins than we have
random_address = "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg" # Does not belong to the ghost machine wallet
with pytest.raises(
SpecterError,
match="Wallet ghost_machine does not have sufficient funds to make the transaction.",
):
psbt = wallet.createpsbt(
[random_address],
[21],
True,
0,
1,
)
unspents = wallet.rpc.listunspent()
selected_coin = [{"txid": unspents[0]["txid"], "vout": unspents[0]["vout"]}]
psbt = wallet.createpsbt(
[random_address],
[19],
False, # Important because otherwise there is no change output!
0,
1,
selected_coins=selected_coin, # Selecting only one UTXO since input ordering seems to also be random in Core.
)
psbt_dict = psbt.to_dict()
assert len(psbt_dict["tx"]["vin"]) == 1
assert len(psbt_dict["inputs"]) == 1
# Input fields
assert (
psbt_dict["inputs"][0]["bip32_derivs"][0]["pubkey"]
== "0330955ab511845fb48fc5739da551875ed54fa1f2fdd4cf77f3473ce2cffb4c75"
)
assert psbt_dict["inputs"][0]["bip32_derivs"][0]["path"] == "m/84h/1h/0h/0/1"
assert psbt_dict["inputs"][0]["bip32_derivs"][0]["master_fingerprint"] == "8c24a510"
# Output fields
for output in psbt_dict["outputs"]: # The ordering of the outputs is random
if output["change"] == False:
assert output["address"] == "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg"
else:
assert output["is_mine"] == True
assert (
output["bip32_derivs"][0]["pubkey"]
== "02251fe2ee4bc43729b0903ffadbcf846d9e6acbb3aa593b09d60085645cbe3653"
)
assert output["bip32_derivs"][0]["path"] == "m/84h/1h/0h/1/0"
# Taproot fields (could be moved to a dedicated test of taproot functionalites in the future)
taproot_wallet = funded_taproot_wallet
assert taproot_wallet.is_taproot == True
address = taproot_wallet.getnewaddress()
assert address.startswith("bcrt1p")
assert taproot_wallet.amount_total == 20
# Let's keep the random address so we have a "mixed" set of outputs: segwit and taproot
psbt = taproot_wallet.createpsbt(
[random_address],
[3],
False,
0,
1,
)
psbt_dict = psbt.to_dict()
# Input fields
assert (
psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["path"] == "m/86h/1h/0h/0/1"
)
assert (
psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["master_fingerprint"]
== "8c24a510"
)
assert psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["leaf_hashes"] == []
complete_pubkey = (
"0274fea50d7f2a69489c2d2a146e317e02f47ad032e81b35fe6059e066670a100e"
)
assert (
psbt_dict["inputs"][0]["taproot_bip32_derivs"][0]["pubkey"]
== complete_pubkey[2:]
) # The pubkey is "xonly", for details: https://embit.rocks/#/api/ec/public_key?id=xonly
# Output fields
for output in psbt_dict["outputs"]:
if output["change"] == False:
assert output["address"] == "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg"
else:
assert output["taproot_bip32_derivs"][0]["path"] == "m/86h/1h/0h/1/0"
assert (
output["taproot_bip32_derivs"][0]["pubkey"]
== "85b747f5ffc1a1ff951790771c86b24725e283afb2d7e5b8392858bc04f5d05c"
)
assert (
output["taproot_bip32_derivs"][0]["pubkey"]
== output["taproot_internal_key"]
)
assert output["taproot_bip32_derivs"][0]["leaf_hashes"] == []
@pytest.mark.slow
def test_check_utxo_and_amounts(funded_hot_wallet_1: Wallet):
wallet = funded_hot_wallet_1
# Let's first prepare some locked txids
unspents = wallet.rpc.listunspent() # to be able to ref in stable way
wallet.check_utxo()
# 10 transactions + 2 unconfirmed
assert len(wallet.full_utxo) == 12
# none are locked
assert len([tx for tx in wallet.full_utxo if tx["locked"]]) == 0
# Freeze 2 UTXO
# ["txid:vout", "txid:vout"]
wallet.toggle_freeze_utxo(
[
f"{unspents[0]['txid']}:{unspents[0]['vout']}",
f"{unspents[1]['txid']}:{unspents[1]['vout']}",
]
)
# still 12 utxo with 2 unconfirmed
wallet.check_utxo()
assert len(wallet.full_utxo) == 12
# 2 are locked
assert len([tx for tx in wallet.full_utxo if tx["locked"]]) == 2
# Check total amount
assert wallet.amount_total == 15
# Check confirmed amount
assert wallet.amount_confirmed == 10
# Check unconfirmed amount
assert wallet.amount_unconfirmed == 5
# Check frozen amount
assert wallet.amount_frozen == 2
# Check available amount
assert wallet.amount_available == 13 # total - frozen
# Check immature amount
address = wallet.getnewaddress()
wallet.rpc.generatetoaddress(1, address)
wallet.update_balance()
assert (
round(wallet.amount_immature, 1) == 50
or round(wallet.amount_immature, 1) == 25
or round(wallet.amount_immature, 1) == 12.5
or round(wallet.amount_immature, 2) == 6.25
)
# Check locked unsigned amount (we need to create a PSBT for that)
selected_coins = [
{"txid": u["txid"], "vout": u["vout"]}
for u in [unspents[2], unspents[3], unspents[4]]
]
selected_coins_amount_sum = (
unspents[2]["amount"] + unspents[3]["amount"] + unspents[4]["amount"]
)
assert (
selected_coins_amount_sum == 3
) # Check funded_hot_wallet_1 in fix_devices_and_wallets.py for the amounts give to certain address ranges
random_address = "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg"
psbt = wallet.createpsbt(
[random_address],
[selected_coins_amount_sum],
True,
0,
1,
selected_coins=selected_coins,
)
assert wallet.amount_locked_unsigned == selected_coins_amount_sum
wallet.delete_pending_psbt(psbt.to_dict()["tx"]["txid"])
assert wallet.amount_locked_unsigned == 0
@pytest.mark.slow
def test_multiple_outputs_in_one_tx(
funded_ghost_machine_wallet: Wallet,
funded_hot_wallet_1: Wallet,
hot_wallet_device_1,
):
receiving_wallet = funded_ghost_machine_wallet
spending_wallet = funded_hot_wallet_1
# Ghost machine addresses (high address index)
address_list = [
"bcrt1qfhxsdnrquh63g7u9f25mmucsavsszz6hqym2rs",
"bcrt1qm2hl7qqaz7ap9279vphdrxey97005wx7rm7k7n",
"bcrt1qd5prsvv2kktulyc4a4gj0y6frszmux0wkzafxu",
]
# Create a PSBT with those three addresses, convert it to a transaction and send this transaction
psbt = spending_wallet.createpsbt(address_list, [1, 2, 3], False, 0, 1)
signed_psbt = hot_wallet_device_1.sign_psbt(psbt.to_string(), spending_wallet)
transaction = spending_wallet.rpc.finalizepsbt(signed_psbt["psbt"])
transaction_hex = transaction["hex"]
spending_wallet.rpc.sendrawtransaction(transaction_hex)
# Check the full utxo list of the receiving wallet
receiving_wallet.check_utxo()
full_utxo = receiving_wallet.full_utxo
assert len(full_utxo) == 4 # The wallet already has one UTXO from being funded
assert full_utxo[0]["amount"] == 1
assert full_utxo[1]["amount"] == 2
assert full_utxo[2]["amount"] == 3
assert full_utxo[3]["amount"] == 20 # The funding UTXO
# Confirming doesn't change anything since check_utxo() calls listunspent with 0 blocks as minconf argument
random_address = "bcrt1q7mlxxdna2e2ufzgalgp5zhtnndl7qddlxjy5eg" # Does not belong to the ghost machine wallet
spending_wallet.rpc.generatetoaddress(
1, random_address
) # We don't need the confirmation,
receiving_wallet.check_utxo()
assert len(full_utxo) == 4
assert full_utxo[0]["amount"] == 1
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"])