3 Commits

Author SHA1 Message Date
1b601609f0 i18n: refresh catalogs and translate the new Check Alive texts
Unify the Check Alive-after-delivery error message across GUI and CLI, then
re-extract the catalog and translate the two new/changed texts into Italian.
2026-10-03 11:37:12 -04:00
b1f78700d1 Merge wip/checkalive-relative into main
Signed relative Check Alive (negative = before delivery, positive = after
creation). Resolves CHANGELOG.md (new section 61).
2026-10-03 11:37:03 -04:00
beab22ce23 core+gui: signed relative Check Alive (negative = before delivery, positive = after creation) 2026-10-03 10:49:25 -04:00
21 changed files with 1410 additions and 854 deletions

View File

@@ -3448,3 +3448,70 @@ only `bal/locale/it_IT/LC_MESSAGES/bal.mo` for the translations and
**Outcome:** DONE, pending the owner's review of the Italian texts and test **Outcome:** DONE, pending the owner's review of the Italian texts and test
of the ZIP. of the ZIP.
## 61. 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.

View File

@@ -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, willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
parallel push/check). parallel push/check).
util.py <- locktime parsing/most helpers (timestamps only). 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). reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free). input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
cli/ <- headless command-line layer (no Qt) cli/ <- headless command-line layer (no Qt)

View File

@@ -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`. - `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`. - `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. - `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`). - `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): - `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), - `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. | | `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()`. | | `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. | | `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}`. | | `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`. | | `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`. |

View File

