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

233 lines
8.2 KiB
Python

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