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

544 lines
18 KiB
Python

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