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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user