diff --git a/bal/core/checkalive.py b/bal/core/checkalive.py index 7081c40..afffb30 100644 --- a/bal/core/checkalive.py +++ b/bal/core/checkalive.py @@ -29,7 +29,10 @@ class CheckAliveError(Exception): def resolve_date_to_check( - is_basic_mode: bool, will_settings: Any, now: float | None = None + is_basic_mode: bool, + will_settings: Any, + now: float | None = None, + built_locktime: float | int | None = None, ) -> float: """Return the reference timestamp for every will-validity check. @@ -41,19 +44,56 @@ def resolve_date_to_check( govern those checks. ``date_to_check`` is set to *now*: every check is evaluated against the current moment (the Check Alive effectively does not exist) while the delivery locktime is still fully enforced. - * ADVANCED mode: the user-controlled stored threshold is used as-is. + * ADVANCED mode: the user-controlled stored threshold is used as-is. An + ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``) + means "N days BEFORE the delivery date" and is resolved against the stored + locktime (matching the date the settings widget displays), so it stays in + lockstep with the built transactions instead of drifting with the clock. + + A RELATIVE stored locktime is resolved against the frozen delivery date of + the built will (``built_locktime``, the locktime inside the signed tx) when + one exists: the will's real delivery date is authoritative, and resolving + the relative locktime from *now* would drift ``date_to_check`` past the + frozen tx locktime so an unchanged will wrongly reads as expired (asking to + invalidate) every day. Without a built will the legacy forward-from-now + resolution is kept. Args: is_basic_mode: ``True`` for the SIMPLE / BASIC user type. - will_settings: the per-wallet settings dict (``"threshold"`` key). + will_settings: the per-wallet settings dict (``"threshold"`` and + ``"locktime"`` keys). now: overridable clock for tests; defaults to ``datetime.now()``. + built_locktime: the absolute locktime frozen inside the built will's + transactions (``None`` when there is no built will yet). Returns: The reference timestamp (float, UNIX seconds). """ if is_basic_mode: return (now if now is not None else datetime.now().timestamp()) - return BalTimestamp(will_settings["threshold"]).to_timestamp() + + threshold = BalTimestamp(will_settings["threshold"]) + # A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery": + # the settings widget resolves it as real_threshold = locktime - N days. + # Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns + # date_to_check into a moving target that disagrees with the fixed + # locktime of the built transactions (and with the date shown in the UI), + # which can wrongly mark the will as expired/postponed. Resolve it + # against the delivery date instead. + if threshold.unit is not None: + locktime_raw = will_settings.get("locktime") + if locktime_raw is None: + # No delivery reference to anchor to: fall back to the legacy + # forward-from-now resolution. + return threshold.to_timestamp() + locktime_dt = BalTimestamp(locktime_raw).to_date(now) + # A RELATIVE stored locktime ("2y") is itself a moving target; when a + # will has already been built, its frozen delivery date (the tx + # locktime) is the authoritative anchor (see docstring). + if BalTimestamp(locktime_raw).unit is not None and built_locktime: + locktime_dt = BalTimestamp(int(built_locktime)).to_date(now) + return threshold.to_date(locktime_dt, reverse=True).timestamp() + return threshold.to_timestamp() def check_alive_expired( diff --git a/bal/core/util.py b/bal/core/util.py index 33824d4..55450bb 100644 --- a/bal/core/util.py +++ b/bal/core/util.py @@ -131,6 +131,72 @@ class Util: + days * 60 * 60 * 24 ) + @staticmethod + def _relative_days(value): + """Duration in days of a relative ``"Nd"``/``"Ny"`` recipe. + + Returns ``None`` when the value is not a relative recipe (an absolute + timestamp, a plain number, or garbage). + """ + s = str(value) + if s and s[-1] in "yYdD": + try: + n = int(s[:-1]) + except ValueError: + return None + return n * 365 if s[-1] in "yY" else n + return None + + @staticmethod + def resolve_locktime_against_tx(current, built, tx_locktime): + """Resolve a locktime recipe against the moment the signed tx was built. + + A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving + target: parsing it against *now* on every check drifts it one day per + day away from the fixed locktime frozen inside the signed Bitcoin + transaction, so an UNCHANGED will is mistaken for a POSTPONE and the + plugin asks to invalidate it every day (reported bug). This resolves + the current recipe against the build moment instead, recovered from the + signed transaction's locktime and the recipe that was actually frozen + at build time (``built``, the value stored in the will item): + + build_moment = tx_locktime - duration(built) + expected = build_moment + duration(current) + + An unchanged recipe therefore resolves to exactly ``tx_locktime`` + (coherent), a lengthened one resolves later (postpone) and a shortened + one earlier (anticipate). + + Args: + current: the current locktime recipe (relative or absolute). + built: the recipe frozen at build time (stored in the will item). + tx_locktime: the absolute locktime frozen inside the signed tx. + + Returns: + int: the resolved absolute locktime (UNIX timestamp). + """ + current_days = Util._relative_days(current) + built_days = Util._relative_days(built) + if current_days is None: + # Absolute current date: compare directly against the frozen tx. + try: + return int(current) + except Exception: + return Util.parse_locktime_string(current) + if built_days is None or not tx_locktime: + # The stored recipe was absolute (a fixed date) or the tx has no + # usable locktime: there is no relative anchor to recover the build + # moment, so fall back to the legacy forward-from-now resolution. + return Util.parse_locktime_string(current) + try: + base = datetime.fromtimestamp(int(tx_locktime)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + build_moment = base - timedelta(days=built_days) + return int((build_moment + timedelta(days=current_days)).timestamp()) + except Exception: + return Util.parse_locktime_string(current) + # ------------------------------------------------------------------ # # Amount helpers # ------------------------------------------------------------------ # diff --git a/bal/core/will.py b/bal/core/will.py index e9322aa..3fb324d 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -1144,8 +1144,6 @@ class Will: if heir := heirs.get(wheir, None): if heir[0] == their[0] and heir[1] == their[1]: - # The requested (possibly new) locktime for this heir. - new_locktime = Util.parse_locktime_string(heir[2]) # IMPORTANT: compare against the locktime that is # actually frozen inside the already-signed Bitcoin # transaction (w.tx.locktime), NOT against their[2]. @@ -1156,6 +1154,16 @@ class Will: # undetected. w.tx.locktime is immutable once signed # and is exactly what the will-executors hold. tx_locktime = int(w.tx.locktime) + # The requested (possibly new) locktime for this heir. + # A RELATIVE recipe ("1y"/"30d") is resolved against + # the moment the signed tx was built, NOT against now: + # re-parsing it from "now" drifts it one day per day + # away from the frozen tx locktime, so an UNCHANGED + # will would be read as a POSTPONE and the plugin + # would ask to invalidate it every day. + new_locktime = Util.resolve_locktime_against_tx( + heir[2], their[2], tx_locktime + ) if new_locktime == tx_locktime: # Unchanged: this heir is still coherent. count = heirs_found.get(wheir, 0) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index eb613cc..735618f 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -1012,6 +1012,14 @@ class BalBuildWillDialog(BalDialog): desired behaviour, and that the date shown in the panel/wizard must reflect this anticipated date (so the calendar .ics also uses it). + RELATIVE dates are additionally normalised here: a relative value + ("30d"/"1y") is re-parsed against "now" on every check, so it drifts + away from the fixed transaction locktime and the postpone check would + wrongly ask to invalidate the will every day. The stored locktime is + therefore frozen to the built transactions' absolute locktime, and a + relative threshold is frozen to its "N days before the delivery" + absolute value. + We route the update through BalWindow.update_setting_widgets, which is the single place that (1) stores the value in WILL_SETTINGS, (2) persists it to Electrum's database and (3) refreshes the date widgets in @@ -1024,31 +1032,73 @@ class BalBuildWillDialog(BalDialog): if min_locktime is None: return min_locktime = int(min_locktime) + stored_locktime = self.bal_window.will_settings["locktime"] + # A relative value ("30d"/"1y") is a MOVING TARGET: it 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 would ALWAYS see a + # postpone -> the plugin asks to invalidate the will every day. It must + # therefore be normalised here to the frozen absolute locktime of the + # built transactions, even when it happens to parse to the same moment + # today. (Only an absolute stored value is comparable, see below.) + is_relative_locktime = ( + isinstance(stored_locktime, str) + and stored_locktime[-1:].lower() in ("d", "y") + ) # Current stored delivery date, as a comparable UNIX timestamp. try: - current = int( - Util.parse_locktime_string( - self.bal_window.will_settings["locktime"] - ) - ) + current = int(Util.parse_locktime_string(stored_locktime)) except Exception: # If the stored value cannot be parsed, fall back to syncing. current = None - # Only anticipate (move the date EARLIER); never overwrite a postpone. - if current is not None and min_locktime >= current: - return - _logger.debug( - f"sync delivery date to anticipated tx locktime: " - f"{current} -> {min_locktime}" - ) - # Remember that we anticipated the date, so the later sign prompt can - # explain WHY signing is needed (see on_success_phase1). - self._date_was_anticipated = True - # update_setting_widgets stores the value, persists it and refreshes the - # date widgets in all panels/wizard (so the .ics calendar uses it too). - self.bal_window.update_setting_widgets( - min_locktime, "locktime", update_all=True - ) + # A genuine user-chosen POSTPONE (a later absolute date) is never + # overwritten; anything else is synced to the built transactions. + was_anticipation = current is not None and min_locktime < current + if not is_relative_locktime and current is not None and not was_anticipation: + pass + else: + _logger.debug( + f"sync delivery date to built tx locktime: " + f"{current} -> {min_locktime}" + ) + # Remember that we anticipated the date, so the later sign prompt can + # explain WHY signing is needed (see on_success_phase1). A pure + # relative->absolute normalisation is NOT an anticipation. + if was_anticipation: + self._date_was_anticipated = True + # update_setting_widgets stores the value, persists it and refreshes + # the date widgets in all panels/wizard (the .ics calendar too). + self.bal_window.update_setting_widgets( + min_locktime, "locktime", update_all=True + ) + # Same moving-target problem for a relative "Check Alive" threshold: + # it means "N days BEFORE the delivery" (the settings widget resolves it + # as real_threshold = locktime - N days), so it is normalised to that + # absolute date, referenced against the now-absolute stored locktime. + threshold_raw = self.bal_window.will_settings.get("threshold") + if ( + isinstance(threshold_raw, str) + and threshold_raw[-1:].lower() in ("d", "y") + ): + try: + locktime_ts = int( + Util.parse_locktime_string( + self.bal_window.will_settings["locktime"] + ) + ) + real_threshold = int( + BalTimestamp(threshold_raw) + .to_date(locktime_ts, reverse=True) + .timestamp() + ) + except Exception as e: + _logger.error(f"sync threshold to absolute failed: {e}") + else: + _logger.debug( + f"sync threshold {threshold_raw} -> absolute {real_threshold}" + ) + self.bal_window.update_setting_widgets( + real_threshold, "threshold", update_all=True + ) def on_accept(self): try: diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 880d386..fc26b65 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -640,7 +640,9 @@ class BalWindow: # exactly as before. The policy itself lives in # ``bal.core.checkalive.resolve_date_to_check``. self.date_to_check = resolve_date_to_check( - self.bal_plugin.is_basic_mode(), self.will_settings + self.bal_plugin.is_basic_mode(), + self.will_settings, + built_locktime=Will.get_min_locktime(self.willitems), ) # found = False # NOTE: block-height tracking removed (A1) - locktimes are always diff --git a/tests/test_core_checkalive.py b/tests/test_core_checkalive.py index 9f5960a..64b9fbd 100644 --- a/tests/test_core_checkalive.py +++ b/tests/test_core_checkalive.py @@ -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 # ------------------------------------------------------------------ # diff --git a/tests/test_core_util.py b/tests/test_core_util.py index 979a2d6..7e15faa 100644 --- a/tests/test_core_util.py +++ b/tests/test_core_util.py @@ -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() diff --git a/tests/test_heir_relative_anchor.py b/tests/test_heir_relative_anchor.py new file mode 100644 index 0000000..185d1f9 --- /dev/null +++ b/tests/test_heir_relative_anchor.py @@ -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") diff --git a/tests/test_sync_locktime_built_txs.py b/tests/test_sync_locktime_built_txs.py new file mode 100644 index 0000000..f969f91 --- /dev/null +++ b/tests/test_sync_locktime_built_txs.py @@ -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.")