core: anchor relative locktime/threshold recipes to the built tx locktime so an unchanged will no longer reads as expired/postponed; stop the daily invalidate prompt (karen7)

This commit is contained in:
2026-08-09 12:08:25 -04:00
parent 95c23a4b21
commit 2221389f44
9 changed files with 755 additions and 28 deletions

View File

@@ -59,12 +59,95 @@ def test_advanced_mode_uses_threshold_absolute():
def test_advanced_mode_parses_relative_threshold():
# "30d" resolves to a future timestamp (midnight-normalised).
# 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
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
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)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
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."""
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
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
from bal.core.plugin_base import BalTimestamp
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))
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()
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 = 1817438400 # frozen tx locktime (2027-08-05), 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.
first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen
)
assert abs(first - (frozen - 30 * 86400)) < 1
later = resolve_date_to_check(
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
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
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))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
# ------------------------------------------------------------------ #
# check_alive_expired
# ------------------------------------------------------------------ #

View File

@@ -85,6 +85,63 @@ def test_int_locktime():
assert Util.int_locktime() == 0
def test_relative_days():
assert Util._relative_days("30d") == 30
assert Util._relative_days("1y") == 365
assert Util._relative_days("2y") == 730
assert Util._relative_days("30D") == 30
assert Util._relative_days(1700000000) is None
assert Util._relative_days("1700000000") is None
assert Util._relative_days("garbage") is None
def test_resolve_locktime_against_tx_absolute():
"""An absolute current date is returned unchanged (compared vs the tx)."""
frozen = 1817438400
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
def test_resolve_locktime_against_tx_unchanged_relative():
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
(coherent), instead of drifting one day per day away from it."""
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
assert resolved == frozen
def test_resolve_locktime_against_tx_lengthened():
"""A lengthened relative recipe resolves later than the frozen tx locktime
(this is what the postpone check uses to trigger invalidation)."""
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
assert resolved == frozen + 365 * 86400
def test_resolve_locktime_against_tx_shortened():
"""A shortened relative recipe resolves earlier than the frozen tx locktime
(this is what the anticipate/rebuild path uses)."""
frozen = 1817438400
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
assert resolved < frozen
def test_resolve_locktime_against_tx_no_relative_anchor():
"""When the built recipe was absolute there is no anchor: falls back to the
legacy forward-from-now resolution (returns a timestamp, no crash)."""
frozen = 1817438400
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
assert isinstance(result, int)
assert result > 1700000000
def test_resolve_locktime_against_tx_zero_tx_locktime():
"""A tx with no usable locktime falls back to now-based resolution."""
result = Util.resolve_locktime_against_tx("1y", "1y", 0)
assert isinstance(result, int)
assert result > 1700000000
def test_encode_decode_amount():
dp = 8 # typical BTC decimal point
@@ -440,6 +497,13 @@ if __name__ == "__main__":
test_str_to_locktime()
test_parse_locktime_string()
test_int_locktime()
test_relative_days()
test_resolve_locktime_against_tx_absolute()
test_resolve_locktime_against_tx_unchanged_relative()
test_resolve_locktime_against_tx_lengthened()
test_resolve_locktime_against_tx_shortened()
test_resolve_locktime_against_tx_no_relative_anchor()
test_resolve_locktime_against_tx_zero_tx_locktime()
test_encode_decode_amount()
test_is_perc()
test_cmp_array()

View File

