""" Regression tests for the high-level inheritance rules described in the project documentation (``docs/inheritance-options.md``). These tests verify the decision flow for changing a will: * an unsigned old will is simply replaced, never invalidated on-chain; * adding / changing / removing an heir triggers anticipation (1 day earlier); * adding or changing a will-executor rebuilds the tx while keeping the same locktime; * postponing an already signed will requires on-chain invalidation; * a Check-Alive threshold in the past triggers invalidation; * relative locktimes are compared against the will creation time, not against the current wall-clock time. The tests use the same mock fixtures as the rest of the suite: genuine regtest addresses from the giovanna7 wallet, synthetic UTXOs and a patched ``PartialTransaction.txid`` so the builder can run without a live Electrum. """ import copy import datetime as real_dt import itertools import os import sys import time import pytest from unittest.mock import MagicMock, patch from datetime import datetime from electrum import constants sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) @pytest.fixture(autouse=True) def _regtest_network(): previous = constants.net constants.BitcoinRegtest.set_as_network() try: yield finally: previous.set_as_network() import bal_fixtures as fx # noqa: E402 from bal.core.heirs import Heirs # noqa: E402 from bal.core.will import ( # noqa: E402 Will, WillItem, WillPostponedException, HeirNotFoundException, NotCompleteWillException, ) from bal.core.willexecutors import Willexecutors # noqa: E402 from bal.core.util import Util # noqa: E402 from bal.core.plugin_base import BalTimestamp # noqa: E402 from electrum.transaction import PartialTransaction # noqa: E402 DAY = 86400 _TXID_COUNTER = itertools.count(1) # ------------------------------------------------------------------ # # Helpers # ------------------------------------------------------------------ # def _willexecutors(specs): out = {} for url, address, base_fee in specs: out[url] = { "url": url, "address": address, "base_fee": base_fee, "selected": True, "status": 200, } return out def _make_heirs(entries): h = Heirs.__new__(Heirs) dict.__init__(h) h.db = MagicMock() h.wallet = MagicMock() h.update(copy.deepcopy(entries)) return h def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100, no_willexecutor=False): wallet = fx.fake_wallet() utxos = fx.make_utxos(4, 5_000_000) plugin = fx.fake_bal_plugin(willexecutors, no_willexecutor=no_willexecutor) if from_locktime is None: from_locktime = int(time.time()) counter = itertools.count(1) def fake_txid(self): if not hasattr(self, "_test_txid"): self._test_txid = f"{next(counter):064x}" return self._test_txid with patch.object(Willexecutors, "get_willexecutors", return_value=willexecutors), \ patch.object(PartialTransaction, "txid", fake_txid): txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos, from_locktime) return txs or {} def _tx_for_we(txs, url): for tx in txs.values(): we = getattr(tx, "willexecutor", None) if we and we.get("url") == url: return tx return None def _backup_tx(txs): for tx in txs.values(): if not getattr(tx, "willexecutor", None): return tx return None def _item_from_tx(tx, *, txid=None, complete=False, pushed=False, time=None, we=None, heirs=None): if txid is None: txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}" if heirs is None: heirs = copy.deepcopy(getattr(tx, "heirs", {})) if we is None and hasattr(tx, "willexecutor"): we = copy.deepcopy(tx.willexecutor) d = { "tx": tx, "heirs": heirs, "willexecutor": we, "status": "", "description": getattr(tx, "description", ""), "time": time, "change": "", "baltx_fees": getattr(tx, "tx_fees", 100), } item = WillItem(d, _id=txid) item.set_status("VALID", True) if complete: item.set_status("COMPLETE", True) if pushed: item.set_status("PUSHED", True) return item def _build_item(heirs, url, address, base_fee, *, from_locktime, time_stamp, complete=False, pushed=False): wes = _willexecutors([(url, address, base_fee)]) txs = _build(heirs, wes, from_locktime=from_locktime) tx = _tx_for_we(txs, url) assert tx is not None, "no will-executor transaction built" txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}" return _item_from_tx(tx, txid=txid, complete=complete, pushed=pushed, time=time_stamp), txid, tx # ------------------------------------------------------------------ # # 1. Unsigned old will is replaced, not invalidated # ------------------------------------------------------------------ # def test_unsigned_will_postpone_triggers_rebuild_not_invalidation(): """Postponing a will that was never signed/sent does NOT raise WillPostponedException: it is treated as a plain rebuild.""" now = int(time.time()) ha = fx.heir_addresses(3) we_addr = fx.willexecutor_addresses(1)[0] wes = _willexecutors([("https://we.example", we_addr, 100000)]) heirs_initial = { "alice": [ha[0], "40%", "300d"], "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_initial = _make_heirs(heirs_initial) txs = _build(h_initial, wes, from_locktime=now) tx = _tx_for_we(txs, "https://we.example") txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}" item = _item_from_tx(tx, txid=txid, complete=False, pushed=False, time=now) heirs_postponed = _make_heirs({ "alice": [ha[0], "40%", "1y"], "bob": [ha[1], "30%", "1y"], "carol": [ha[2], "30%", "1y"], }) check_date = BalTimestamp("30d").to_timestamp() with pytest.raises(NotCompleteWillException) as exc_info: Will.check_willexecutors_and_heirs( {txid: item}, heirs_postponed, wes, False, check_date, 100 ) assert not isinstance(exc_info.value, WillPostponedException) def test_unsigned_will_change_is_anticipated(): """A change to an unsigned will is anticipated (replaced), not invalidated.""" now = int(time.time()) lt = int((now + 365 * DAY) / DAY) * DAY ha = fx.heir_addresses(4) we_addr = ha[3] old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) old_item, _, old_tx = _build_item( old_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now - 10, complete=False, pushed=False, ) new_heirs = _make_heirs({"alice": [ha[1], "100%", str(lt)]}) new_item, _, _ = _build_item( new_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now, complete=False, pushed=False, ) assert new_item.set_anticipate(old_item) assert new_item.get_status("ANTICIPATED") assert int(new_item.tx.locktime) == int( Util.anticipate_locktime(old_tx.locktime, days=1) ) # ------------------------------------------------------------------ # # 2. Adding / changing / removing an heir anticipates the locktime # ------------------------------------------------------------------ # def test_add_heir_anticipates_locktime(): now = int(time.time()) lt = int((now + 365 * DAY) / DAY) * DAY ha = fx.heir_addresses(4) we_addr = ha[3] old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) old_item, _, old_tx = _build_item( old_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now - 10, complete=True, pushed=True, ) new_heirs = _make_heirs({ "alice": [ha[0], "60%", str(lt)], "bob": [ha[1], "40%", str(lt)], }) new_item, _, _ = _build_item( new_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now, complete=False, pushed=False, ) assert new_item.set_anticipate(old_item) assert new_item.get_status("ANTICIPATED") assert int(new_item.tx.locktime) == int( Util.anticipate_locktime(old_tx.locktime, days=1) ) def test_change_heir_amount_anticipates_locktime(): now = int(time.time()) lt = int((now + 365 * DAY) / DAY) * DAY ha = fx.heir_addresses(4) we_addr = ha[3] old_heirs = _make_heirs({ "alice": [ha[0], "60%", str(lt)], "bob": [ha[1], "40%", str(lt)], }) old_item, _, old_tx = _build_item( old_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now - 10, complete=True, pushed=True, ) new_heirs = _make_heirs({ "alice": [ha[0], "80%", str(lt)], "bob": [ha[1], "20%", str(lt)], }) new_item, _, _ = _build_item( new_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now, complete=False, pushed=False, ) assert new_item.set_anticipate(old_item) assert new_item.get_status("ANTICIPATED") assert int(new_item.tx.locktime) == int( Util.anticipate_locktime(old_tx.locktime, days=1) ) def test_remove_heir_anticipates_locktime(): now = int(time.time()) lt = int((now + 365 * DAY) / DAY) * DAY ha = fx.heir_addresses(4) we_addr = ha[3] old_heirs = _make_heirs({ "alice": [ha[0], "60%", str(lt)], "bob": [ha[1], "40%", str(lt)], }) old_item, _, old_tx = _build_item( old_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now - 10, complete=True, pushed=True, ) new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) new_item, _, _ = _build_item( new_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now, complete=False, pushed=False, ) assert new_item.set_anticipate(old_item) assert new_item.get_status("ANTICIPATED") assert int(new_item.tx.locktime) == int( Util.anticipate_locktime(old_tx.locktime, days=1) ) # ------------------------------------------------------------------ # # 3. Adding or changing a will-executor keeps the locktime # ------------------------------------------------------------------ # def test_add_willexecutor_keeps_locktime(): now = int(time.time()) lt = int((now + 365 * DAY) / DAY) * DAY ha = fx.heir_addresses(4) we_addr = ha[3] # Old will: local backup only, no will-executor. old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) old_txs = _build(old_heirs, {}, from_locktime=now, no_willexecutor=True) old_tx = _backup_tx(old_txs) assert old_tx is not None, "no backup transaction built" old_item = _item_from_tx( old_tx, complete=True, pushed=True, time=now - 10, we=None ) old_locktime = int(old_tx.locktime) # New will: same heir, same locktime, but now with a will-executor. new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) new_item, _, _ = _build_item( new_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now, complete=False, pushed=False, ) kept = Will.check_anticipate(old_item, new_item) assert int(kept) == old_locktime assert not new_item.set_anticipate(old_item) assert int(new_item.tx.locktime) == old_locktime def test_modify_willexecutor_keeps_locktime(): now = int(time.time()) lt = int((now + 365 * DAY) / DAY) * DAY ha = fx.heir_addresses(4) we_addr = ha[3] old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) old_item, _, old_tx = _build_item( old_heirs, "https://we.example", we_addr, 100000, from_locktime=now, time_stamp=now - 10, complete=True, pushed=True, ) old_locktime = int(old_tx.locktime) # Same URL, higher base_fee -> locktime must stay the same. new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]}) new_item, _, _ = _build_item( new_heirs, "https://we.example", we_addr, 150000, from_locktime=now, time_stamp=now, complete=False, pushed=False, ) kept = Will.check_anticipate(old_item, new_item) assert int(kept) == old_locktime assert not new_item.set_anticipate(old_item) assert int(new_item.tx.locktime) == old_locktime # ------------------------------------------------------------------ # # 4. Postponing a signed will requires invalidation # ------------------------------------------------------------------ # def test_postpone_signed_will_requires_invalidation(): now = int(time.time()) ha = fx.heir_addresses(3) we_addr = fx.willexecutor_addresses(1)[0] wes = _willexecutors([("https://we.example", we_addr, 100000)]) heirs_initial = { "alice": [ha[0], "40%", "300d"], "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_initial = _make_heirs(heirs_initial) txs = _build(h_initial, wes, from_locktime=now) tx = _tx_for_we(txs, "https://we.example") txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}" willitems = _item_from_tx(tx, txid=txid, complete=True, pushed=True, time=now) heirs_postponed = _make_heirs({ "alice": [ha[0], "40%", "1y"], "bob": [ha[1], "30%", "1y"], "carol": [ha[2], "30%", "1y"], }) check_date = BalTimestamp("30d").to_timestamp() with pytest.raises(WillPostponedException): Will.check_willexecutors_and_heirs( {txid: willitems}, heirs_postponed, wes, False, check_date, 100 ) # ------------------------------------------------------------------ # # 5. Check-Alive threshold in the past triggers invalidation # ------------------------------------------------------------------ # def test_threshold_in_past_raises_check_alive_error(): """A Check-Alive threshold that is already in the past makes ``BalWindow.init_class_variables`` raise ``CheckAliveError``.""" from bal.gui.qt.window import BalWindow from bal.gui.qt.common import CheckAliveError import bal.gui.qt.window as window_mod now = int(time.time()) bw = object.__new__(BalWindow) bw.heirs = {"alice": [fx.heir_addresses(1)[0], "100%", "300d"]} bw.will_settings = {"threshold": now - 1000, "locktime": "1y"} bw.bal_plugin = MagicMock() bw.bal_plugin.NO_WILLEXECUTOR.get.return_value = False bw.bal_plugin.ENABLE_MULTIVERSE.get.return_value = False bw.willexecutors = {} class _PatchedDt: @classmethod def now(cls): return real_dt.datetime.fromtimestamp(now) @classmethod def fromtimestamp(cls, ts): return real_dt.datetime.fromtimestamp(ts) @classmethod def today(cls): return real_dt.datetime.fromtimestamp(now).date() _PatchedDt.timedelta = real_dt.timedelta with patch.object(window_mod, "datetime", _PatchedDt), \ patch.object(window_mod, "Willexecutors") as mock_we: mock_we.get_willexecutors.return_value = {} with pytest.raises(CheckAliveError): bw.init_class_variables() # ------------------------------------------------------------------ # # 6. Relative locktime postpone must compare against creation time # ------------------------------------------------------------------ # def test_relative_locktime_postpone_uses_creation_time(): """For relative locktimes the postpone check resolves the requested locktime against the will creation time (``w.time``), not against the current ``datetime.now()``. This also verifies that the comparison is effectively relative: the same relative string stays coherent even when wall-clock time advances, while a longer relative string is detected as a postpone.""" now = int(time.time()) ha = fx.heir_addresses(3) we_addr = fx.willexecutor_addresses(1)[0] wes = _willexecutors([("https://we.example", we_addr, 100000)]) heirs_initial = { "alice": [ha[0], "40%", "300d"], "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_initial = _make_heirs(heirs_initial) txs = _build(h_initial, wes, from_locktime=now) tx = _tx_for_we(txs, "https://we.example") txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}" item = _item_from_tx(tx, txid=txid, complete=True, pushed=True, time=now) # Advance wall-clock by two days. advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY) import bal.core.plugin_base as pb_mod class _PatchedDt: @classmethod def now(cls): return advanced_now_dt @classmethod def fromtimestamp(cls, ts): return real_dt.datetime.fromtimestamp(ts) @classmethod def today(cls): return advanced_now_dt.date() _PatchedDt.timedelta = real_dt.timedelta with patch.object(pb_mod, "datetime", _PatchedDt): check_date = BalTimestamp("30d").to_timestamp() # Same relative locktime after two days must remain coherent (no spurious # postpone detection). Will.check_willexecutors_and_heirs( {txid: item}, h_initial, wes, False, check_date, 100 ) # Moving the relative locktime from "300d" to "1y" is a real postpone and # must be detected using the creation-time base. heirs_postponed = _make_heirs({ "alice": [ha[0], "40%", "1y"], "bob": [ha[1], "30%", "1y"], "carol": [ha[2], "30%", "1y"], }) with pytest.raises(WillPostponedException): Will.check_willexecutors_and_heirs( {txid: item}, heirs_postponed, wes, False, check_date, 100 ) if __name__ == "__main__": import inspect for name, obj in sorted(globals().items()): if name.startswith("test_") and inspect.isfunction(obj): obj() print(f" [OK] {name}") print("[OK] All inheritance-rules tests passed")