Compare commits
3 Commits
70196fc3cd
...
1b601609f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
1b601609f0
|
|||
|
b1f78700d1
|
|||
|
beab22ce23
|
67
CHANGELOG.md
67
CHANGELOG.md
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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(
|
||||
_(
|
||||
"Check Alive must be after today and before the delivery date, please update it"
|
||||
)
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -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 ``"<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(
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -498,22 +498,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -754,6 +759,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(
|
||||
@@ -1201,10 +1213,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:
|
||||
@@ -1553,6 +1566,18 @@ 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(
|
||||
_(
|
||||
"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))
|
||||
password = None
|
||||
if self.have_to_sign is None:
|
||||
@@ -1844,9 +1869,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()
|
||||
|
||||
@@ -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,
|
||||
@@ -217,6 +218,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)
|
||||
@@ -227,7 +232,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()
|
||||
@@ -482,9 +489,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
|
||||
@@ -510,7 +517,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"
|
||||
@@ -520,16 +527,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)
|
||||
@@ -643,6 +656,11 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
"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>"
|
||||
" - 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 "
|
||||
"<b>Local time</b> (not UTC time).<br>"
|
||||
)
|
||||
@@ -650,6 +668,8 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
#label_text = "Check Alive"
|
||||
tooltip_text = N_("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:
|
||||
@@ -1135,18 +1155,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:
|
||||
|
||||
@@ -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 (
|
||||
@@ -492,6 +494,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 = []
|
||||
@@ -740,6 +743,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
|
||||
@@ -750,19 +754,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())
|
||||
|
||||
@@ -787,7 +792,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
|
||||
@@ -837,6 +844,13 @@ class BalWindow:
|
||||
_("Will-executor fee too high: {}").format(e)
|
||||
)
|
||||
return
|
||||
except CheckAliveAfterDeliveryError:
|
||||
self.show_error(
|
||||
_(
|
||||
"Check Alive must be after today and before the delivery date, please update it"
|
||||
)
|
||||
)
|
||||
return
|
||||
except CheckAliveError:
|
||||
self.show_error(
|
||||
_(
|
||||
@@ -1298,6 +1312,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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user