core: anchor relative locktime/threshold recipes to the built tx locktime so an unchanged will no longer reads as expired/postponed; stop the daily invalidate prompt (karen7)

This commit is contained in:
2026-08-09 12:08:25 -04:00
parent 95c23a4b21
commit 2221389f44
9 changed files with 755 additions and 28 deletions

View File

@@ -29,7 +29,10 @@ class CheckAliveError(Exception):
def resolve_date_to_check(
is_basic_mode: bool, will_settings: Any, now: float | None = None
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
built_locktime: float | int | None = None,
) -> float:
"""Return the reference timestamp for every will-validity check.
@@ -41,19 +44,56 @@ def resolve_date_to_check(
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is.
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
will_settings: the per-wallet settings dict (``"threshold"`` key).
will_settings: the per-wallet settings dict (``"threshold"`` and
``"locktime"`` keys).
now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet).
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now().timestamp())
return BalTimestamp(will_settings["threshold"]).to_timestamp()
threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
# the settings widget resolves it as real_threshold = locktime - N days.
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
# date_to_check into a moving target that disagrees with the fixed
# locktime of the built transactions (and with the date shown in the UI),
# which can wrongly mark the will as expired/postponed. Resolve it
# against the delivery date instead.
if threshold.unit is not None:
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
# No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
def check_alive_expired(

View File

@@ -131,6 +131,72 @@ class Util:
+ days * 60 * 60 * 24
)
@staticmethod
def _relative_days(value):
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
Returns ``None`` when the value is not a relative recipe (an absolute
timestamp, a plain number, or garbage).
"""
s = str(value)
if s and s[-1] in "yYdD":
try:
n = int(s[:-1])
except ValueError:
return None
return n * 365 if s[-1] in "yY" else n
return None
@staticmethod
def resolve_locktime_against_tx(current, built, tx_locktime):
"""Resolve a locktime recipe against the moment the signed tx was built.
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
target: parsing it against *now* on every check drifts it one day per
day away from the fixed locktime frozen inside the signed Bitcoin
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
plugin asks to invalidate it every day (reported bug). This resolves
the current recipe against the build moment instead, recovered from the
signed transaction's locktime and the recipe that was actually frozen
at build time (``built``, the value stored in the will item):
build_moment = tx_locktime - duration(built)
expected = build_moment + duration(current)
An unchanged recipe therefore resolves to exactly ``tx_locktime``
(coherent), a lengthened one resolves later (postpone) and a shortened
one earlier (anticipate).
Args:
current: the current locktime recipe (relative or absolute).
built: the recipe frozen at build time (stored in the will item).
tx_locktime: the absolute locktime frozen inside the signed tx.
Returns:
int: the resolved absolute locktime (UNIX timestamp).
"""
current_days = Util._relative_days(current)
built_days = Util._relative_days(built)
if current_days is None:
# Absolute current date: compare directly against the frozen tx.
try:
return int(current)
except Exception:
return Util.parse_locktime_string(current)
if built_days is None or not tx_locktime:
# The stored recipe was absolute (a fixed date) or the tx has no
# usable locktime: there is no relative anchor to recover the build
# moment, so fall back to the legacy forward-from-now resolution.
return Util.parse_locktime_string(current)
try:
base = datetime.fromtimestamp(int(tx_locktime)).replace(
hour=0, minute=0, second=0, microsecond=0
)
build_moment = base - timedelta(days=built_days)
return int((build_moment + timedelta(days=current_days)).timestamp())
except Exception:
return Util.parse_locktime_string(current)
# ------------------------------------------------------------------ #
# Amount helpers
# ------------------------------------------------------------------ #

View File

@@ -1144,8 +1144,6 @@ class Will:
if heir := heirs.get(wheir, None):
if heir[0] == their[0] and heir[1] == their[1]:
# The requested (possibly new) locktime for this heir.
new_locktime = Util.parse_locktime_string(heir[2])
# IMPORTANT: compare against the locktime that is
# actually frozen inside the already-signed Bitcoin
# transaction (w.tx.locktime), NOT against their[2].
@@ -1156,6 +1154,16 @@ class Will:
# undetected. w.tx.locktime is immutable once signed
# and is exactly what the will-executors hold.
tx_locktime = int(w.tx.locktime)
# The requested (possibly new) locktime for this heir.
# A RELATIVE recipe ("1y"/"30d") is resolved against
# the moment the signed tx was built, NOT against now:
# re-parsing it from "now" drifts it one day per day
# away from the frozen tx locktime, so an UNCHANGED
# will would be read as a POSTPONE and the plugin
# would ask to invalidate it every day.
new_locktime = Util.resolve_locktime_against_tx(
heir[2], their[2], tx_locktime
)
if new_locktime == tx_locktime:
# Unchanged: this heir is still coherent.
count = heirs_found.get(wheir, 0)