@@ -0,0 +1,223 @@
"""
Tests for the relative-recipe anchoring in the will coherence check.
Regression for the reported bug: a wallet built with RELATIVE locktimes
(``"1y"`` on the heirs, relative will_settings) was asked to invalidate the
will EVERY DAY. The relative recipes were re-parsed against *now* on every
check, so they drifted one day per day away from the fixed locktime frozen
inside the signed transaction and the check mistook the (unchanged) will for a
POSTPONE / EXPIRED one.
The two gates that produced the prompt are covered here:
1. ``Will.check_willexecutors_and_heirs`` must treat an UNCHANGED relative
recipe as coherent (resolved against the build moment, not "now"), while
still detecting a genuinely lengthened recipe as a postpone.
2. ``resolve_date_to_check`` (ADVANCED mode) must anchor a relative stored
locktime to the built transactions' frozen delivery date, so the will is
never read as EXPIRED because the check window drifts past the frozen
tx locktime.
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
``{"locktime": "2y", "threshold": "150d"}``.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_heir_relative_anchor.py
"""
import copy
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import constants # noqa: E402 (path insert above)
constants.net = constants.BitcoinRegtest
from bal.core.checkalive import resolve_date_to_check # noqa: E402
from bal.core.will import ( # noqa: E402
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
Will,
WillItem,
WillPostponedException,
)
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
# will built 2026-08-05 with a "1y" recipe.
_FROZEN = 1817438400
def _make_will_item(heirs, tx_locktime, status_complete=False):
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 1,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
return item
def _run_heir_check(will_heirs, current_heirs, tx_locktime, status_complete):
"""Run ``check_willexecutors_and_heirs`` and return the outcome."""
item = _make_will_item(will_heirs, tx_locktime, status_complete)
will = {"willid_1": item}
try:
result = Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 1
)
return f"coherent ({result})"
except WillPostponedException as e:
return f"POSTPONE: {e}"
except HeirNotFoundException as e:
return f"rebuild: {e}"
except NoHeirsException as e:
return f"NoHeirs: {e}"
except NotCompleteWillException as e:
return f"NotComplete: {e}"
def test_unchanged_relative_recipe_signed_is_coherent():
"""The reported bug: an unchanged "1y" recipe on a signed will must NOT be
read as a postpone just because the clock has advanced past build day."""
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
def test_unchanged_relative_recipe_unsigned_is_coherent():
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
)
assert outcome.startswith("coherent"), outcome
def test_relative_recipe_lengthened_on_signed_is_postpone():
"""A genuinely lengthened recipe ("1y" -> "2y") on a signed/sent will is
still detected as a postpone (must invalidate on-chain first)."""
built = {"alice": ["addr_alice", 5000, "1y"]}
now = {"alice": ["addr_alice", 5000, "2y"]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("POSTPONE"), outcome
def test_relative_recipe_shortened_on_signed_is_rebuild():
"""A shortened recipe ("1y" -> "30d") is an ANTICIPATE: plain rebuild, no
on-chain invalidation."""
built = {"alice": ["addr_alice", 5000, "1y"]}
now = {"alice": ["addr_alice", 5000, "30d"]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("rebuild"), outcome
def test_unchanged_absolute_recipe_is_coherent():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
outcome = _run_heir_check(
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
def test_absolute_postpone_on_signed_still_detected():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
now = {"alice": ["addr_alice", 5000, str(_FROZEN + 86400)]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("POSTPONE"), outcome
# ------------------------------------------------------------------ #
# karen7 wallet regression (real fixture)
# ------------------------------------------------------------------ #
def _load_karen7():
path = os.path.join(os.path.dirname(__file__), "karen7")
with open(path) as f:
return json.load(f)
def test_karen7_frozen_delivery_not_expired():
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
window opens BEFORE the delivery, so the will is never read as expired."""
data = _load_karen7()
will_settings = data["will_settings"]
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime == _FROZEN
date_to_check = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
)
assert int(date_to_check) < _FROZEN
# Re-evaluated 10 days later the window is identical (no daily drift).
later = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
built_locktime=built_locktime,
)
assert date_to_check == later
def test_karen7_unchanged_heirs_are_coherent():
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
signed tx: the plugin must NOT ask to invalidate the will."""
data = _load_karen7()
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
date_to_check = resolve_date_to_check(
False, data["will_settings"],
now=1_800_000_000.0,
built_locktime=int(wi.tx.locktime),
)
outcome = _run_heir_check(
data["will"][valid_wid]["heirs"],
data["heirs"],
int(wi.tx.locktime),
status_complete=True,
)
assert outcome.startswith("coherent"), outcome
assert int(date_to_check) < int(wi.tx.locktime)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All heir-relative-anchor tests passed")

View File

@@ -0,0 +1,191 @@
"""
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
This is the post-build sync that keeps the plugin's stored delivery date
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
asks to invalidate the will EVERY DAY. The relative value is re-parsed
against "now" on every check, so it drifts one day per day away from the
fixed tx locktime and the postpone check always sees a "postpone".
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
Electrum wallet) by calling it as an unbound method.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_sync_locktime_built_txs.py
"""
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
# ------------------------------------------------------------------ #
# Fakes
# ------------------------------------------------------------------ #
def _fake_willitem(tx_locktime):
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime."""
return SimpleNamespace(
tx=SimpleNamespace(locktime=tx_locktime),
get_status=lambda name: True,
)
def _make_dialog(will_settings, tx_locktimes, recorded):
"""Build a fake dialog ``self`` for _sync_locktime_to_built_txs."""
def update_setting_widgets(new_value, field, update_all=False):
will_settings[field] = new_value
recorded.append((field, new_value, update_all))
return SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt) for i, lt in enumerate(tx_locktimes)
},
will_settings=will_settings,
update_setting_widgets=update_setting_widgets,
),
_date_was_anticipated=False,
)
def _call_sync(will_settings, tx_locktimes, recorded):
fake = _make_dialog(will_settings, tx_locktimes, recorded)
BalBuildWillDialog._sync_locktime_to_built_txs(fake)
return fake
# ------------------------------------------------------------------ #
# Tests
# ------------------------------------------------------------------ #
def test_relative_locktime_normalized_to_absolute():
"""The reported bug: a relative stored locktime is frozen to the absolute
value of the built transaction, even when it parses to the same moment."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake.bal_window.will_settings["locktime"] != "90d"
# A pure relative->absolute normalisation is NOT an anticipation: the sign
# prompt must not claim the date was anticipated.
assert fake._date_was_anticipated is False
def test_relative_threshold_frozen_to_absolute():
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
same absolute value the settings widget computes (real_threshold)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
expected = int(
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
)
assert fake.bal_window.will_settings["threshold"] == expected
assert ("threshold", expected, True) in recorded
def test_absolute_locktime_unchanged_on_equal():
"""An absolute stored locktime that already matches the built txs is left
untouched (no spurious rewrite)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": tx_locktime, "threshold": 1_700_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is False
def test_anticipation_sets_flag_and_moves_earlier():
"""A real anticipation (built locktime earlier than the stored absolute
one) still moves the date earlier and flags the sign prompt."""
tx_locktime = 1_700_000_000
recorded = []
fake = _call_sync(
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is True
def test_stored_earlier_than_built_never_moved_later():
"""A stored absolute date that is already EARLIER than the built txs (the
user moved the delivery later) is never pulled back up on rebuild: only
anticipation (built < stored) and relative normalisation move the value."""
stored = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": stored, "threshold": 1_700_000_000},
[1_900_000_000],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == stored
assert fake._date_was_anticipated is False
def test_multiple_txs_uses_minimum_locktime():
"""When several transactions carry different locktimes, the minimum is used
(owner-confirmed behaviour for the delivery date shown in the UI)."""
min_locktime = 1_750_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"},
[min_locktime, min_locktime + 86_400],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == min_locktime
def test_relative_locktime_stops_daily_postpone():
"""End-to-end guard for the reported bug: after the sync, re-parsing the
stored (now absolute) locktime on later days always equals the built
tx locktime, so the postpone check never fires again."""
from datetime import datetime, timedelta
from bal.core.util import Util
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
stored = fake.bal_window.will_settings["locktime"]
for _day in range(0, 7):
# Simulate the check on later days: parse the STORED value (which is
# now the absolute tx locktime) and compare with the fixed tx locktime.
new_locktime = Util.parse_locktime_string(stored)
assert new_locktime == tx_locktime
assert new_locktime <= tx_locktime # no POSTPONE / drift
# Sanity: a RELATIVE value would have drifted past it (the bug).
drifted = int(
(
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
).timestamp()
)
assert drifted > tx_locktime
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All sync_locktime_built_txs tests passed.")