Files
bal-electrum-plugin/tests/test_no_invalidation_after_time.py
2026-06-20 09:49:39 -04:00

224 lines
8.3 KiB
Python

"""
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. ✓