diff --git a/CHANGELOG.md b/CHANGELOG.md index 1576e7f..181558e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3327,3 +3327,71 @@ densest frames possible. the base36 count fields are 3 chars regardless of how many frames exist. **Outcome:** DONE. + +## Signed relative Check Alive (negative = before delivery, positive = after creation) + +**Date:** 2026-10-01 + +**Goal:** Change the meaning of a RELATIVE Check Alive value so that its SIGN +selects the anchor date: + +- NEGATIVE (`-30d` / `-1y`): counted BACKWARDS from the delivery date + (`delivery + N days`; `-30d` = 30 days BEFORE the delivery). This preserves + the historical meaning of a relative threshold. +- POSITIVE (`30d` / `1y`): counted FORWARD from the inheritance creation date + (`min(WillItem.time)` over the VALID will items, frozen in the wallet DB). +- When the anchor is unknown (no built will yet, or imported items without a + `time`), the reference falls back to *now*. + +No migration: existing stored positive values switch to the new meaning. The +Check Alive must always be strictly AFTER today and strictly BEFORE the +delivery; when it is not, the plugin raises an explicit error (see below). + +**What changed:** + +- `bal/core/checkalive.py` (centralised, GUI-free): + - new `resolve_delivery_date(will_settings, now, built_locktime)` — the single + source of the delivery reference (absolute locktime as-is; relative locktime + resolved forward from *now* unless a built will freezes it). + - new `resolve_relative_check_alive_date(threshold_raw, delivery_date, + creation_date, now)` — the SINGLE calculation rule (sign selects the anchor; + `BalTimestamp.to_date` is used forward on both anchors, no more + `reverse=True`). + - new `is_check_alive_date_valid(date_to_check, delivery_date, now)` and + `ensure_check_alive_valid(is_basic_mode, date_to_check, delivery_date, now)` + — the SINGLE validity gate: `now < date_to_check < delivery`. BASIC mode is + a no-op (Check Alive is inert). + - `resolve_date_to_check` / `resolve_guard_threshold`: gain a `creation_date` + parameter and delegate to the shared rule; `resolve_guard_threshold` still + resolves the delivery from the stored settings alone (no built anchor). + - `check_alive_expired` removed (superseded by `ensure_check_alive_valid`). + - new `CheckAliveAfterDeliveryError` (a SIBLING of `CheckAliveError`, not a + subclass): raised when the Check Alive is on/after the delivery. It must not + reuse the `except CheckAliveError` handlers, which invalidate the will + on-chain; this is a configuration error that only aborts the action. + `CheckAliveError` gained an optional `reason`. +- `bal/core/will.py`: new `Will.get_creation_time(will)` — the oldest + `WillItem.time` among VALID items (`None` when unknown). +- `bal/core/input_rules.py`: `normalize_locktime_raw_text(..., allow_negative)` + keeps a single leading `-` (Check Alive field only). +- `bal/gui/qt/widgets.py`: the Check Alive RAW editor accepts `-` + (`ThresholdTimeWidget.allow_negative_raw`); `on_locktime_change` uses the + shared resolver for `real_threshold`; updated help text. +- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`: pass + `creation_date=Will.get_creation_time(...)`, call + `ensure_check_alive_valid`, and handle `CheckAliveAfterDeliveryError` by + aborting (never invalidating). The wizard .ics provider and the CLI guard use + the shared resolver too. +- `bal/core/plugin_base.py`: the default relative Check Alive is now `"-30d"` + (30 days BEFORE the 1-year delivery), preserving the historical default; the + absolute default threshold follows the same sign rule. + +**Tests:** + +- `tests/test_core_checkalive.py` rewritten (both signs, delivery/creation + resolution, validity gate, new exceptions, `Will.get_creation_time`). +- Updated `tests/test_group_i_basic_checkalive.py`, `tests/test_core_will.py`, + `tests/test_gui_will_flows.py`, `tests/test_cli_controller_offline.py`, + `tests/test_core_plugin_base.py`, `tests/test_core_input_rules.py`. + +**Outcome:** DONE. diff --git a/HANDOFF.md b/HANDOFF.md index 60b0177..5544849 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -68,7 +68,8 @@ bal/ <- the plugin package (this is what ships in the ZI willexecutors.py <- remote will-executor services handling (is_selected / is_valid, parallel push/check). util.py <- locktime parsing/most helpers (timestamps only). - checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free). + checkalive.py <- resolve_date_to_check, resolve_relative_check_alive_date, + ensure_check_alive_valid (GUI-free; signed relative rule). reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free). input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free). cli/ <- headless command-line layer (no Qt) diff --git a/PLAN_CMDLINE_PLUGIN.md b/PLAN_CMDLINE_PLUGIN.md index 0c3dbd6..d642529 100644 --- a/PLAN_CMDLINE_PLUGIN.md +++ b/PLAN_CMDLINE_PLUGIN.md @@ -49,7 +49,7 @@ Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`, - `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`. - `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`. - `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB. - - `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check). + - `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione). - `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`). - `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt): - `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129), @@ -173,7 +173,7 @@ Metodi principali (replicano le funzioni Qt, senza dialoghi): |---|---|---| | `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. | | `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. | -| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. | +| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. | | `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. | | `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. | | `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. | diff --git a/bal/cli/controller.py b/bal/cli/controller.py index 6e96ac7..9443af3 100644 --- a/bal/cli/controller.py +++ b/bal/cli/controller.py @@ -40,9 +40,11 @@ from ..core import checkalive from ..core import heirs as heirs_mod from ..core import will as will_mod from ..core.checkalive import ( + CheckAliveAfterDeliveryError, CheckAliveError, - check_alive_expired, + ensure_check_alive_valid, resolve_date_to_check, + resolve_delivery_date, resolve_guard_threshold, ) from ..core.heirs import Heirs, is_op_return_address @@ -106,6 +108,12 @@ def _user_facing(e): return UserFacingException(str(e)) if isinstance(e, heirs_mod.LocktimeNotValid): return UserFacingException(str(e)) + if isinstance(e, checkalive.CheckAliveAfterDeliveryError): + return UserFacingException( + _( + "CheckAlive must be after today and before the delivery date" + ) + ) if isinstance(e, checkalive.CheckAliveError): return UserFacingException( _( @@ -272,13 +280,21 @@ class BalController: self.plugin.is_basic_mode(), self.will_settings, built_locktime=Will.get_min_locktime(self.willitems), + creation_date=Will.get_creation_time(self.willitems), ) self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get()) self.willexecutors = Willexecutors.get_willexecutors( self.plugin, update=True, task=False ) - if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check): - raise CheckAliveError(self.date_to_check) + delivery_date = resolve_delivery_date( + self.will_settings, + built_locktime=Will.get_min_locktime(self.willitems), + ) + ensure_check_alive_valid( + self.plugin.is_basic_mode(), + self.date_to_check, + delivery_date=delivery_date, + ) self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get()) def _init_heirs_to_locktime(self, multiverse=False): @@ -458,7 +474,9 @@ class BalController: locktime = Util.parse_locktime_string(self.will_settings["locktime"]) threshold_ts = resolve_guard_threshold( - self.plugin.is_basic_mode(), self.will_settings + self.plugin.is_basic_mode(), + self.will_settings, + creation_date=Will.get_creation_time(self.willitems), ) if threshold_ts is not None: if locktime < threshold_ts: @@ -583,6 +601,11 @@ class BalController: except will_mod.NoHeirsException as e: _logger.info(f"auto rebuild: no heirs ({e})") return {"result": "no_heirs"} + except CheckAliveAfterDeliveryError: + _logger.info( + "auto rebuild: Check Alive not before the delivery, nothing to do" + ) + return {"result": "check_alive_before_delivery"} except CheckAliveError: _logger.info("auto rebuild: Check Alive already in the past") return self._auto_rebuild_invalidate("threshold_passed") diff --git a/bal/core/checkalive.py b/bal/core/checkalive.py index 51f63c0..3c23ed7 100644 --- a/bal/core/checkalive.py +++ b/bal/core/checkalive.py @@ -6,8 +6,21 @@ The "Check Alive" policy: the single reference timestamp (``date_to_check``) against which every will-validity check is evaluated, and the BASIC/ADVANCED mode rules that decide it. -Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the -postpone/invalidate flow; the decision that it *should* be raised lives here. +A RELATIVE Check Alive value is anchored according to its SIGN: + +* NEGATIVE (``"-30d"`` / ``"-1y"``): counted BACKWARDS from the delivery date, + i.e. ``delivery + N days`` (``-30d`` = 30 days BEFORE the delivery). This is + the historical meaning of a relative threshold. +* POSITIVE (``"30d"`` / ``"1y"``): counted FORWARD from the inheritance + creation date (``min(WillItem.time)`` of the valid will items), i.e. + ``creation + N days``. The creation date is frozen in the wallet DB, so the + reference never drifts with the clock. +* When the anchor is unknown (no built will yet, or imported items without a + ``time``). The reference falls back to *now*. + +Pure, GUI-free. The GUI raises :class:`CheckAliveError` / +:class:`CheckAliveAfterDeliveryError` to trigger the postpone/invalidate flow; +the decision that they *should* be raised lives here. """ from datetime import datetime, timezone @@ -19,13 +32,114 @@ from .plugin_base import BalTimestamp class CheckAliveError(Exception): """Raised when the "check alive" date is in the past.""" - def __init__(self, timestamp_to_check): + def __init__(self, timestamp_to_check, reason: str | None = None): self.timestamp_to_check = timestamp_to_check + self.reason = reason def __str__(self): - return "Check alive expired please update it: {}".format( + message = "Check alive expired please update it: {}".format( datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat() ) + if self.reason: + message += f" ({self.reason})" + return message + + +class CheckAliveAfterDeliveryError(Exception): + """Raised when the "check alive" date is not before the delivery date. + + Deliberately NOT a subclass of :class:`CheckAliveError`: the existing + ``except CheckAliveError`` handlers invalidate the inheritance on-chain, + while a Check Alive that lands on/after the delivery is a configuration + error that must only abort the current action. + """ + + def __init__(self, timestamp_to_check, delivery_timestamp=None): + self.timestamp_to_check = timestamp_to_check + self.delivery_timestamp = delivery_timestamp + + def __str__(self): + check_date = datetime.fromtimestamp( + self.timestamp_to_check, tz=timezone.utc + ).isoformat() + if self.delivery_timestamp is None: + return ( + "Check alive date must be after today and before the delivery " + f"date: {check_date}" + ) + delivery_date = datetime.fromtimestamp( + self.delivery_timestamp, tz=timezone.utc + ).isoformat() + return ( + "Check alive date must be after today and before the delivery " + f"date: {check_date} >= {delivery_date}" + ) + + +def resolve_delivery_date( + will_settings: Any, + now: float | None = None, + built_locktime: float | int | None = None, +) -> float | None: + """Return the delivery date every Check Alive calculation is anchored to. + + This is the single source of the delivery reference, shared by + :func:`resolve_date_to_check` and :func:`resolve_guard_threshold`: + + * an ABSOLUTE stored locktime is used as-is; + * a RELATIVE stored locktime (``"2y"``) is resolved forward from *now*, + UNLESS a will has already been built: its frozen delivery date + (``built_locktime``, the locktime inside the signed tx) is then + authoritative, so an unchanged will never drifts as the clock advances. + + Returns ``None`` when no locktime is stored. + """ + locktime_raw = will_settings.get("locktime") + if locktime_raw is None: + return None + if BalTimestamp(locktime_raw).unit is not None and built_locktime: + return BalTimestamp(int(built_locktime)).to_timestamp(now) + return BalTimestamp(locktime_raw).to_timestamp(now) + + +def resolve_relative_check_alive_date( + threshold_raw: Any, + delivery_date: float | None, + creation_date: float | None = None, + now: float | None = None, +) -> float: + """Resolve a RELATIVE Check Alive value to an absolute timestamp. + + The SIGN of the value selects the anchor (the single calculation rule): + + * negative (``"-30d"``): ``delivery_date + N days`` (N < 0 -> BEFORE the + delivery); + * positive (``"30d"``): ``creation_date + N days`` (the inheritance + creation date); + * when the selected anchor is unknown, *now* is used as a fallback. + + Args: + threshold_raw: the stored ``"d"`` / ``"y"`` value (may be + negative). + delivery_date: the resolved delivery timestamp (see + :func:`resolve_delivery_date`), or ``None``. + creation_date: the inheritance creation timestamp + (``Will.get_creation_time``), or ``None``. + now: overridable clock for tests. + + Returns: + The absolute reference timestamp (float, UNIX seconds). + """ + threshold = BalTimestamp(threshold_raw) + if threshold.unit is None: + return threshold.to_timestamp() + if threshold.value < 0: + anchor = delivery_date + else: + anchor = creation_date + if anchor is None: + anchor = now if now is not None else datetime.now(tz=timezone.utc).timestamp() + return threshold.to_date(anchor).timestamp() def resolve_date_to_check( @@ -33,6 +147,7 @@ def resolve_date_to_check( will_settings: Any, now: float | None = None, built_locktime: float | int | None = None, + creation_date: float | None = None, ) -> float: """Return the reference timestamp for every will-validity check. @@ -41,22 +156,11 @@ def resolve_date_to_check( ``check_amounts`` and the locktime-vs-threshold guard. * BASIC mode: the Check Alive is hidden and NOT editable, so it must never - 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. 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. + govern those checks. ``date_to_check`` is set to *now*. + * ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a + RELATIVE one is resolved by :func:`resolve_relative_check_alive_date` + (negative -> anchored to the delivery, positive -> anchored to the + inheritance creation date). Args: is_basic_mode: ``True`` for the SIMPLE / BASIC user type. @@ -65,6 +169,8 @@ def resolve_date_to_check( 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). + creation_date: the inheritance creation timestamp + (``Will.get_creation_time``); ``None`` falls back to *now*. Returns: The reference timestamp (float, UNIX seconds). @@ -73,33 +179,24 @@ def resolve_date_to_check( return (now if now is not None else datetime.now(tz=timezone.utc).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() + if threshold.unit is None: + return threshold.to_timestamp() + delivery_date = resolve_delivery_date( + will_settings, now=now, built_locktime=built_locktime + ) + return resolve_relative_check_alive_date( + will_settings["threshold"], + delivery_date, + creation_date=creation_date, + now=now, + ) def resolve_guard_threshold( is_basic_mode: bool, will_settings: Any, now: float | None = None, + creation_date: float | None = None, ) -> float | None: """Resolve the "locktime is lower than threshold" guard's reference. @@ -109,22 +206,22 @@ def resolve_guard_threshold( Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the built will's frozen tx locktime so that an unchanged will never reads as expired -- this helper resolves the threshold from the **stored settings - alone**. Otherwise, when the stored relative locktime is shorter than the - frozen locktime of an old (still valid) built will (e.g. the delivery was - shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh - "1y" locktime against the old will's anchored threshold and wrongly fire, - even though locktime > threshold by the settings themselves. + alone** (no ``built_locktime``). Otherwise, when the stored relative + locktime is shorter than the frozen locktime of an old (still valid) built + will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard + would compare the fresh "1y" locktime against the old will's anchored + threshold and wrongly fire, even though locktime > threshold by the + settings themselves. * BASIC mode: no threshold exists. Returns ``None`` and the caller falls back to comparing the locktime against ``date_to_check`` (= now), so its behaviour is unchanged. * ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold as-is. - * ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning - "N days BEFORE the delivery"): the threshold is anchored to the locktime - resolved forward from *now* (the settings' own delivery reading, never a - built tx), keeping both sides of the comparison in the same reference - frame, as the settings widget displays it. + * ADVANCED mode with a RELATIVE threshold: resolved by + :func:`resolve_relative_check_alive_date` (negative -> delivery anchor, + positive -> inheritance creation date), using *now* for the delivery + reading and never a built tx. Returns ``None`` when there is no threshold to enforce (BASIC mode or a missing stored value). @@ -137,23 +234,58 @@ def resolve_guard_threshold( threshold = BalTimestamp(threshold_raw) if threshold.unit is None: return threshold.to_timestamp() - now_dt = ( - datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None + delivery_date = resolve_delivery_date(will_settings, now=now, built_locktime=None) + return resolve_relative_check_alive_date( + threshold_raw, + delivery_date, + creation_date=creation_date, + now=now, ) - locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt) - return threshold.to_date(locktime_dt, reverse=True).timestamp() -def check_alive_expired( - is_basic_mode: bool, date_to_check: float, now: float | None = None +def is_check_alive_date_valid( + date_to_check: float | None, + delivery_date: float | None, + now: float | None = None, ) -> bool: - """True when the Check Alive guard should fire (``CheckAliveError``). + """True when the Check Alive date is inside ``(now, delivery_date)``. - Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert - by construction, so a passed check-alive date must never force a postpone or - rewrite of the will. + The single validity rule for the Check Alive reference: it must be AFTER + today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the + upper bound (no delivery is known yet). """ - if is_basic_mode: + if date_to_check is None: return False current = now if now is not None else datetime.now(tz=timezone.utc).timestamp() - return date_to_check < current + if date_to_check < current: + return False + if delivery_date is not None and date_to_check >= delivery_date: + return False + return True + + +def ensure_check_alive_valid( + is_basic_mode: bool, + date_to_check: float | None, + delivery_date: float | None = None, + now: float | None = None, +) -> None: + """Raise when the Check Alive date is not valid. + + Only ADVANCED mode can be invalid: in BASIC mode the Check Alive is inert + by construction, so a passed check-alive date must never force a postpone + or rewrite of the will. + + Raises: + CheckAliveError: the date is in the past. + CheckAliveAfterDeliveryError: the date is on/after the delivery. + """ + if is_basic_mode: + return + if date_to_check is None: + return + current = now if now is not None else datetime.now(tz=timezone.utc).timestamp() + if date_to_check < current: + raise CheckAliveError(date_to_check) + if delivery_date is not None and date_to_check >= delivery_date: + raise CheckAliveAfterDeliveryError(date_to_check, delivery_date) diff --git a/bal/core/input_rules.py b/bal/core/input_rules.py index c75efbe..4785b4c 100644 --- a/bal/core/input_rules.py +++ b/bal/core/input_rules.py @@ -107,7 +107,7 @@ def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]: def normalize_locktime_raw_text( - text: str, pos: int + text: str, pos: int, allow_negative: bool = False ) -> Tuple[str, bool, bool, int]: """Sanitise the RAW locktime field text. @@ -117,6 +117,9 @@ def normalize_locktime_raw_text( Args: text: the raw field text. pos: the cursor position within ``text``. + allow_negative: when ``True`` a single leading ``-`` is kept (used by + the Check Alive field, whose sign selects the anchor date). The + delivery (locktime) field never accepts a sign. Returns: ``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised @@ -124,11 +127,17 @@ def normalize_locktime_raw_text( """ text = text.strip() chars = "0123456789dy" + if allow_negative: + chars += "-" pos = len("".join([i for i in text[:pos] if i in chars])) s = "".join([i for i in text if i in chars]) isdays = False isyears = False + negative = allow_negative and "-" in s + if negative: + s = s.replace("-", "") + pos, s = _checkbdy(s, pos, "d") pos, s = _checkbdy(s, pos, "y") @@ -141,6 +150,10 @@ def normalize_locktime_raw_text( s = replace_dy_suffixes(s) + "d" if isyears: s = replace_dy_suffixes(s) + "y" + + if negative: + s = "-" + s + pos = max(pos, 1) return s, isdays, isyears, pos diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 75cdc52..c2ce66d 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -467,22 +467,35 @@ class BalPlugin(BasePlugin): @staticmethod def default_will_settings_absolute(): - """Convert the default relative dates into absolute timestamps (from today).""" + """Convert the default relative dates into absolute timestamps. + + The Check Alive anchor follows its sign (see ``bal.core.checkalive``): + a negative default counts back from the delivery, a positive one counts + forward from today (the inheritance creation date). + """ relative_dates = BalPlugin.default_will_settings_relative() today = datetime.now(tz=timezone.utc).date() dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc) - threshold = ( - dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) - ).timestamp() - locktime = ( - dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) - ).timestamp() - return {"threshold": threshold, "locktime": locktime} + locktime_dt = dt + timedelta( + days=BalTimestamp(relative_dates["locktime"]).duration_to_days() + ) + threshold = BalTimestamp(relative_dates["threshold"]) + if threshold.unit is not None and threshold.value < 0: + anchor_dt = locktime_dt + else: + anchor_dt = dt + threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days()) + return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()} @staticmethod def default_will_settings_relative(): - """Default relative dates: 30 days threshold, 1 year locktime.""" - return {"threshold": "30d", "locktime": "1y"} + """Default relative dates: 30 days BEFORE the delivery, 1 year locktime. + + The Check Alive default is NEGATIVE on purpose: it keeps counting back + from the delivery date, preserving the historical "check alive 30 days + before delivery" behaviour. + """ + return {"threshold": "-30d", "locktime": "1y"} class BalTimestamp: diff --git a/bal/core/will.py b/bal/core/will.py index 71d7ff1..4e63414 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -972,6 +972,25 @@ class Will: def get_min_locktime(will,default_value=None): return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) + @staticmethod + def get_creation_time(will): + """Return the inheritance creation timestamp (oldest VALID will item). + + A positive relative Check Alive value counts forward from this date. + The per-item ``time`` is stamped when the will is built and persisted + in the wallet DB, so the anchor does not drift with the clock. Only + VALID items are considered; imported items without a ``time`` are + ignored. Returns ``None`` when no VALID item carries a timestamp. + """ + return min( + ( + v.time + for v in will.values() + if v.get_status('VALID') and v.time is not None + ), + default=None, + ) + @staticmethod diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 13f40cb..3e4f283 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -46,7 +46,12 @@ from ...core.animated_qr import ( ur1_frames, ur2_frames, ) -from ...core.checkalive import CheckAliveError, resolve_date_to_check +from ...core.checkalive import ( + CheckAliveAfterDeliveryError, + CheckAliveError, + resolve_date_to_check, + resolve_relative_check_alive_date, +) from ...core.qrtransfer import ( CHUNK_PRESETS, MissingFramesError, @@ -751,6 +756,13 @@ class BalBuildWillDialog(BalDialog): varrow = self.msg_set_status("Checking variables") try: self.bal_window.init_class_variables() + except CheckAliveAfterDeliveryError: + self.msg_set_status( + "Checking variables", varrow, + "Check Alive must be after today and before the delivery date", + self.COLOR_ERROR, + ) + return "check_alive_before_delivery", None except CheckAliveError as cae: fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) tx = Will.invalidate_will( @@ -1194,10 +1206,11 @@ class BalBuildWillDialog(BalDialog): self.bal_window.update_setting_widgets( min_locktime, "locktime", update_all=True ) - # A relative "Check Alive" threshold ("N days BEFORE the delivery") is - # also PRESERVED: it is anchored on every check by - # ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does - # not need to be frozen to an absolute date here. + # A relative "Check Alive" threshold is also PRESERVED: it is anchored + # on every check by ``resolve_date_to_check`` / ``resolve_guard_threshold`` + # (negative counts back from the delivery, positive forward from the + # inheritance creation date), so it never needs freezing to an absolute + # date here. def on_accept(self): try: @@ -1542,6 +1555,19 @@ class BalBuildWillDialog(BalDialog): self._add_no_heirs_buttons() return + if self.have_to_sign == "check_alive_before_delivery": + # The Check Alive date is on/after the delivery (a configuration + # error, not an expired will): do NOT invalidate, just tell the + # user and let them close the wizard. + self.bal_window.show_error( + _( + "CheckAlive must be after today and before the delivery " + "date please update it" + ) + ) + self._add_close_button() + return + _logger.debug("have to sign {}".format(self.have_to_sign)) password = None if self.have_to_sign is None: @@ -1830,9 +1856,14 @@ class BalBuildWillDialog(BalDialog): threshold = None num_reminders = 3 else: - threshold_ts = BalTimestamp( - self.bal_window.will_settings["threshold"] - ).to_timestamp() + # The Check Alive reference shares the same sign rule as the + # core: negative counts back from the delivery, positive counts + # forward from the inheritance creation date. + threshold_ts = resolve_relative_check_alive_date( + self.bal_window.will_settings["threshold"], + delivery_date=locktime_ts, + creation_date=Will.get_creation_time(self.bal_window.willitems), + ) threshold = datetime.fromtimestamp(threshold_ts) raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 1c1deb7..8e1f1af 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -20,6 +20,7 @@ Contents: from typing import TYPE_CHECKING +from ...core.checkalive import resolve_relative_check_alive_date from ...core.heirs import get_op_return_hex, is_op_return_address from ...core.input_rules import ( LockTimeEditor, @@ -215,6 +216,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): label_text = None tooltip_text = None base_field = None + # A leading "-" is accepted by the RAW editor only for the Check Alive + # field (its sign selects the anchor date); the delivery field never + # accepts a sign. Subclasses override this. + allow_negative_raw = False def __init__(self, bal_window, parent, default_locktime=None): super().__init__(parent) @@ -225,7 +230,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): hbox.setContentsMargins(0, 0, 0, 0) hbox.setSpacing(0) self.setMinimumWidth(40 * char_width_in_lineedit()) - self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self) + self.locktime_raw_e = TimeRawEditWidget( + self, time_edit=self, allow_negative=self.allow_negative_raw + ) self.locktime_date_e = LockTimeDateEdit(self, time_edit=self) self.editors = [self.locktime_raw_e, self.locktime_date_e] self.combo = QComboBox() @@ -478,9 +485,9 @@ class TimeRawEditWidget(QWidget): def is_acceptable_locktime(self, value): return True - def __init__(self, parent, time_edit=None): + def __init__(self, parent, time_edit=None, allow_negative=False): super().__init__(parent) - self.editor = LockTimeRawEdit(parent, time_edit) + self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative) self.label = QLabel("") # Group C / C3: this trailing label shows the ABSOLUTE date computed # from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a @@ -506,7 +513,7 @@ class TimeRawEditWidget(QWidget): class LockTimeRawEdit(QLineEdit, _LockTimeEditor): - def __init__(self, parent=None, time_edit=None): + def __init__(self, parent=None, time_edit=None, allow_negative=False): QLineEdit.__init__(self, parent) # Group C / C3: narrow the RAW input to roughly a third of its former # width. The accepted values are short relative durations such as "30d" @@ -516,16 +523,22 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor): self.textChanged.connect(self.numbify) self.isdays = False self.isyears = False + # The Check Alive field accepts a leading "-" (the sign selects the + # anchor date); the delivery field does not. + self.allow_negative = allow_negative self.time_edit = time_edit def numbify(self): - # Only digits plus the day ("d") and year ("y") suffixes are accepted. - # The block-height suffix ("b") was removed (A1): locktimes are always - # UNIX timestamps now, so block-relative input is no longer allowed. + # Only digits plus the day ("d") and year ("y") suffixes are accepted + # (and, for the Check Alive field, a single leading "-"). The + # block-height suffix ("b") was removed (A1): locktimes are always UNIX + # timestamps now, so block-relative input is no longer allowed. # The sanitisation itself lives in bal.core.input_rules. text = self.text().strip() pos = self.cursorPosition() - s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos) + s, isdays, isyears, pos = normalize_locktime_raw_text( + text, pos, allow_negative=self.allow_negative + ) self.isdays = isdays self.isyears = isyears self.blockSignals(True) @@ -639,6 +652,11 @@ class ThresholdTimeWidget(BalTimeEditWidget): "if you choose Raw, you can insert various options based on suffix:
" " - d: number of days after current day(ex: 1d means tomorrow)
" " - y: number of years after current day(ex: 1y means one year from today)
" + "The sign selects the reference date:
" + " - a NEGATIVE value (ex: -30d) counts backwards from the " + "delivery date (30 days before the delivery);
" + " - a POSITIVE value (ex: 30d) counts forward from the " + "inheritance creation date (30 days after the will was created).
" "
Note: the date/time is expressed in your computer's " "Local time (not UTC time).
" ) @@ -646,6 +664,8 @@ class ThresholdTimeWidget(BalTimeEditWidget): #label_text = "Check Alive" tooltip_text = "Check Alive" base_field = "threshold" + # The Check Alive field accepts a signed value; the delivery field does not. + allow_negative_raw = True def __init__(self, bal_window, parent, init_value=None): if init_value is None: @@ -1127,18 +1147,23 @@ class WillSettingsWidget(QWidget): def on_locktime_change(self): locktime = self.widgets["locktime"].get_value() - threshold = self.widgets["threshold"].get_value() + threshold_raw = self.widgets["threshold"].get_value() locktime = BalTimestamp(locktime) - threshold = BalTimestamp(threshold) min_locktime = min( Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX), locktime.to_timestamp(), ) - # Resolved Check Alive date. For relative values this counts BACKWARDS - # from the delivery time (reverse=True): "90d" means "90 days BEFORE the - # delivery", not "90 days from now". - td = threshold.to_date(min_locktime, True) + # Resolved Check Alive date. The SIGN of the RAW value selects the + # anchor: negative counts backwards from the delivery, positive counts + # forward from the inheritance creation date. The rule lives in + # ``bal.core.checkalive`` so this display matches every validity check. + resolved = resolve_relative_check_alive_date( + threshold_raw, + delivery_date=min_locktime, + creation_date=Will.get_creation_time(self.bal_window.willitems), + ) + td = BalTimestamp._safe_fromtimestamp(resolved) self.widgets["threshold"].alarm=td self.bal_window.will_settings["real_threshold"]=td.timestamp() try: diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 55c3784..3d4215b 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -20,9 +20,11 @@ import threading from electrum.util import MyEncoder from ...core.checkalive import ( + CheckAliveAfterDeliveryError, CheckAliveError, - check_alive_expired, + ensure_check_alive_valid, resolve_date_to_check, + resolve_delivery_date, resolve_guard_threshold, ) from .common import ( @@ -491,6 +493,7 @@ class BalWindow: self.bal_plugin.is_basic_mode(), self.will_settings, built_locktime=_new_locktime, + creation_date=Will.get_creation_time(self.willitems), ) will = {} # willtodelete = [] @@ -739,6 +742,7 @@ class BalWindow: self.bal_plugin.is_basic_mode(), self.will_settings, built_locktime=Will.get_min_locktime(self.willitems), + creation_date=Will.get_creation_time(self.willitems), ) # found = False # NOTE: block-height tracking removed (A1) - locktimes are always @@ -749,19 +753,20 @@ class BalWindow: self.willexecutors = Willexecutors.get_willexecutors( self.bal_plugin, update=True, bal_window=self, task=False ) - # SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must - # behave AS IF IT DID NOT EXIST. The check-alive threshold drives - # the "you are alive -> postpone the inheritance" prompt; raising - # CheckAliveError here is what triggers that postpone/invalidate - # flow. In BASIC we therefore SKIP this check entirely, so a passed - # check-alive date never forces a postpone/rewrite of the will. The - # delivery time (locktime) is unaffected and still fully enforced. - # The BASIC/ADVANCED rule lives in - # ``bal.core.checkalive.check_alive_expired``. - if check_alive_expired( - self.bal_plugin.is_basic_mode(), self.date_to_check - ): - raise CheckAliveError(self.date_to_check) + # The Check Alive must be strictly after today and strictly before + # the delivery. In BASIC mode the single validity gate below is a + # no-op (see ``bal.core.checkalive.ensure_check_alive_valid``), so a + # passed check-alive date never forces a postpone/rewrite; the + # delivery time (locktime) stays fully enforced. + delivery_date = resolve_delivery_date( + self.will_settings, + built_locktime=Will.get_min_locktime(self.willitems), + ) + ensure_check_alive_valid( + self.bal_plugin.is_basic_mode(), + self.date_to_check, + delivery_date=delivery_date, + ) self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) @@ -786,7 +791,9 @@ class BalWindow: """ locktime = Util.parse_locktime_string(self.will_settings["locktime"]) threshold_ts = resolve_guard_threshold( - self.bal_plugin.is_basic_mode(), self.will_settings + self.bal_plugin.is_basic_mode(), + self.will_settings, + creation_date=Will.get_creation_time(self.willitems), ) if threshold_ts is not None: return locktime < threshold_ts @@ -836,6 +843,13 @@ class BalWindow: _(f"Will-executor fee too high: {e}") ) return + except CheckAliveAfterDeliveryError: + self.show_error( + _( + "CheckAlive must be after today and before the delivery date please update it" + ) + ) + return except CheckAliveError: self.show_error( _( @@ -1295,6 +1309,12 @@ class BalWindow: # means the old will must be invalidated on-chain. try: self.init_class_variables() + except CheckAliveAfterDeliveryError: + _logger.info( + "auto rebuild: check-alive not before the delivery, " + "nothing to rebuild/invalidate" + ) + return False except CheckAliveError: _logger.info("auto rebuild: check-alive threshold passed -> invalidate") self._auto_invalidate_will() diff --git a/docs/inheritance-options.md b/docs/inheritance-options.md index 04dbbdb..57233ee 100644 --- a/docs/inheritance-options.md +++ b/docs/inheritance-options.md @@ -32,6 +32,18 @@ you change anything in your will, BAL must decide between three outcomes: The whole point of rule 3 is safety: **a will‑executor must never be able to broadcast an old transaction that would execute your inheritance too early.** +**Check‑Alive relative values are signed.** A RELATIVE Check‑Alive +(``-30d`` / ``30d``) is anchored according to its sign: + +- **NEGATIVE** (``-30d``) counts **backwards from the delivery date** — the + classic "check alive 30 days before the delivery". +- **POSITIVE** (``30d``) counts **forward from the inheritance creation date** + (the date the will was built), so it stays fixed while the will is unchanged. + +The Check‑Alive date must always be **after today** and **before the delivery**; +when it is not, BAL raises `CheckAliveAfterDeliveryError` (a configuration error +that aborts the action and never invalidates the will). + --- ## 2. Transaction states (status flags) @@ -164,6 +176,7 @@ exactly what the will‑executors hold — not the in‑memory copy. | **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** | | **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** | | Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** | +| Check‑Alive date not before the delivery (config error) | — | **Abort**, no rebuild/invalidate (`CheckAliveAfterDeliveryError`) | **No** | > **Why postpone needs an on‑chain invalidation:** the will‑executor still holds > the *old* transaction with the *earlier* locktime. If you simply re‑signed a diff --git a/tests/test_cli_controller_offline.py b/tests/test_cli_controller_offline.py index f793aa6..85d1fa6 100644 --- a/tests/test_cli_controller_offline.py +++ b/tests/test_cli_controller_offline.py @@ -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"] diff --git a/tests/test_core_checkalive.py b/tests/test_core_checkalive.py index c609b9c..8c668bc 100644 --- a/tests/test_core_checkalive.py +++ b/tests/test_core_checkalive.py @@ -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 # ------------------------------------------------------------------ # diff --git a/tests/test_core_input_rules.py b/tests/test_core_input_rules.py index c9fe4d0..d935541 100644 --- a/tests/test_core_input_rules.py +++ b/tests/test_core_input_rules.py @@ -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 # ------------------------------------------------------------------ # diff --git a/tests/test_core_plugin_base.py b/tests/test_core_plugin_base.py index 4a00d24..72e13fd 100644 --- a/tests/test_core_plugin_base.py +++ b/tests/test_core_plugin_base.py @@ -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" diff --git a/tests/test_core_will.py b/tests/test_core_will.py index 4f5c28d..4756bdb 100644 --- a/tests/test_core_will.py +++ b/tests/test_core_will.py @@ -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 diff --git a/tests/test_group_i_basic_checkalive.py b/tests/test_group_i_basic_checkalive.py index 8c020df..c897f9e 100644 --- a/tests/test_group_i_basic_checkalive.py +++ b/tests/test_group_i_basic_checkalive.py @@ -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) diff --git a/tests/test_gui_will_flows.py b/tests/test_gui_will_flows.py index cf7a6fb..511584a 100644 --- a/tests/test_gui_will_flows.py +++ b/tests/test_gui_will_flows.py @@ -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"