""" Test: no invalidation is requested when time passes with unchanged settings. Scenario: The user creates a will with RELATIVE locktime ("1y") and RELATIVE threshold ("30d"), signs it and pushes it to the will-executor. After 2 days, they run Check again. The system must NOT raise any of: * CheckAliveError (threshold resolved against ``now`` → always future) * WillExpiredException (locktime resolved against *creation time* → stable, does not drift) * WillPostponedException (same locktime → no postpone detected) This verifies that the "cancel/invalidate" prompt never appears spuriously when the user simply lets time pass without changing any setting. """ 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, WillExpiredException, WillPostponedException, ) from bal.core.util import Util # noqa: E402 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 (same patterns as test_inheritance_flow.py) # ------------------------------------------------------------------ # 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, 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 # ------------------------------------------------------------------ # # The actual test # ------------------------------------------------------------------ # def test_no_invalidation_after_two_days_with_relative_settings(): """No invalidation after 2 days when locktime/threshold are RELATIVE strings. This is the critical scenario the user reported: a will created with "1y" locktime and "30d" threshold (both relative), signed and pushed, should NOT trigger any cancellation request when Check is re-run 2 days later with no settings changed. """ now = int(time.time()) ha = fx.heir_addresses(3) we_addr = fx.willexecutor_addresses(1)[0] # ── 1. Create heirs with RELATIVE locktime "1y" ────────────────── heirs_dict = { "alice": [ha[0], "40%", "1y"], "bob": [ha[1], "30%", "1y"], "carol": [ha[2], "30%", "1y"], } h = Heirs.__new__(Heirs) dict.__init__(h) h.db = MagicMock() h.wallet = MagicMock() h.update(copy.deepcopy(heirs_dict)) wes = _willexecutors([("https://we.example", we_addr, 100000)]) # ── 2. Build the will ─────────────────────────────────────────── txs = _build(h, 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 a WillItem, mark as SIGNED (COMPLETE + PUSHED) ── # IMPORTANT: use copy.deepcopy(tx.heirs) — the builder stores the # original percentage strings (not resolved integers), and the # postpone comparison in check_willexecutors_and_heirs expects # those same strings so they can be compared with the current # heirs dict entries. 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 # will creation time — the stable base for "1y" 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) # ── 4. Baseline: check flow at ORIGINAL time (must pass) ──────── check_date = BalTimestamp("30d").to_timestamp() assert check_date > now, "baseline: threshold is in the future" 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) # ── 5. Advance "now" by 2 days ────────────────────────────────── advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY) # BalTimestamp("30d").to_timestamp() calls datetime.now() internally. # Patch only that module's reference so fromtimestamp etc. still work. import bal.core.plugin_base as pb_mod import datetime as real_dt # We need the patched datetime to proxy fromtimestamp/timedelta to the # real ones, otherwise BalTimestamp._safe_fromtimestamp and timedelta # arithmetic break. class _PatchedDt: """Minimal proxy: only .now() returns the advanced time, everything else delegates to the real datetime class.""" @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() # The threshold resolved against the advanced "now" is STILL future. assert check_date_2d > advanced_now_dt.timestamp(), ( "30d threshold is still in the future even after 2 days" ) # ── 6. check_will_expired — no drift ──────────────────────────── # The existing tx locktime was resolved from "1y" against from_locktime # (= the original `now`) during build, so it is immutable. The check # compares it against check_date_2d (which is *future*), so it never # raises WillExpiredException. all_inputs_2d = Will.get_all_inputs(willitems, only_valid=True) all_inputs_min_2d = Will.get_all_inputs_min_locktime(all_inputs_2d) Will.check_will_expired(all_inputs_min_2d, check_date_2d) # ── 7. check_willexecutors_and_heirs — no spurious postpone ───── # The function resolves "1y" against w.time (= the original `now`), # giving the same absolute locktime as tx.locktime → no postpone. Will.check_willexecutors_and_heirs( willitems, h, wes, False, # no_willexecutor check_date_2d, 100, ) # If we reach here, no invalidation was requested. ✓