@@ -40,9 +40,11 @@ from ..core import checkalive
from ..core import heirs as heirs_mod from ..core import heirs as heirs_mod
from ..core import will as will_mod from ..core import will as will_mod
from ..core.checkalive import ( from ..core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
check_alive_expired, ensure_check_alive_valid,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold, resolve_guard_threshold,
) )
from ..core.heirs import Heirs, is_op_return_address from ..core.heirs import Heirs, is_op_return_address
@@ -106,6 +108,12 @@ def _user_facing(e):
return UserFacingException(str(e)) return UserFacingException(str(e))
if isinstance(e, heirs_mod.LocktimeNotValid): if isinstance(e, heirs_mod.LocktimeNotValid):
return UserFacingException(str(e)) return UserFacingException(str(e))
if isinstance(e, checkalive.CheckAliveAfterDeliveryError):
return UserFacingException(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
if isinstance(e, checkalive.CheckAliveError): if isinstance(e, checkalive.CheckAliveError):
return UserFacingException( return UserFacingException(
_( _(
@@ -272,13 +280,21 @@ class BalController:
self.plugin.is_basic_mode(), self.plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems), 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.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=True, task=False self.plugin, update=True, task=False
) )
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check): delivery_date = resolve_delivery_date(
raise CheckAliveError(self.date_to_check) 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()) self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
def _init_heirs_to_locktime(self, multiverse=False): def _init_heirs_to_locktime(self, multiverse=False):
@@ -458,7 +474,9 @@ class BalController:
locktime = Util.parse_locktime_string(self.will_settings["locktime"]) locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold( 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 threshold_ts is not None:
if locktime < threshold_ts: if locktime < threshold_ts:
@@ -583,6 +601,11 @@ class BalController:
except will_mod.NoHeirsException as e: except will_mod.NoHeirsException as e:
_logger.info(f"auto rebuild: no heirs ({e})") _logger.info(f"auto rebuild: no heirs ({e})")
return {"result": "no_heirs"} 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: except CheckAliveError:
_logger.info("auto rebuild: Check Alive already in the past") _logger.info("auto rebuild: Check Alive already in the past")
return self._auto_rebuild_invalidate("threshold_passed") return self._auto_rebuild_invalidate("threshold_passed")

View File

@@ -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 against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it. mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the A RELATIVE Check Alive value is anchored according to its SIGN:
postpone/invalidate flow; the decision that it *should* be raised lives here.
* 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 from datetime import datetime, timezone
@@ -19,13 +32,114 @@ from .plugin_base import BalTimestamp
class CheckAliveError(Exception): class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past.""" """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.timestamp_to_check = timestamp_to_check
self.reason = reason
def __str__(self): 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() 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 ``"<n>d"`` / ``"<n>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( def resolve_date_to_check(
@@ -33,6 +147,7 @@ def resolve_date_to_check(
will_settings: Any, will_settings: Any,
now: float | None = None, now: float | None = None,
built_locktime: float | int | None = None, built_locktime: float | int | None = None,
creation_date: float | None = None,
) -> float: ) -> float:
"""Return the reference timestamp for every will-validity check. """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. ``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never * 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 govern those checks. ``date_to_check`` is set to *now*.
evaluated against the current moment (the Check Alive effectively does not * ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a
exist) while the delivery locktime is still fully enforced. RELATIVE one is resolved by :func:`resolve_relative_check_alive_date`
* ADVANCED mode: the user-controlled stored threshold is used as-is. An (negative -> anchored to the delivery, positive -> anchored to the
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``) inheritance creation date).
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: Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type. 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()``. now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet). 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: Returns:
The reference timestamp (float, UNIX seconds). 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()) return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
threshold = BalTimestamp(will_settings["threshold"]) threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery": if threshold.unit is None:
# 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() 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( def resolve_guard_threshold(
is_basic_mode: bool, is_basic_mode: bool,
will_settings: Any, will_settings: Any,
now: float | None = None, now: float | None = None,
creation_date: float | None = None,
) -> float | None: ) -> float | None:
"""Resolve the "locktime is lower than threshold" guard's reference. """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 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 built will's frozen tx locktime so that an unchanged will never reads as
expired -- this helper resolves the threshold from the **stored settings expired -- this helper resolves the threshold from the **stored settings
alone**. Otherwise, when the stored relative locktime is shorter than the alone** (no ``built_locktime``). Otherwise, when the stored relative
frozen locktime of an old (still valid) built will (e.g. the delivery was locktime is shorter than the frozen locktime of an old (still valid) built
shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard
"1y" locktime against the old will's anchored threshold and wrongly fire, would compare the fresh "1y" locktime against the old will's anchored
even though locktime > threshold by the settings themselves. threshold and wrongly fire, even though locktime > threshold by the
settings themselves.
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls * BASIC mode: no threshold exists. Returns ``None`` and the caller falls
back to comparing the locktime against ``date_to_check`` (= now), so its back to comparing the locktime against ``date_to_check`` (= now), so its
behaviour is unchanged. behaviour is unchanged.
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold * ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
as-is. as-is.
* ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning * ADVANCED mode with a RELATIVE threshold: resolved by
"N days BEFORE the delivery"): the threshold is anchored to the locktime :func:`resolve_relative_check_alive_date` (negative -> delivery anchor,
resolved forward from *now* (the settings' own delivery reading, never a positive -> inheritance creation date), using *now* for the delivery
built tx), keeping both sides of the comparison in the same reference reading and never a built tx.
frame, as the settings widget displays it.
Returns ``None`` when there is no threshold to enforce (BASIC mode or a Returns ``None`` when there is no threshold to enforce (BASIC mode or a
missing stored value). missing stored value).
@@ -137,23 +234,58 @@ def resolve_guard_threshold(
threshold = BalTimestamp(threshold_raw) threshold = BalTimestamp(threshold_raw)
if threshold.unit is None: if threshold.unit is None:
return threshold.to_timestamp() return threshold.to_timestamp()
now_dt = ( delivery_date = resolve_delivery_date(will_settings, now=now, built_locktime=None)
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else 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( def is_check_alive_date_valid(
is_basic_mode: bool, date_to_check: float, now: float | None = None date_to_check: float | None,
delivery_date: float | None,
now: float | None = None,
) -> bool: ) -> 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 The single validity rule for the Check Alive reference: it must be AFTER
by construction, so a passed check-alive date must never force a postpone or today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the
rewrite of the will. upper bound (no delivery is known yet).
""" """
if is_basic_mode: if date_to_check is None:
return False return False
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp() 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)

View File

@@ -107,7 +107,7 @@ def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
def normalize_locktime_raw_text( def normalize_locktime_raw_text(
text: str, pos: int text: str, pos: int, allow_negative: bool = False
) -> Tuple[str, bool, bool, int]: ) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text. """Sanitise the RAW locktime field text.
@@ -117,6 +117,9 @@ def normalize_locktime_raw_text(
Args: Args:
text: the raw field text. text: the raw field text.
pos: the cursor position within ``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: Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised ``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
@@ -124,11 +127,17 @@ def normalize_locktime_raw_text(
""" """
text = text.strip() text = text.strip()
chars = "0123456789dy" chars = "0123456789dy"
if allow_negative:
chars += "-"
pos = len("".join([i for i in text[:pos] if i in 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]) s = "".join([i for i in text if i in chars])
isdays = False isdays = False
isyears = 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, "d")
pos, s = _checkbdy(s, pos, "y") pos, s = _checkbdy(s, pos, "y")
@@ -141,6 +150,10 @@ def normalize_locktime_raw_text(
s = replace_dy_suffixes(s) + "d" s = replace_dy_suffixes(s) + "d"
if isyears: if isyears:
s = replace_dy_suffixes(s) + "y" s = replace_dy_suffixes(s) + "y"
if negative:
s = "-" + s
pos = max(pos, 1)
return s, isdays, isyears, pos return s, isdays, isyears, pos

View File

@@ -498,22 +498,35 @@ class BalPlugin(BasePlugin):
@staticmethod @staticmethod
def default_will_settings_absolute(): 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() relative_dates = BalPlugin.default_will_settings_relative()
today = datetime.now(tz=timezone.utc).date() today = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc) dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
threshold = ( locktime_dt = dt + timedelta(
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
).timestamp() )
locktime = ( threshold = BalTimestamp(relative_dates["threshold"])
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) if threshold.unit is not None and threshold.value < 0:
).timestamp() anchor_dt = locktime_dt
return {"threshold": threshold, "locktime": locktime} else:
anchor_dt = dt
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
@staticmethod @staticmethod
def default_will_settings_relative(): def default_will_settings_relative():
"""Default relative dates: 30 days threshold, 1 year locktime.""" """Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
return {"threshold": "30d", "locktime": "1y"}
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: class BalTimestamp:

View File

@@ -972,6 +972,25 @@ class Will:
def get_min_locktime(will,default_value=None): 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) 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 @staticmethod

View File

@@ -46,7 +46,12 @@ from ...core.animated_qr import (
ur1_frames, ur1_frames,
ur2_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 ( from ...core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MissingFramesError, MissingFramesError,
@@ -754,6 +759,13 @@ class BalBuildWillDialog(BalDialog):
varrow = self.msg_set_status(_("Checking variables")) varrow = self.msg_set_status(_("Checking variables"))
try: try:
self.bal_window.init_class_variables() 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: except CheckAliveError as cae:
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
tx = Will.invalidate_will( tx = Will.invalidate_will(
@@ -1201,10 +1213,11 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.update_setting_widgets( self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True min_locktime, "locktime", update_all=True
) )
# A relative "Check Alive" threshold ("N days BEFORE the delivery") is # A relative "Check Alive" threshold is also PRESERVED: it is anchored
# also PRESERVED: it is anchored on every check by # on every check by ``resolve_date_to_check`` / ``resolve_guard_threshold``
# ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does # (negative counts back from the delivery, positive forward from the
# not need to be frozen to an absolute date here. # inheritance creation date), so it never needs freezing to an absolute
# date here.
def on_accept(self): def on_accept(self):
try: try:
@@ -1553,6 +1566,18 @@ class BalBuildWillDialog(BalDialog):
self._add_no_heirs_buttons() self._add_no_heirs_buttons()
return 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(
_(
"Check Alive 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)) _logger.debug("have to sign {}".format(self.have_to_sign))
password = None password = None
if self.have_to_sign is None: if self.have_to_sign is None:
@@ -1844,9 +1869,14 @@ class BalBuildWillDialog(BalDialog):
threshold = None threshold = None
num_reminders = 3 num_reminders = 3
else: else:
threshold_ts = BalTimestamp( # The Check Alive reference shares the same sign rule as the
self.bal_window.will_settings["threshold"] # core: negative counts back from the delivery, positive counts
).to_timestamp() # 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) threshold = datetime.fromtimestamp(threshold_ts)
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()

View File

@@ -20,6 +20,7 @@ Contents:
from typing import TYPE_CHECKING 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.heirs import get_op_return_hex, is_op_return_address
from ...core.input_rules import ( from ...core.input_rules import (
LockTimeEditor, LockTimeEditor,
@@ -217,6 +218,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
label_text = None label_text = None
tooltip_text = None tooltip_text = None
base_field = 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): def __init__(self, bal_window, parent, default_locktime=None):
super().__init__(parent) super().__init__(parent)
@@ -227,7 +232,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
hbox.setContentsMargins(0, 0, 0, 0) hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(0) hbox.setSpacing(0)
self.setMinimumWidth(40 * char_width_in_lineedit()) 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.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
self.editors = [self.locktime_raw_e, self.locktime_date_e] self.editors = [self.locktime_raw_e, self.locktime_date_e]
self.combo = QComboBox() self.combo = QComboBox()
@@ -482,9 +489,9 @@ class TimeRawEditWidget(QWidget):
def is_acceptable_locktime(self, value): def is_acceptable_locktime(self, value):
return True return True
def __init__(self, parent, time_edit=None): def __init__(self, parent, time_edit=None, allow_negative=False):
super().__init__(parent) super().__init__(parent)
self.editor = LockTimeRawEdit(parent, time_edit) self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative)
self.label = QLabel("") self.label = QLabel("")
# Group C / C3: this trailing label shows the ABSOLUTE date computed # 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 # from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
@@ -510,7 +517,7 @@ class TimeRawEditWidget(QWidget):
class LockTimeRawEdit(QLineEdit, _LockTimeEditor): 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) QLineEdit.__init__(self, parent)
# Group C / C3: narrow the RAW input to roughly a third of its former # 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" # width. The accepted values are short relative durations such as "30d"
@@ -520,16 +527,22 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.textChanged.connect(self.numbify) self.textChanged.connect(self.numbify)
self.isdays = False self.isdays = False
self.isyears = 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 self.time_edit = time_edit
def numbify(self): def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted. # Only digits plus the day ("d") and year ("y") suffixes are accepted
# The block-height suffix ("b") was removed (A1): locktimes are always # (and, for the Check Alive field, a single leading "-"). The
# UNIX timestamps now, so block-relative input is no longer allowed. # 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. # The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
pos = self.cursorPosition() 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.isdays = isdays
self.isyears = isyears self.isyears = isyears
self.blockSignals(True) self.blockSignals(True)
@@ -643,6 +656,11 @@ class ThresholdTimeWidget(BalTimeEditWidget):
"if you choose Raw, you can insert various options based on suffix:<br>" "if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>" " - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after current day(ex: 1y means one year from today)<br>" " - y: number of years after current day(ex: 1y means one year from today)<br>"
"The sign selects the reference date:<br>"
" - a NEGATIVE value (ex: -30d) counts backwards from the "
"<b>delivery</b> date (30 days before the delivery);<br>"
" - a POSITIVE value (ex: 30d) counts forward from the "
"<b>inheritance creation</b> date (30 days after the will was created).<br>"
"<br><b>Note:</b> the date/time is expressed in your computer's " "<br><b>Note:</b> the date/time is expressed in your computer's "
"<b>Local time</b> (not UTC time).<br>" "<b>Local time</b> (not UTC time).<br>"
) )
@@ -650,6 +668,8 @@ class ThresholdTimeWidget(BalTimeEditWidget):
#label_text = "Check Alive" #label_text = "Check Alive"
tooltip_text = N_("Check Alive") tooltip_text = N_("Check Alive")
base_field = "threshold" 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): def __init__(self, bal_window, parent, init_value=None):
if init_value is None: if init_value is None:
@@ -1135,18 +1155,23 @@ class WillSettingsWidget(QWidget):
def on_locktime_change(self): def on_locktime_change(self):
locktime = self.widgets["locktime"].get_value() locktime = self.widgets["locktime"].get_value()
threshold = self.widgets["threshold"].get_value() threshold_raw = self.widgets["threshold"].get_value()
locktime = BalTimestamp(locktime) locktime = BalTimestamp(locktime)
threshold = BalTimestamp(threshold)
min_locktime = min( min_locktime = min(
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX), Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
locktime.to_timestamp(), locktime.to_timestamp(),
) )
# Resolved Check Alive date. For relative values this counts BACKWARDS # Resolved Check Alive date. The SIGN of the RAW value selects the
# from the delivery time (reverse=True): "90d" means "90 days BEFORE the # anchor: negative counts backwards from the delivery, positive counts
# delivery", not "90 days from now". # forward from the inheritance creation date. The rule lives in
td = threshold.to_date(min_locktime, True) # ``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.widgets["threshold"].alarm=td
self.bal_window.will_settings["real_threshold"]=td.timestamp() self.bal_window.will_settings["real_threshold"]=td.timestamp()
try: try:

View File

@@ -20,9 +20,11 @@ import threading
from electrum.util import MyEncoder from electrum.util import MyEncoder
from ...core.checkalive import ( from ...core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
check_alive_expired, ensure_check_alive_valid,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold, resolve_guard_threshold,
) )
from .common import ( from .common import (
@@ -492,6 +494,7 @@ class BalWindow:
self.bal_plugin.is_basic_mode(), self.bal_plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=_new_locktime, built_locktime=_new_locktime,
creation_date=Will.get_creation_time(self.willitems),
) )
will = {} will = {}
# willtodelete = [] # willtodelete = []
@@ -740,6 +743,7 @@ class BalWindow:
self.bal_plugin.is_basic_mode(), self.bal_plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems), built_locktime=Will.get_min_locktime(self.willitems),
creation_date=Will.get_creation_time(self.willitems),
) )
# found = False # found = False
# NOTE: block-height tracking removed (A1) - locktimes are always # NOTE: block-height tracking removed (A1) - locktimes are always
@@ -750,19 +754,20 @@ class BalWindow:
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=True, bal_window=self, task=False self.bal_plugin, update=True, bal_window=self, task=False
) )
# SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must # The Check Alive must be strictly after today and strictly before
# behave AS IF IT DID NOT EXIST. The check-alive threshold drives # the delivery. In BASIC mode the single validity gate below is a
# the "you are alive -> postpone the inheritance" prompt; raising # no-op (see ``bal.core.checkalive.ensure_check_alive_valid``), so a
# CheckAliveError here is what triggers that postpone/invalidate # passed check-alive date never forces a postpone/rewrite; the
# flow. In BASIC we therefore SKIP this check entirely, so a passed # delivery time (locktime) stays fully enforced.
# check-alive date never forces a postpone/rewrite of the will. The delivery_date = resolve_delivery_date(
# delivery time (locktime) is unaffected and still fully enforced. self.will_settings,
# The BASIC/ADVANCED rule lives in built_locktime=Will.get_min_locktime(self.willitems),
# ``bal.core.checkalive.check_alive_expired``. )
if check_alive_expired( ensure_check_alive_valid(
self.bal_plugin.is_basic_mode(), self.date_to_check self.bal_plugin.is_basic_mode(),
): self.date_to_check,
raise CheckAliveError(self.date_to_check) delivery_date=delivery_date,
)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
@@ -787,7 +792,9 @@ class BalWindow:
""" """
locktime = Util.parse_locktime_string(self.will_settings["locktime"]) locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold( 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: if threshold_ts is not None:
return locktime < threshold_ts return locktime < threshold_ts
@@ -837,6 +844,13 @@ class BalWindow:
_("Will-executor fee too high: {}").format(e) _("Will-executor fee too high: {}").format(e)
) )
return return
except CheckAliveAfterDeliveryError:
self.show_error(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
return
except CheckAliveError: except CheckAliveError:
self.show_error( self.show_error(
_( _(
@@ -1298,6 +1312,12 @@ class BalWindow:
# means the old will must be invalidated on-chain. # means the old will must be invalidated on-chain.
try: try:
self.init_class_variables() self.init_class_variables()
except CheckAliveAfterDeliveryError:
_logger.info(
"auto rebuild: check-alive not before the delivery, "
"nothing to rebuild/invalidate"
)
return False
except CheckAliveError: except CheckAliveError:
_logger.info("auto rebuild: check-alive threshold passed -> invalidate") _logger.info("auto rebuild: check-alive threshold passed -> invalidate")
self._auto_invalidate_will() self._auto_invalidate_will()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 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.** 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) ## 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, 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** | | **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 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 > **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 > the *old* transaction with the *earlier* locktime. If you simply re‑signed a

View File

@@ -240,7 +240,7 @@ def test_build_will_reanchors_date_to_check_to_new_locktime():
plugin.USER_TYPE.set("advanced") plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True) plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.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 = _make_controller(plugin)
c.no_willexecutor = True c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"] c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]

View File

@@ -1,8 +1,11 @@
""" """
Tests for ``bal.core.checkalive`` (pure, GUI-free). Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
policy (``resolve_date_to_check`` / ``check_alive_expired``). (``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: Run:
source "$BAL_HOME/electrum/env/bin/activate" source "$BAL_HOME/electrum/env/bin/activate"
@@ -11,24 +14,41 @@ Run:
import sys import sys
import time import time
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above) from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
check_alive_expired, ensure_check_alive_valid,
is_check_alive_date_valid,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold, 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(): def test_check_alive_error_default():
err = CheckAliveError(1000000) err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000 assert err.timestamp_to_check == 1000000
assert err.reason is None
def test_check_alive_error_str(): def test_check_alive_error_str():
@@ -38,10 +58,107 @@ def test_check_alive_error_str():
assert "1970" in s 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(): def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception) 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 # resolve_date_to_check
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -59,69 +176,60 @@ def test_advanced_mode_uses_threshold_absolute():
assert abs(result - threshold) < 1 assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold(): def test_advanced_mode_negative_relative_anchored_to_delivery():
# 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
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400 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) result = resolve_date_to_check(False, settings, now=fake_now)
# date_to_check = (locktime, midnight-normalised) - 30 days. expected = _midnight(locktime) - 30 * 86400
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
assert abs(result - expected) < 1 assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime(): def test_advanced_mode_positive_relative_anchored_to_creation():
"""A relative threshold never drifts with the clock: re-resolving it a day fake_now = 1_800_000_000.0
later, with the same fixed absolute locktime, yields the same date.""" 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 fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400 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) 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) second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime(): def test_advanced_mode_positive_relative_creation_never_drifts():
"""A relative locktime is resolved against 'now' first, then the relative """A positive relative threshold anchored to the frozen creation date does
threshold counts N days back from it (matches the settings widget).""" not drift with the clock."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"} creation = fake_now + 2 * 86400
result = resolve_date_to_check(False, settings, now=fake_now) settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
# Recompute the expected value with the same resolution rules: first = resolve_date_to_check(
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d. False, settings, now=fake_now, creation_date=creation
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc)) )
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp() second = resolve_date_to_check(
assert abs(result - expected) < 1 False, settings, now=fake_now + 5 * 86400, creation_date=creation
assert result > fake_now )
assert first == second
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(): def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen """A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug).""" 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 frozen = 1817424000 # frozen tx locktime, built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"} 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( first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen 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 False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
) )
assert first == later assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back(): 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 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) result = resolve_date_to_check(False, settings, now=fake_now)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc)) locktime_dt = BalTimestamp("90d").to_date(fake_now)
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp() expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
assert abs(result - expected) < 1 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(): def test_guard_threshold_basic_mode_returns_none():
fake_now = 1_800_000_000.0 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 assert threshold is None
def _guard_locktime(settings, fake_now): def _guard_locktime(settings, fake_now):
"""Reproduce the call-site locktime expression of the guard.""" """Reproduce the call-site locktime expression of the guard."""
from bal.core.plugin_base import BalTimestamp
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now) 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 assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
def test_guard_threshold_relative_fresh_anchor(): def test_guard_threshold_negative_relative_fresh_anchor():
"""A relative threshold must be anchored to the FRESH locktime so the """A negative relative threshold is anchored to the FRESH locktime so the
guard and the settings always share one reference frame. 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).
"""
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
settings = {"locktime": "1y", "threshold": "30d"} settings = {"locktime": "1y", "threshold": "-30d"}
locktime = _guard_locktime(settings, fake_now) locktime = _guard_locktime(settings, fake_now)
threshold = resolve_guard_threshold(False, settings, now=fake_now) threshold = resolve_guard_threshold(False, settings, now=fake_now)
assert threshold is not None assert threshold is not None
assert locktime > threshold # internally consistent: no fire assert locktime > threshold # internally consistent: no fire
assert threshold > fake_now 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 fake_now = 1_800_000_000.0
threshold = fake_now + 200 * 86400 creation = fake_now + 4 * 86400
settings = {"locktime": "1y", "threshold": threshold} settings = {"locktime": "1y", "threshold": "30d"}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold threshold = resolve_guard_threshold(
# "1y" from now is later than the stored absolute threshold: allowed. False, settings, now=fake_now, creation_date=creation
locktime = _guard_locktime(settings, fake_now) )
assert locktime > threshold expected = _midnight(creation) + 30 * 86400
assert abs(threshold - expected) < 1
def test_guard_threshold_missing_returns_none(): 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(): def test_basic_mode_never_expired():
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 check_alive_expired(True, time.time() - 10_000) is False assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
def test_advanced_expired_when_past(): def test_advanced_raises_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True 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(): def test_advanced_raises_when_after_delivery():
assert check_alive_expired(False, time.time() + 10_000) is False now = time.time()
try:
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
except CheckAliveAfterDeliveryError:
pass
else:
raise AssertionError("expected CheckAliveAfterDeliveryError")
def test_advanced_valid_does_not_raise():
now = time.time()
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
# ------------------------------------------------------------------ #
# Will.get_creation_time
# ------------------------------------------------------------------ #
class _FakeWillItem:
def __init__(self, time_value, valid=True):
self.time = time_value
self._valid = valid
def get_status(self, status):
return self._valid if status == "VALID" else False
def test_get_creation_time_min_of_valid_items():
will = {
"a": _FakeWillItem(2000),
"b": _FakeWillItem(1000),
"c": _FakeWillItem(3000),
}
assert Will.get_creation_time(will) == 1000
def test_get_creation_time_ignores_invalid_and_none():
will = {
"a": _FakeWillItem(500, valid=False),
"b": _FakeWillItem(None),
"c": _FakeWillItem(2000),
}
assert Will.get_creation_time(will) == 2000
def test_get_creation_time_empty_returns_none():
assert Will.get_creation_time({}) is None
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -117,6 +117,30 @@ def test_normalize_locktime_raw_single_suffix():
assert s == "130d" 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 # Percentage-or-amount normalisation
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

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

View File

@@ -231,7 +231,7 @@ def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
will = _make_will_with_heirs(will_heirs, lt) will = _make_will_with_heirs(will_heirs, lt)
will["willid_1"].set_status("COMPLETE", True) will["willid_1"].set_status("COMPLETE", True)
check_date = resolve_date_to_check( 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 assert check_date < lt # the anchored window really precedes the delivery
raised = None raised = None

View File

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

View File

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