BalWindow/plugin: history persistence, will import/merge, sig tracking, local-spender fixes

This commit is contained in:
2026-08-01 17:21:36 -04:00
parent 08394f4868
commit 30a5720ceb
18 changed files with 3139 additions and 153 deletions

View File

@@ -0,0 +1,559 @@
"""
Tests for the "Import" (read-only will preview) flow.
Covers:
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
- ``import_will_into_details`` normalization + IMPORTED status.
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
will, never on the live wallet state.
- ``WillWidget`` honouring an explicit ``will`` argument.
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
"""
import sys
import tempfile
from types import MethodType, SimpleNamespace
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QWidget
from bal.core.will import Will, WillItem
from bal.gui.qt import window as window_mod
_app = QApplication.instance() or QApplication(sys.argv)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
def _make_willitem_dict(**overrides):
"""Return a minimal dict that can construct a WillItem."""
d = {
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
}
d.update(overrides)
return d
def _make_willitems(n, prefix="w"):
"""Create ``n`` WillItems with distinct txids/locktimes."""
willitems = {}
for i in range(n):
wid = f"{prefix}{i}"
wi = WillItem(_make_willitem_dict())
wi.tx.locktime = 1000 + i
wi._id = wid
willitems[wid] = wi
return willitems
# ------------------------------------------------------------------ #
# _load_will_file round-trip
# ------------------------------------------------------------------ #
def test_load_will_file_roundtrip():
from bal.gui.qt.common import write_json_file
src = _make_willitems(2)
data = {wid: wi.to_dict() for wid, wi in src.items()}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
path = f.name
write_json_file(path, data)
try:
loaded = window_mod.BalWindow._load_will_file(None, path)
assert set(loaded) == {"w0", "w1"}
for wid, wi in loaded.items():
assert isinstance(wi, WillItem)
assert wi.heirs == {}
assert wi._id == wid
assert wi.tx is not None
finally:
import os
os.unlink(path)
# ------------------------------------------------------------------ #
# import_will_into_details normalization + IMPORTED status
# ------------------------------------------------------------------ #
def test_imported_will_is_normalized_and_marked_imported():
willitems = _make_willitems(1)
Will.normalize_will(willitems, None)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
assert all(wi.get_status("VALID") for wi in willitems.values())
# ------------------------------------------------------------------ #
# sign_transactions operates only on the passed (imported) will
# ------------------------------------------------------------------ #
def test_sign_transactions_external_only():
class FakeWallet:
def sign_transaction(self, tx, password, ignore_warnings=False):
# No-op: never marks the tx complete.
return None
live = _make_willitems(1, prefix="live")
wid_live = next(iter(live))
imported = _make_willitems(2, prefix="imp")
fake = SimpleNamespace(
willitems=live,
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
assert result is not None
assert set(result) == set(imported)
assert wid_live not in result
# The live will must be completely untouched by the external sign run.
assert live[wid_live].get_status("COMPLETE") is False
# ------------------------------------------------------------------ #
# WillWidget honours an explicit ``will`` argument
# ------------------------------------------------------------------ #
def test_will_widget_explicit_will():
from bal.gui.qt.widgets import WillWidget
live = _make_willitems(1, prefix="live")
imported = _make_willitems(2, prefix="imp")
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems=live,
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will=imported)
assert w.will is imported
w2 = WillWidget(parent=fake_parent)
assert w2.will is live
# ------------------------------------------------------------------ #
# WillDetailDialog external-will mode
# ------------------------------------------------------------------ #
def _make_fake_bal_window(window_widget):
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
return SimpleNamespace(
window=window_widget,
bal_plugin=bal_plugin,
wallet=SimpleNamespace(),
show_transaction=lambda *a, **k: None,
willitems=_make_willitems(1),
will_settings={"real_threshold": 9999},
)
def test_will_detail_dialog_external_threshold():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(2)
# locktimes 1000 and 1001 -> threshold must be the max (1001).
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.will is imported
assert dialog.threshold == 1001
dialog2 = WillDetailDialog(bal_window)
assert dialog2._external_will is False
assert dialog2.will is bal_window.willitems
assert dialog2.threshold == 9999
def test_will_detail_dialog_buttons_pass_will():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(1)
dialog = WillDetailDialog(bal_window, will=imported)
calls = []
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
("sign", k.get("will"))
)
bal_window.broadcast_transactions = lambda **k: calls.append(
("broadcast", k.get("will"))
)
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
bal_window.invalidate_will = lambda **k: calls.append(
("invalidate", k.get("will"))
)
dialog.ask_password_and_sign_transactions()
dialog.broadcast_transactions()
dialog.export_will()
dialog.invalidate_will()
assert len(calls) == 4
for action, will in calls:
assert will is imported, f"{action} did not receive the imported will"
# ------------------------------------------------------------------ #
# Merge flow (BalWindow.merge_will)
# ------------------------------------------------------------------ #
def _make_partial_tx(locktime=1000, signed=False):
"""A PartialTransaction derived from _VALID_TX_HEX.
When ``signed`` the input scriptSig of the raw tx is copied over, which
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
"""
from electrum.transaction import PartialTransaction, Transaction
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
ptx.locktime = locktime
if signed:
raw = Transaction(_VALID_TX_HEX)
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
return ptx
def _make_willitem_with_tx(tx, key=None):
wi = WillItem(_make_willitem_dict())
wi.tx = tx
wi._id = key if key is not None else tx.txid()
return wi
def _make_merge_fake(willitems):
"""A BalWindow-like object with a wallet stub sufficient for the local
validity check that ``merge_will`` runs after merging.
"""
class FakeWallet:
def add_input_info(self, txin, **kwargs):
pass
def add_output_info(self, txout, **kwargs):
pass
def get_utxos(self):
return []
def get_tx_info(self, tx):
return SimpleNamespace(
tx_mined_status=SimpleNamespace(height=lambda: 0)
)
@property
def db(self):
return SimpleNamespace(get_transaction=lambda txid: None)
calls = []
fake = SimpleNamespace(
willitems=willitems,
will={},
wallet=FakeWallet(),
bal_window=None,
date_to_check=1700000000,
bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})"
)
),
update_all=lambda: calls.append("update_all"),
)
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
return fake, calls
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
# The realistic "signed on another machine, imported here" case: the live
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
# (complete). The transaction must be substituted and COMPLETE set.
wid = "same_id"
live_tx = _make_partial_tx(locktime=1000, signed=False)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
imported[wid].set_status("CHECKED", True)
fake, calls = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# The live WillItem object is kept (never replaced) and the signed tx
# substituted in.
assert live[wid] is live_item
assert live_item.tx is imported_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
# Operational statuses were carried over.
assert live_item.get_status("PUSHED") is True
assert live_item.get_status("CHECKED") is True
# The will was saved and the GUI refreshed.
assert wid in fake.will
assert fake.will[wid]["COMPLETE"] is True
assert "update_all" in calls
def test_merge_will_same_id_both_signed_combines():
# Live tx is signed but the COMPLETE flag was not set yet; the imported
# signed tx with the same txid must be COMBINED into the live one (the live
# tx object is kept) rather than substituted.
from electrum.transaction import Transaction
wid = "same_id_combine"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
txid = imported_tx.txid()
assert txid is not None
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# Same txid -> combine_with_other_psbt: live tx object is preserved.
assert live_item.tx is live_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
assert live_item.tx.inputs()[0].script_sig == raw_sig
def test_merge_will_same_id_already_complete_never_touches_live_tx():
wid = "already_complete"
live_tx = _make_partial_tx(locktime=1000, signed=False)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("COMPLETE", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# An already-signed live will is left untouched: no combine, no substitute.
assert live_item.tx is live_tx
assert live_item.tx.is_complete() is False
assert live_item.tx.inputs()[0].script_sig is None
assert live_item.get_status("COMPLETE") is True
assert live_item.get_status("PUSHED") is True
def test_merge_will_statuses_are_monotonic():
# Statuses that are True in the live item must never be cleared by a False
# value coming from the imported item.
wid = "monotonic"
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("CONFIRMED", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("MEMPOOL", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("CONFIRMED") is True
assert live_item.get_status("MEMPOOL") is True
assert live_item.get_status("COMPLETE") is True
def test_merge_will_new_ids_added_wholesale():
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
imported_tx = _make_partial_tx(locktime=2000, signed=True)
new_id = imported_tx.txid()
imported = {new_id: _make_willitem_with_tx(imported_tx)}
imported[new_id].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
window_mod.BalWindow.merge_will(fake, imported)
assert len(live) == 2
assert live[new_id] is imported[new_id]
assert live[new_id].get_status("PUSHED") is True
assert new_id in fake.will
def test_merge_will_from_file_invalid_file_raises():
from bal.gui.qt.common import FileImportFailed
def bad_load(path):
raise ValueError("bad file")
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
try:
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
except FileImportFailed as e:
assert "bad file" in str(e)
else:
raise AssertionError("expected FileImportFailed")
def test_merge_will_partial_signatures_update_counts():
# A live unsigned 2-of-3 multisig will merged with an imported copy that
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
# refreshed on the live item (check_signatures runs after the merge).
from binascii import unhexlify
from electrum import crypto
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
TxOutpoint,
)
def make_multisig_tx(nsigs):
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
from electrum.bitcoin import public_key_to_p2wpkh
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
ptx = PartialTransaction()
ptx.add_inputs([txin])
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
ptx.locktime = 1000
return ptx, desc
wid = "multisig_will"
live_tx, _ = make_multisig_tx(0)
imported_tx, _ = make_multisig_tx(1)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("PARTIALLY_SIGNED") is True
assert live_item.sigs_have == 1
assert live_item.sigs_required == 2
def test_will_detail_dialog_merge_switches_to_live():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
live = bal_window.willitems
imported = _make_willitems(2)
merged = []
bal_window.merge_will = lambda will: merged.append(will)
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.merge_button is not None
assert dialog.merge_button.isHidden() is False
dialog.merge_will()
assert merged == [imported]
assert dialog._external_will is False
assert dialog.will is live
assert dialog.threshold == bal_window.will_settings["real_threshold"]
assert dialog.merge_button.isHidden() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All import-will-details tests passed")