From 0011d8b40a1661631d556e33a1b5fd68ff1887ba Mon Sep 17 00:00:00 2001 From: svatantrya Date: Fri, 14 Aug 2026 23:41:39 -0400 Subject: [PATCH] test: end-to-end GUI tests for will flows (prepare, sign->COMPLETE, export/merge roundtrip, insufficient funds) --- tests/test_gui_will_flows.py | 402 +++++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 tests/test_gui_will_flows.py diff --git a/tests/test_gui_will_flows.py b/tests/test_gui_will_flows.py new file mode 100644 index 0000000..5f2b356 --- /dev/null +++ b/tests/test_gui_will_flows.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""End-to-end GUI-level tests for the BAL will flows. + +Exercises the real ``BalWindow`` controller (built via ``__new__`` to skip +the heavyweight tab construction) with a fake wallet/window, through the full +will lifecycle: + +* prepare/build + persist (``prepare_will``) +* sign -> COMPLETE (``sign_transactions``) +* export -> merge roundtrip (``export_json_file`` / ``_load_will_file`` / + ``merge_will``) +* insufficient-funds warning path + +Run offline under the runtime venv: + + source /home/steal/devel/bal/electrum/env/bin/activate + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_flows.py +""" + +import json +import os +import sys +import tempfile +import unittest.mock as mock + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from electrum import bitcoin, crypto # noqa: E402 +from electrum.descriptor import parse_descriptor # noqa: E402 +from electrum.transaction import ( # noqa: E402 + PartialTxInput, + PartialTxOutput, + TxOutpoint, + tx_from_any, +) +from electrum.util import bfh # noqa: E402 +from PyQt6.QtWidgets import QApplication # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from bal.core.heirs import Heirs # noqa: E402 +from bal.core.plugin_base import BalPlugin # noqa: E402 +from bal.core.util import Util # noqa: E402 +from bal.core.will import Will # noqa: E402 +from bal.core.willexecutors import Willexecutors # noqa: E402 +from bal.gui.qt.window import BalWindow # noqa: E402 + +# --------------------------------------------------------------------------- # +# Fixtures +# --------------------------------------------------------------------------- # + +PRIVKEY = bytes(range(32)) +PUBKEY = crypto.privkey_to_pubkey(PRIVKEY) +ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY) +SCRIPT = bitcoin.address_to_script(ADDRESS) +FUNDING_SATOSHIS = 500000 + + +def make_funding_input(): + """Return a fake wallet UTXO spent by the will. + + The input has no descriptor attached: the controller only needs + prevout + value + script for coin selection, and the wallet's + ``sign_transaction`` re-attaches the descriptor after the PSBT + roundtrip drops it. + """ + utxo = PartialTxInput(prevout=TxOutpoint(bfh("11" * 32), 0)) + # A real wallet input carries a witness UTXO, which is the ONLY field that + # survives the PSBT roundtrip the controller does (WillItem re-parses the tx + # via get_tx_from_any). Without it the re-parsed tx loses the input value + # and has no txid. + utxo.witness_utxo = PartialTxOutput.from_address_and_value(ADDRESS, FUNDING_SATOSHIS) + utxo._trusted_value_sats = FUNDING_SATOSHIS + utxo._TxInput__scriptpubkey = SCRIPT + utxo._TxInput__address = ADDRESS + return utxo + + +class FakeDB: + def __init__(self): + self._data = {} + + def get(self, key, default=None): + return self._data.get(key, default) + + def put(self, key, value): + self._data[key] = value + + def get_transaction(self, txid): + return None + + def commit(self): + pass + + +class FakeWallet: + def __init__(self, utxos): + self.db = FakeDB() + self.adb = None + self.network = None + self._utxos = list(utxos) + self._dust = 546 + self._change_addresses = [ADDRESS] + self.labels = {} + self.save_db_calls = 0 + + # -- wallet API used by the BAL core ---------------------------------- # + def save_db(self): + self.save_db_calls += 1 + + def dust_threshold(self): + return self._dust + + def has_keystore_encryption(self): + return False + + def set_label(self, txid, label): + self.labels[txid] = label + + def get_all_labels(self): + return dict(self.labels) + + def get_label_for_txid(self, txid): + return self.labels.get(txid, "") + + def get_utxos(self): + return list(self._utxos) + + def get_change_addresses_for_new_transaction(self, *args, **kwargs): + return self._change_addresses + + def add_input_info(self, txin, only_der_suffix=False): + pass + + def add_output_info(self, txout, only_der_suffix=False): + pass + + def get_tx_info(self, tx): + class _TxInfo: + def __init__(self): + class _MinedStatus: + def height(self): + return 0 + + self.tx_mined_status = _MinedStatus() + + return _TxInfo() + + def get_transaction(self, txid): + return None + + # -- signing ----------------------------------------------------------- # + def sign_transaction(self, tx, password=None, ignore_warnings=True): + # The controller passes a re-parsed transaction whose inputs lost the + # descriptor and the trusted value through the PSBT roundtrip. + descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})") + for txin in tx.inputs(): + if txin.script_descriptor is None: + txin.script_descriptor = descriptor + if txin.value_sats() is None: + txin._trusted_value_sats = FUNDING_SATOSHIS + tx.sign({PUBKEY: PRIVKEY}) + + +class FakeConfig: + def __init__(self): + self._data = {} + self._tmpdir = tempfile.mkdtemp(prefix="bal-test-") + + def electrum_path(self): + return self._tmpdir + + def user_dir(self): + return self._tmpdir + + def get(self, key, default=None): + return self._data.get(key, default) + + def set_key(self, key, value, save=True): + self._data[key] = value + + +class FakeWindow: + def __init__(self, wallet): + self.wallet = wallet + self.messages = [] + self.warnings = [] + self.errors = [] + + def get_decimal_point(self): + return 0 + + def show_message(self, text): + self.messages.append(str(text)) + + def show_warning(self, text, parent=None, title=None): + self.warnings.append(str(text)) + + def show_error(self, text): + self.errors.append(str(text)) + + def show_critical(self, text): + self.errors.append(str(text)) + + def update_status(self): + pass + + +def make_controller(utxos=None): + """Build a fully-wired BalWindow without constructing the Qt tabs.""" + utxos = [make_funding_input()] if utxos is None else utxos + config = FakeConfig() + wallet = FakeWallet(utxos) + window = FakeWindow(wallet) + + plugin = BalPlugin(None, config, "bal") + plugin.get_window_title = lambda title: str(title) + # BalWindow.__init__ wires these onto the plugin; replicate since we skip it. + plugin.get_decimal_point = window.get_decimal_point + # Self-will-executor mode; init_class_variables() reads this from config and + # would otherwise overwrite ctl.no_willexecutor with the (False) default. + plugin.NO_WILLEXECUTOR.set(True) + + ctl = BalWindow.__new__(BalWindow) + ctl.bal_plugin = plugin + ctl.window = window + ctl.wallet = wallet + ctl.will = {} + ctl.willitems = {} + ctl.willexecutors = {} + ctl.will_settings = plugin.WILL_SETTINGS.get() + Util.fix_will_settings_tx_fees(ctl.will_settings) + ctl.heirs = Heirs(wallet) + ctl.heirs["alice"] = [ADDRESS, "100000", "1y"] + ctl.heirs["bob"] = [ADDRESS, "100%", "1y"] + ctl.no_willexecutor = True + ctl.disable_plugin = False + ctl.update_all = lambda: None + ctl._schedule_history_refresh = lambda: None + return ctl + + +def _no_willexecutors(): + """Force an empty will-executor list (offline tests).""" + return mock.patch.object( + Willexecutors, + "get_willexecutors", + return_value={}, + ) + + +def _single(controller): + """Return (txid, WillItem) for the controller's single will item.""" + assert len(controller.willitems) == 1, controller.willitems + return next(iter(controller.willitems.items())) + + +# --------------------------------------------------------------------------- # +# Tests +# --------------------------------------------------------------------------- # + +def test_prepare_will_builds_and_persists(): + with _no_willexecutors(): + ctl = make_controller() + will = ctl.prepare_will() + + assert will, "prepare_will must return the built will" + txid, item = _single(ctl) + + assert item.get_status("VALID"), "fresh items default to VALID" + assert txid == item.tx.txid() + assert isinstance(txid, str) and txid.startswith("2"), "raw tx id expected" + assert not item.tx.is_complete(), "unsigned will must not be complete" + assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0 + + # Will item metadata mirrors the funded heir. + heirs = item.heirs + assert len(heirs) == 2 + assert heirs["alice"][0] == ADDRESS and int(heirs["alice"][1]) == 100000 + assert heirs["bob"][0] == ADDRESS + assert int(heirs["bob"][3]) > 0, "percent heir got a real amount" + assert int(heirs["alice"][3]) > 0 + + # The full wallet balance is swept into the will; the only leftover is the + # transaction fee. + total = sum(int(h[3]) for h in heirs.values()) + assert FUNDING_SATOSHIS - total == item.tx.get_fee() + + # Unsigned partial transaction serialises to PSBT base64. + serialized = str(item.tx) + assert not serialized.startswith("02"), "partial must not be raw hex" + + # Nothing was pushed to the live history (no adb). + assert "history" not in ctl.wallet.db._data + assert ctl.wallet.save_db_calls == 0 + + # The rebuilt, ready-to-sign will is labelled for Electrum's History tab and + # the user is guided to the next manual step (sign). + assert ctl.wallet.labels[txid] == "BAL Inheritance transaction" + assert any("sign" in m.lower() for m in ctl.window.messages) + assert not ctl.window.warnings, "a clean build must not warn" + + +def test_check_will_passes_after_build(): + with _no_willexecutors(): + ctl = make_controller() + ctl.prepare_will() + assert ctl.check_will() is True + + +def test_sign_transitions_to_complete(): + with _no_willexecutors(): + ctl = make_controller() + ctl.prepare_will() + txid, item = _single(ctl) + + txs = ctl.sign_transactions(None) + + assert txs and txid in txs + signed = txs[txid] + assert signed.is_complete(), "single-key p2wpkh must be fully signable" + + # Mirror BalWindow.ask_password_and_sign_transactions.on_success: store + # the signed tx back into the will item and re-check signatures. + item.tx = Will.get_tx_from_any(str(signed)) + Will.check_signatures(ctl.willitems, ctl.wallet) + + assert item.get_status("COMPLETE"), "signed item must become COMPLETE" + assert item.sigs_have == 1 and item.sigs_required == 1 + assert item.tx.inputs()[0].is_segwit() + + # A complete tx serialises to raw hex. + raw = str(item.tx) + assert raw.startswith("02") + # The raw serialization must roundtrip back into the same complete tx. + reparsed = tx_from_any(raw) + assert reparsed.is_complete() + assert reparsed.txid() == signed.txid() + + +def test_export_and_merge_roundtrip(): + with _no_willexecutors(): + src = make_controller() + src.prepare_will() + txid, _ = _single(src) + signed = src.sign_transactions(None) + item = src.willitems[txid] + item.tx = Will.get_tx_from_any(str(signed[txid])) + Will.check_signatures(src.willitems, src.wallet) + + assert item.get_status("COMPLETE") + + tmpdir = tempfile.mkdtemp(prefix="bal-export-") + path = os.path.join(tmpdir, "will.json") + src.export_json_file(path, will=src.willitems) + + # The exported file is a JSON dict keyed by txid; the signed tx is + # serialised as raw hex via MyEncoder. + with open(path) as f: + exported = json.load(f) + assert list(exported) == [txid] + assert exported[txid]["tx"] == str(item.tx) + assert exported[txid]["COMPLETE"] is True + assert exported[txid]["VALID"] is True + + # Fresh controller receives the exported will. + dst = make_controller() + imported = dst._load_will_file(path) + dst.merge_will(imported) + + assert len(dst.willitems) == 1 + dst_txid, dst_item = _single(dst) + assert dst_txid == txid, "merge must keep the same txid" + assert dst_item.get_status("COMPLETE"), "signed status must survive merge" + assert str(dst_item.tx) == str(item.tx), "signed raw tx must survive roundtrip" + assert dst_item.tx.is_complete() + + +def test_insufficient_funds_warns(): + with _no_willexecutors(): + ctl = make_controller(utxos=[]) + + will = ctl.prepare_will() + + assert not will, "no wallet funds must not build a will" + assert ctl.window.warnings, "user must be warned about the balance" + assert any("adjustment" in w.lower() for w in ctl.window.warnings) + assert not ctl.willitems + + +def _run_all(): + tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")] + for fn in tests: + print(f"{fn.__name__} ... ", end="", flush=True) + fn() + print("OK") + print(f"\n{len(tests)} tests passed") + + +if __name__ == "__main__": + app = QApplication.instance() or QApplication([]) + _run_all()