""" Test: anticipating a signed will does NOT request an invalidation transaction. Scenario: The user has a signed/pushed will with RELATIVE locktime "300d". After 2 days they change the locktime to "100d" (shorter → anticipate). Anticipating means the new inheritance would unlock EARLIER than the already-signed one. Unlike a postpone, this does NOT need the old pre-signed transaction to be invalidated on-chain first: a will-executor holding the old "300d" tx can only broadcast it LATER than the new "100d" one, so there is no risk of executing the inheritance too early. Therefore ``check_willexecutors_and_heirs`` must NOT raise ``WillPostponedException`` (the only exception that requests an on-chain invalidation transaction). It may request a plain rebuild (``HeirNotFoundException``) but never an invalidation. The threshold ("30d") also stays in the future, so ``CheckAliveError`` / ``WillExpiredException`` must not fire either. """ import copy import os import sys import time import itertools import pytest from unittest.mock import MagicMock, patch from datetime import datetime from electrum import constants 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, WillExpiredException, ) from bal.core.willexecutors import Willexecutors # noqa: E402 from bal.core.plugin_base import BalTimestamp # noqa: E402 from electrum.transaction import PartialTransaction # noqa: E402 DAY = 86400 # ------------------------------------------------------------------ # # 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 _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100): wallet = fx.fake_wallet() utxos = fx.make_utxos(4, 5_000_000) plugin = fx.fake_bal_plugin(willexecutors) 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 # ------------------------------------------------------------------ # # The test # ------------------------------------------------------------------ # def test_anticipate_after_two_days_does_not_request_invalidation(): """Anticipating (300d -> 100d) after 2 days must NOT raise WillPostponedException (no on-chain invalidation transaction).""" now = int(time.time()) ha = fx.heir_addresses(3) we_addr = fx.willexecutor_addresses(1)[0] # ── 1. Create heirs with INITIAL locktime "300d" ───────────────── heirs_initial = { "alice": [ha[0], "40%", "300d"], "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_initial = Heirs.__new__(Heirs) dict.__init__(h_initial) h_initial.db = MagicMock() h_initial.wallet = MagicMock() h_initial.update(copy.deepcopy(heirs_initial)) wes = _willexecutors([("https://we.example", we_addr, 100000)]) # ── 2. Build the will ─────────────────────────────────────────── txs = _build(h_initial, wes, from_locktime=now) tx = _tx_for_we(txs, "https://we.example") assert tx is not None, "no will-executor transaction built" txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid() # ── 3. Wrap in WillItem, mark SIGNED + PUSHED ─────────────────── item = WillItem.__new__(WillItem) item.tx = tx item._id = txid item.heirs = copy.deepcopy(tx.heirs) item.we = copy.deepcopy(tx.willexecutor) item.status = "" item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) item.tx_fees = 100 item.time = now item.father = None item.children = {} item.description = "" item.change = "" item.set_status("VALID", True) item.set_status("COMPLETE", True) # ← signed item.set_status("PUSHED", True) # ← sent to will-executor willitems = {txid: item} old_locktime = int(tx.locktime) # The frozen tx locktime is ~ now + 300d. assert old_locktime > now + 290 * DAY # ── 4. Advance "now" by 2 days ────────────────────────────────── advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY) import bal.core.plugin_base as pb_mod import datetime as real_dt 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_2d = BalTimestamp("30d").to_timestamp() # ── 5. Threshold still in future → no CheckAliveError ─────────── assert check_date_2d > advanced_now_dt.timestamp(), ( "30d threshold is still future after 2 days" ) # ── 6. check_will_expired — old locktime (now+300d) > check_date ─ # The frozen 300d locktime is well beyond the 30d threshold, so the # already-signed will is NOT expired and no anticipation is forced here. all_inputs = Will.get_all_inputs(willitems, only_valid=True) all_inputs_min = Will.get_all_inputs_min_locktime(all_inputs) Will.check_will_expired(all_inputs_min, check_date_2d) # ── 7. check_willexecutors_and_heirs with SHORTER locktime ────── # Change the locktime from "300d" to "100d" (anticipate). heirs_changed = { "alice": [ha[0], "40%", "100d"], "bob": [ha[1], "30%", "100d"], "carol": [ha[2], "30%", "100d"], } h_changed = Heirs.__new__(Heirs) dict.__init__(h_changed) h_changed.db = MagicMock() h_changed.wallet = MagicMock() h_changed.update(copy.deepcopy(heirs_changed)) # The new locktime (now+100d) is EARLIER than the frozen one (now+300d): new_locktime = ( real_dt.datetime.fromtimestamp(now) + real_dt.timedelta(days=100) ).timestamp() assert new_locktime < old_locktime, "100d must anticipate 300d" # The whole point: NO WillPostponedException is raised (anticipation does # not need an on-chain invalidation transaction). try: Will.check_willexecutors_and_heirs( willitems, h_changed, wes, False, # no_willexecutor check_date_2d, 100, # tx_fees (unchanged) ) except WillPostponedException as exc: pytest.fail( f"anticipation must NOT request an invalidation transaction, " f"but WillPostponedException was raised: {exc}" ) except WillExpiredException as exc: pytest.fail( f"anticipation within the threshold must NOT expire the will, " f"but WillExpiredException was raised: {exc}" ) except Exception: # A plain rebuild path (e.g. HeirNotFoundException) is acceptable: # it does NOT request an on-chain invalidation transaction. pass