Merge wip/checkalive-relative into main

Signed relative Check Alive (negative = before delivery, positive = after
creation). Resolves CHANGELOG.md (new section 61).
This commit is contained in:
2026-10-03 11:37:03 -04:00
19 changed files with 774 additions and 225 deletions

View File

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

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
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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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,19 @@ class BalBuildWillDialog(BalDialog):
self._add_no_heirs_buttons()
return
if self.have_to_sign == "check_alive_before_delivery":
# The Check Alive date is on/after the delivery (a configuration
# error, not an expired will): do NOT invalidate, just tell the
# user and let them close the wizard.
self.bal_window.show_error(
_(
"CheckAlive must be after today and before the delivery "
"date please update it"
)
)
self._add_close_button()
return
_logger.debug("have to sign {}".format(self.have_to_sign))
password = None
if self.have_to_sign is None:
@@ -1844,9 +1870,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()

View File

@@ -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:

View File

@@ -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(
_(
"CheckAlive 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()