gui: rebuild the will automatically on new transactions (AUTO_REBUILD, default off; anticipate delivery by 1 day, invalidate on-chain only when the anticipated locktime crosses the check-alive threshold)
This commit is contained in:
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
|
||||
feature.
|
||||
|
||||
Covers:
|
||||
|
||||
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
|
||||
to OFF (False), and can be enabled and read back;
|
||||
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
|
||||
for the matching wallet and only when the setting is enabled;
|
||||
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
|
||||
re-entrancy / cooldown guards;
|
||||
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
|
||||
- no-op when the will is still valid;
|
||||
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
|
||||
invalidation, the rebuilt tx is anticipated to mine before the old);
|
||||
- on-chain invalidation when the check-alive threshold is already in the
|
||||
past (CheckAliveError);
|
||||
- on-chain invalidation when the will is already expired;
|
||||
- on-chain invalidation when the anticipated locktime would fall before
|
||||
the check-alive threshold (and no sign/push in that case).
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
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,
|
||||
)
|
||||
from electrum.util import bfh # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bal.gui.qt.window as window_mod # noqa: E402
|
||||
from bal.core.heirs import Heirs # noqa: E402
|
||||
from bal.core.plugin_base import BalConfig, 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.plugin import Plugin # noqa: E402
|
||||
from bal.gui.qt.window import BalWindow # noqa: E402
|
||||
|
||||
CONFIG_KEY = "bal_auto_rebuild"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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(prevout_hex="11" * 32):
|
||||
"""Return a fake wallet UTXO spendable by the will."""
|
||||
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||
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)
|
||||
plugin.get_decimal_point = window.get_decimal_point
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
plugin.AUTO_REBUILD.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.ok = True
|
||||
ctl.update_all = lambda: None
|
||||
ctl._schedule_history_refresh = lambda: None
|
||||
ctl._auto_rebuild_running = False
|
||||
ctl._auto_rebuild_cooldown_until = 0.0
|
||||
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()))
|
||||
|
||||
|
||||
def _item_spending(controller, *prevout_hexes):
|
||||
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||
wanted = sorted(h for h in prevout_hexes)
|
||||
items = [
|
||||
item
|
||||
for item in controller.willitems.values()
|
||||
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||
]
|
||||
assert len(items) == 1, controller.willitems
|
||||
return items[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config key
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_config_defaults_off():
|
||||
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||
assert rebuild.get() is False
|
||||
|
||||
|
||||
def test_auto_rebuild_config_can_be_enabled():
|
||||
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||
rebuild.set(True)
|
||||
assert rebuild.get() is True
|
||||
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Event wiring (Plugin._wallet_activity)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_wallet_activity_schedules_only_matching_wallet():
|
||||
plugin = Plugin.__new__(Plugin)
|
||||
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
|
||||
wallet_a = FakeWallet([make_funding_input()])
|
||||
wallet_b = FakeWallet([make_funding_input()])
|
||||
|
||||
scheduled = []
|
||||
|
||||
class _Win:
|
||||
wallet = wallet_a
|
||||
ok = True
|
||||
disable_plugin = False
|
||||
|
||||
def schedule_auto_rebuild(self):
|
||||
scheduled.append(self)
|
||||
|
||||
win = _Win()
|
||||
plugin.bal_windows = {"a": win}
|
||||
|
||||
plugin._wallet_activity(wallet_b)
|
||||
assert scheduled == [], "a different wallet must not schedule a rebuild"
|
||||
|
||||
plugin._wallet_activity(wallet_a)
|
||||
assert scheduled == [win], "the matching wallet must schedule a rebuild"
|
||||
|
||||
|
||||
def test_wallet_activity_skips_when_disabled():
|
||||
plugin = Plugin.__new__(Plugin)
|
||||
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
|
||||
wallet_obj = FakeWallet([make_funding_input()])
|
||||
|
||||
scheduled = []
|
||||
|
||||
class _Win:
|
||||
wallet = wallet_obj
|
||||
ok = True
|
||||
disable_plugin = False
|
||||
|
||||
def schedule_auto_rebuild(self):
|
||||
scheduled.append(self)
|
||||
|
||||
plugin.bal_windows = {"a": _Win()}
|
||||
|
||||
plugin._wallet_activity(wallet_obj)
|
||||
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scheduling / guards
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_schedule_auto_rebuild_debounces():
|
||||
ctl = make_controller()
|
||||
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
|
||||
ctl.schedule_auto_rebuild()
|
||||
single_shot.assert_called_once_with(
|
||||
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_guards():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
assert ctl._auto_rebuild_allowed() is True
|
||||
# Re-entrancy guard.
|
||||
ctl._auto_rebuild_running = True
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl._auto_rebuild_running = False
|
||||
# Cooldown guard.
|
||||
ctl._auto_rebuild_cooldown_until = time.time() + 100
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl._auto_rebuild_cooldown_until = 0.0
|
||||
assert ctl._auto_rebuild_allowed() is True
|
||||
# Disabled / inactive guards.
|
||||
ctl.disable_plugin = True
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl.disable_plugin = False
|
||||
ctl.ok = False
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
|
||||
|
||||
def test_run_auto_rebuild_spawns_worker_when_allowed():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
|
||||
started = []
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, target, daemon=None):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
started.append(self.target)
|
||||
|
||||
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
|
||||
ctl._run_auto_rebuild()
|
||||
assert len(started) == 1, "the worker thread must be spawned"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# maybe_auto_rebuild behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_noop_when_disabled():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
ctl.bal_plugin.AUTO_REBUILD.set(False)
|
||||
txid_before, _ = _single(ctl)
|
||||
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is False
|
||||
txid_after, _ = _single(ctl)
|
||||
assert txid_after == txid_before, "disabled flow must not touch the will"
|
||||
|
||||
|
||||
def test_auto_rebuild_noop_without_will():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
assert not ctl.willitems
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_auto_rebuild_noop_when_will_valid():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
txid_before, _ = _single(ctl)
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is False
|
||||
txid_after, _ = _single(ctl)
|
||||
assert txid_after == txid_before, "a valid will must not be rebuilt"
|
||||
inv.assert_not_called()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
# A relative delivery recipe keeps the will coherent after the rebuild
|
||||
# anticipates the locktime by one day (an absolute recipe would read the
|
||||
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
|
||||
ctl.will_settings["locktime"] = "1y"
|
||||
ctl.prepare_will()
|
||||
old_txid, old_item = _single(ctl)
|
||||
old_locktime = int(old_item.tx.locktime)
|
||||
|
||||
# An incoming payment adds a second UTXO -> the will no longer covers
|
||||
# the whole wallet (NotCompleteWillException).
|
||||
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "push_transactions_to_willexecutors"
|
||||
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True, "a stale will must be rebuilt"
|
||||
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
|
||||
push.assert_called_once()
|
||||
history.assert_called_once()
|
||||
|
||||
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
|
||||
# single-input transaction alongside it in the will).
|
||||
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
# The new locktime must be at most the old one, so the new tx can be mined
|
||||
# before the previous will.
|
||||
assert int(new_item.tx.locktime) <= old_locktime
|
||||
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||
assert new_item.get_status("VALID")
|
||||
|
||||
# The rebuilt will is still valid now: no further work.
|
||||
assert ctl.check_will() is True
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_threshold_passed():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
# ADVANCED mode with a check-alive threshold already in the past.
|
||||
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||
ctl.will_settings["threshold"] = int(time.time()) - 3600
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
txid, item = _single(ctl)
|
||||
# Move the frozen delivery date into the past: "too late to
|
||||
# anticipate" -> the old will must be invalidated on-chain.
|
||||
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
now = time.time()
|
||||
delivery = int(now + 3 * 86400)
|
||||
ctl.will_settings["locktime"] = delivery
|
||||
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
|
||||
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||
ctl.will_settings["threshold"] = delivery - 12 * 3600
|
||||
|
||||
ctl.prepare_will()
|
||||
old_txid, _ = _single(ctl)
|
||||
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
# The rebuild itself anticipates the delivery date by one day ONLY when
|
||||
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
|
||||
# same coins + same heirs). Real amounts are re-computed against the
|
||||
# wallet balance, so a new UTXO normally changes them and the rebuilt
|
||||
# will keeps the old locktime. Force the anticipating branch here to
|
||||
# exercise the "anticipated locktime crosses the threshold" handling.
|
||||
with mock.patch.object(
|
||||
Will, "check_anticipate", return_value=delivery - 86400
|
||||
):
|
||||
with mock.patch.object(
|
||||
ctl, "_auto_invalidate_will"
|
||||
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once(), (
|
||||
"an anticipated locktime below the threshold must invalidate on-chain"
|
||||
)
|
||||
sign.assert_not_called(), (
|
||||
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||
"(the wizard stops and waits for the invalidation to confirm)"
|
||||
)
|
||||
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||
"the rebuilt locktime must be anticipated by one day"
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -269,7 +269,9 @@ def test_prepare_will_builds_and_persists():
|
||||
|
||||
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 isinstance(txid, str) and len(txid) == 64 and all(
|
||||
c in "0123456789abcdef" for c in txid
|
||||
), "raw tx id expected (64-char hex, not a label/short id)"
|
||||
assert not item.tx.is_complete(), "unsigned will must not be complete"
|
||||
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user