View File

@@ -1012,6 +1012,14 @@ class BalBuildWillDialog(BalDialog):
desired behaviour, and that the date shown in the panel/wizard must
reflect this anticipated date (so the calendar .ics also uses it).
RELATIVE dates are additionally normalised here: a relative value
("30d"/"1y") is re-parsed against "now" on every check, so it drifts
away from the fixed transaction locktime and the postpone check would
wrongly ask to invalidate the will every day. The stored locktime is
therefore frozen to the built transactions' absolute locktime, and a
relative threshold is frozen to its "N days before the delivery"
absolute value.
We route the update through BalWindow.update_setting_widgets, which is
the single place that (1) stores the value in WILL_SETTINGS, (2)
persists it to Electrum's database and (3) refreshes the date widgets in
@@ -1024,31 +1032,73 @@ class BalBuildWillDialog(BalDialog):
if min_locktime is None:
return
min_locktime = int(min_locktime)
stored_locktime = self.bal_window.will_settings["locktime"]
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
# against "now" on every check, so it drifts one day per day away from
# the fixed tx locktime and the postpone check would ALWAYS see a
# postpone -> the plugin asks to invalidate the will every day. It must
# therefore be normalised here to the frozen absolute locktime of the
# built transactions, even when it happens to parse to the same moment
# today. (Only an absolute stored value is comparable, see below.)
is_relative_locktime = (
isinstance(stored_locktime, str)
and stored_locktime[-1:].lower() in ("d", "y")
)
# Current stored delivery date, as a comparable UNIX timestamp.
try:
current = int(
Util.parse_locktime_string(
self.bal_window.will_settings["locktime"]
)
)
current = int(Util.parse_locktime_string(stored_locktime))
except Exception:
# If the stored value cannot be parsed, fall back to syncing.
current = None
# Only anticipate (move the date EARLIER); never overwrite a postpone.
if current is not None and min_locktime >= current:
return
_logger.debug(
f"sync delivery date to anticipated tx locktime: "
f"{current} -> {min_locktime}"
)
# Remember that we anticipated the date, so the later sign prompt can
# explain WHY signing is needed (see on_success_phase1).
self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and refreshes the
# date widgets in all panels/wizard (so the .ics calendar uses it too).
self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True
)
# A genuine user-chosen POSTPONE (a later absolute date) is never
# overwritten; anything else is synced to the built transactions.
was_anticipation = current is not None and min_locktime < current
if not is_relative_locktime and current is not None and not was_anticipation:
pass
else:
_logger.debug(
f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}"
)
# Remember that we anticipated the date, so the later sign prompt can
# explain WHY signing is needed (see on_success_phase1). A pure
# relative->absolute normalisation is NOT an anticipation.
if was_anticipation:
self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and refreshes
# the date widgets in all panels/wizard (the .ics calendar too).
self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True
)
# Same moving-target problem for a relative "Check Alive" threshold:
# it means "N days BEFORE the delivery" (the settings widget resolves it
# as real_threshold = locktime - N days), so it is normalised to that
# absolute date, referenced against the now-absolute stored locktime.
threshold_raw = self.bal_window.will_settings.get("threshold")
if (
isinstance(threshold_raw, str)
and threshold_raw[-1:].lower() in ("d", "y")
):
try:
locktime_ts = int(
Util.parse_locktime_string(
self.bal_window.will_settings["locktime"]
)
)
real_threshold = int(
BalTimestamp(threshold_raw)
.to_date(locktime_ts, reverse=True)
.timestamp()
)
except Exception as e:
_logger.error(f"sync threshold to absolute failed: {e}")
else:
_logger.debug(
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
)
self.bal_window.update_setting_widgets(
real_threshold, "threshold", update_all=True
)
def on_accept(self):
try:

View File

@@ -640,7 +640,9 @@ class BalWindow:
# exactly as before. The policy itself lives in
# ``bal.core.checkalive.resolve_date_to_check``.
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
self.bal_plugin.is_basic_mode(),
self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems),
)
# found = False
# NOTE: block-height tracking removed (A1) - locktimes are always