Merge wip/checkalive-relative into main

Signed relative Check Alive (negative = before delivery, positive = after
creation). Resolves CHANGELOG.md (new section 61).
This commit is contained in:
2026-10-03 11:37:03 -04:00
19 changed files with 774 additions and 225 deletions

View File

@@ -240,7 +240,7 @@ def test_build_will_reanchors_date_to_check_to_new_locktime():
plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.set(True)
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20})
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
c = _make_controller(plugin)
c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]

View File

@@ -1,8 +1,11 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
(``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` /
``is_check_alive_date_valid`` / ``ensure_check_alive_valid``), including the
sign rule: a NEGATIVE relative Check Alive counts back from the delivery, a
POSITIVE one counts forward from the inheritance creation date.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
@@ -11,24 +14,41 @@ Run:
import sys
import time
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveAfterDeliveryError,
CheckAliveError,
check_alive_expired,
ensure_check_alive_valid,
is_check_alive_date_valid,
resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold,
resolve_relative_check_alive_date,
)
from bal.core.plugin_base import BalTimestamp # noqa: E402
from bal.core.will import Will # noqa: E402
def _midnight(ts):
return (
datetime.fromtimestamp(ts, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
)
# ------------------------------------------------------------------ #
# CheckAliveError
# CheckAliveError / CheckAliveAfterDeliveryError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
assert err.reason is None
def test_check_alive_error_str():
@@ -38,10 +58,107 @@ def test_check_alive_error_str():
assert "1970" in s
def test_check_alive_error_str_with_reason():
err = CheckAliveError(1000000, reason="too early")
assert "too early" in str(err)
def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception)
def test_check_alive_after_delivery_error_is_sibling():
# NOT a subclass of CheckAliveError: the existing ``except CheckAliveError``
# handlers must not invalidate the will for this configuration error.
assert not issubclass(CheckAliveAfterDeliveryError, CheckAliveError)
err = CheckAliveAfterDeliveryError(2000, 1000)
assert err.timestamp_to_check == 2000
assert err.delivery_timestamp == 1000
assert "delivery" in str(err)
# ------------------------------------------------------------------ #
# resolve_delivery_date
# ------------------------------------------------------------------ #
def test_delivery_absolute_locktime():
settings = {"locktime": 1_800_000_000}
assert resolve_delivery_date(settings) == 1_800_000_000
def test_delivery_relative_locktime_resolved_from_now():
fake_now = 1_800_000_000.0
settings = {"locktime": "90d"}
result = resolve_delivery_date(settings, now=fake_now)
expected = _midnight(fake_now) + 90 * 86400
assert abs(result - expected) < 1
def test_delivery_relative_locktime_anchored_to_built_tx():
frozen = 1817424000
settings = {"locktime": "2y"}
result = resolve_delivery_date(
settings, now=1_800_000_000.0, built_locktime=frozen
)
assert result == frozen
def test_delivery_missing_locktime_returns_none():
assert resolve_delivery_date({}) is None
# ------------------------------------------------------------------ #
# resolve_relative_check_alive_date (the single calculation rule)
# ------------------------------------------------------------------ #
def test_relative_negative_anchors_to_delivery():
fake_now = 1_800_000_000.0
delivery = fake_now + 90 * 86400
result = resolve_relative_check_alive_date(
"-30d", delivery_date=delivery, creation_date=fake_now, now=fake_now
)
expected = _midnight(delivery) - 30 * 86400
assert abs(result - expected) < 1
assert result < delivery
def test_relative_positive_anchors_to_creation():
fake_now = 1_800_000_000.0
creation = fake_now + 5 * 86400
delivery = fake_now + 365 * 86400
result = resolve_relative_check_alive_date(
"30d", delivery_date=delivery, creation_date=creation, now=fake_now
)
expected = _midnight(creation) + 30 * 86400
assert abs(result - expected) < 1
# The delivery anchor is irrelevant for a positive value.
assert result < delivery
def test_relative_positive_without_creation_falls_back_to_now():
fake_now = 1_800_000_000.0
result = resolve_relative_check_alive_date(
"30d", delivery_date=fake_now + 365 * 86400, creation_date=None, now=fake_now
)
expected = _midnight(fake_now) + 30 * 86400
assert abs(result - expected) < 1
def test_relative_negative_without_delivery_falls_back_to_now():
fake_now = 1_800_000_000.0
result = resolve_relative_check_alive_date(
"-30d", delivery_date=None, creation_date=fake_now, now=fake_now
)
expected = _midnight(fake_now) - 30 * 86400
assert abs(result - expected) < 1
def test_relative_absolute_value_returned_unchanged():
assert resolve_relative_check_alive_date(1_700_000_000, None, None) == 1_700_000_000
# ------------------------------------------------------------------ #
# resolve_date_to_check
# ------------------------------------------------------------------ #
@@ -59,69 +176,60 @@ def test_advanced_mode_uses_threshold_absolute():
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta, timezone
def test_advanced_mode_negative_relative_anchored_to_delivery():
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
settings = {"threshold": "-30d", "locktime": locktime}
result = resolve_date_to_check(False, settings, now=fake_now)
# date_to_check = (locktime, midnight-normalised) - 30 days.
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
expected = _midnight(locktime) - 30 * 86400
assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime():
"""A relative threshold never drifts with the clock: re-resolving it a day
later, with the same fixed absolute locktime, yields the same date."""
def test_advanced_mode_positive_relative_anchored_to_creation():
fake_now = 1_800_000_000.0
creation = fake_now + 10 * 86400
locktime = fake_now + 365 * 86400
settings = {"threshold": "30d", "locktime": locktime}
result = resolve_date_to_check(
False, settings, now=fake_now, creation_date=creation
)
expected = _midnight(creation) + 30 * 86400
assert abs(result - expected) < 1
def test_advanced_mode_negative_relative_never_drifts():
"""A negative relative threshold anchored to a fixed absolute delivery does
not drift with the clock."""
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
settings = {"threshold": "-30d", "locktime": locktime}
first = resolve_date_to_check(False, settings, now=fake_now)
# Next day: same stored settings (the fixed delivery), a later clock.
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime():
"""A relative locktime is resolved against 'now' first, then the relative
threshold counts N days back from it (matches the settings widget)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
def test_advanced_mode_positive_relative_creation_never_drifts():
"""A positive relative threshold anchored to the frozen creation date does
not drift with the clock."""
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
# Recompute the expected value with the same resolution rules:
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
assert result > fake_now
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
# Without a locktime reference, fall back to the legacy forward resolution.
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
creation = fake_now + 2 * 86400
settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
first = resolve_date_to_check(
False, settings, now=fake_now, creation_date=creation
)
second = resolve_date_to_check(
False, settings, now=fake_now + 5 * 86400, creation_date=creation
)
assert first == second
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"}
# On build day the frozen delivery is authoritative: date_to_check is
# frozen - 30d and NEVER drifts, however much later the clock gets.
frozen = 1817424000 # frozen tx locktime, built 2026-08-05
settings = {"threshold": "-30d", "locktime": "2y"}
first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen
)
@@ -130,22 +238,15 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
)
assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""Without a built will there is no anchor: keeps the legacy now-based
resolution (a moving target, used only before the first build)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
settings = {"threshold": "-30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
locktime_dt = BalTimestamp("90d").to_date(fake_now)
expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
assert abs(result - expected) < 1
@@ -156,14 +257,12 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
def test_guard_threshold_basic_mode_returns_none():
fake_now = 1_800_000_000.0
threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now)
threshold = resolve_guard_threshold(True, {"threshold": "-30d"}, now=fake_now)
assert threshold is None
def _guard_locktime(settings, fake_now):
"""Reproduce the call-site locktime expression of the guard."""
from bal.core.plugin_base import BalTimestamp
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
@@ -175,43 +274,27 @@ def test_guard_threshold_absolute():
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
def test_guard_threshold_relative_fresh_anchor():
"""A relative threshold must be anchored to the FRESH locktime so the
guard and the settings always share one reference frame.
Regression for the false positive where a still-valid built will frozen at
a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently
stored shorter delivery ("1y"): the old guard compared the fresh "1y"
locktime against that anchored threshold and wrongly fired "locktime is
lower than threshold", even though the settings themselves are consistent
(locktime is 30d AFTER the threshold).
"""
def test_guard_threshold_negative_relative_fresh_anchor():
"""A negative relative threshold is anchored to the FRESH locktime so the
guard and the settings always share one reference frame."""
fake_now = 1_800_000_000.0
settings = {"locktime": "1y", "threshold": "30d"}
settings = {"locktime": "1y", "threshold": "-30d"}
locktime = _guard_locktime(settings, fake_now)
threshold = resolve_guard_threshold(False, settings, now=fake_now)
assert threshold is not None
assert locktime > threshold # internally consistent: no fire
assert threshold > fake_now
# The helper takes no built anchor: a frozen "2y" built will must NOT
# contaminate the result, although resolve_date_to_check (the expiry
# reference) legitimately keeps using it.
frozen_two_years = locktime + 365 * 86400
anchored = resolve_date_to_check(
False, settings, now=fake_now, built_locktime=frozen_two_years
)
assert anchored > threshold # built anchor pushes date_to_check forward...
assert locktime < anchored # ...which is exactly what used to fire the bug
def test_guard_threshold_relative_locktime_absolute_threshold():
def test_guard_threshold_positive_relative_anchored_to_creation():
fake_now = 1_800_000_000.0
threshold = fake_now + 200 * 86400
settings = {"locktime": "1y", "threshold": threshold}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
# "1y" from now is later than the stored absolute threshold: allowed.
locktime = _guard_locktime(settings, fake_now)
assert locktime > threshold
creation = fake_now + 4 * 86400
settings = {"locktime": "1y", "threshold": "30d"}
threshold = resolve_guard_threshold(
False, settings, now=fake_now, creation_date=creation
)
expected = _midnight(creation) + 30 * 86400
assert abs(threshold - expected) < 1
def test_guard_threshold_missing_returns_none():
@@ -220,21 +303,99 @@ def test_guard_threshold_missing_returns_none():
# ------------------------------------------------------------------ #
# check_alive_expired
# is_check_alive_date_valid / ensure_check_alive_valid
# ------------------------------------------------------------------ #
def test_is_valid_inside_window():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now + 100, now + 1000, now=now) is True
def test_is_valid_rejects_past():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now - 100, now + 1000, now=now) is False
def test_is_valid_rejects_on_or_after_delivery():
now = 1_800_000_000.0
delivery = now + 1000
assert is_check_alive_date_valid(delivery, delivery, now=now) is False
assert is_check_alive_date_valid(delivery + 1, delivery, now=now) is False
def test_is_valid_without_delivery_skips_upper_bound():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now + 100, None, now=now) is True
def test_is_valid_none_is_false():
assert is_check_alive_date_valid(None, 1000) is False
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_raises_when_past():
try:
ensure_check_alive_valid(False, time.time() - 10_000)
except CheckAliveError:
pass
else:
raise AssertionError("expected CheckAliveError")
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
def test_advanced_raises_when_after_delivery():
now = time.time()
try:
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
except CheckAliveAfterDeliveryError:
pass
else:
raise AssertionError("expected CheckAliveAfterDeliveryError")
def test_advanced_valid_does_not_raise():
now = time.time()
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
# ------------------------------------------------------------------ #
# Will.get_creation_time
# ------------------------------------------------------------------ #
class _FakeWillItem:
def __init__(self, time_value, valid=True):
self.time = time_value
self._valid = valid
def get_status(self, status):
return self._valid if status == "VALID" else False
def test_get_creation_time_min_of_valid_items():
will = {
"a": _FakeWillItem(2000),
"b": _FakeWillItem(1000),
"c": _FakeWillItem(3000),
}
assert Will.get_creation_time(will) == 1000
def test_get_creation_time_ignores_invalid_and_none():
will = {
"a": _FakeWillItem(500, valid=False),
"b": _FakeWillItem(None),
"c": _FakeWillItem(2000),
}
assert Will.get_creation_time(will) == 2000
def test_get_creation_time_empty_returns_none():
assert Will.get_creation_time({}) is None
# ------------------------------------------------------------------ #

View File

@@ -117,6 +117,30 @@ def test_normalize_locktime_raw_single_suffix():
assert s == "130d"
def test_normalize_locktime_raw_negative_disallowed_by_default():
# The delivery field must never accept a sign.
s, isdays, isyears, pos = normalize_locktime_raw_text("-30d", 4)
assert s == "30d"
assert isdays is True
def test_normalize_locktime_raw_negative_allowed_keeps_single_sign():
s, isdays, isyears, pos = normalize_locktime_raw_text(
"-30d", 4, allow_negative=True
)
assert s == "-30d"
assert isdays is True
assert pos >= 1
def test_normalize_locktime_raw_negative_only_leading():
# Extra signs are dropped; a single leading sign survives.
s, _isdays, _isyears, _pos = normalize_locktime_raw_text(
"--1y", 4, allow_negative=True
)
assert s == "-1y"
# ------------------------------------------------------------------ #
# Percentage-or-amount normalisation
# ------------------------------------------------------------------ #

View File

@@ -201,7 +201,7 @@ def test_balconfig_set():
def test_default_will_settings_relative():
rel = BalPlugin.default_will_settings_relative()
assert rel["threshold"] == "30d"
assert rel["threshold"] == "-30d"
assert rel["locktime"] == "1y"

View File

@@ -231,7 +231,7 @@ def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
will = _make_will_with_heirs(will_heirs, lt)
will["willid_1"].set_status("COMPLETE", True)
check_date = resolve_date_to_check(
False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt
False, {"locktime": "2y", "threshold": "-150d"}, built_locktime=lt
)
assert check_date < lt # the anchored window really precedes the delivery
raised = None

View File

@@ -15,7 +15,7 @@ pure policy in ``bal.core.checkalive``:
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
* ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
* ``ensure_check_alive_valid(is_basic_mode, ...)`` - whether the
Check-Alive guard should fire (never in BASIC).
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
@@ -33,7 +33,8 @@ sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
ensure_check_alive_valid,
is_check_alive_date_valid,
resolve_date_to_check,
)
@@ -72,23 +73,29 @@ def test_advanced_mode_parses_relative_threshold():
def test_basic_mode_never_expired():
"""BASIC mode can never be "expired": a passed check-alive date must never
force a postpone/rewrite of the will."""
assert check_alive_expired(True, time.time() - 10_000) is False
assert check_alive_expired(True, 1_000_000_000.0) is False
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
def test_advanced_mode_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
assert is_check_alive_date_valid(time.time() - 10_000, None) is False
try:
ensure_check_alive_valid(False, time.time() - 10_000)
except CheckAliveError:
pass
else:
raise AssertionError("expected CheckAliveError")
def test_advanced_mode_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
assert is_check_alive_date_valid(time.time() + 10_000, None) is True
def test_basic_mode_never_raises_check_alive_error():
"""Regression guard for the reported bug: whatever the delivery date,
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
assert not check_alive_expired(True, date_to_check)
date_to_check = resolve_date_to_check(True, {"threshold": "-30d"})
assert ensure_check_alive_valid(True, date_to_check) is None
# If it did fire, this is the exact exception that would be raised.
assert issubclass(CheckAliveError, Exception)

View File

@@ -412,7 +412,7 @@ def test_guard_not_blocked_by_old_built_will():
# Freeze the built (VALID) will at a delivery one year longer than the
# now-shortened settings: the pre-fix guard would reject the rebuild.
item.tx.locktime = item.tx.locktime + 365 * 86400
ctl.will_settings = {"locktime": "1y", "threshold": "30d"}
ctl.will_settings = {"locktime": "1y", "threshold": "-30d"}
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.init_class_variables()
@@ -446,7 +446,7 @@ def test_anticipated_rebuild_reanchors_date_to_check():
# Per-heir deliveries require multiverse mode (the only way heirs can
# carry a different recipe than the settings locktime).
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20}
ctl.will_settings = {"locktime": "2y", "threshold": "-150d", "baltx_fees": 20}
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.heirs["alice"][2] = "2y"
ctl.heirs["bob"][2] = "2y"