core+gui+cli: anticipation/rebuild dates, preserve relative settings

Fixes around the delivery/build/check date handling (karen7 regtest):

- build_will (GUI + CLI) re-anchors date_to_check to the CURRENT heirs'
  earliest future delivery before building, so an anticipated rebuild is no
  longer blocked by the stale old-will anchor (NO_FUTURE_DATE). The checks of
  the existing will keep their anchored date_to_check.
- _sync_locktime_to_built_txs now PRESERVES RELATIVE locktime/threshold
  recipes ("2y"/"150d") in WILL_SETTINGS instead of freezing them to absolute
  timestamps: the anchored comparisons (resolve_locktime_against_tx and
  resolve_date_to_check with built_locktime) already prevent the daily
  invalidate prompt. Absolute values still sync on a genuine automatic
  anticipation.
- Will.remove_stale_wallet_history drops stale wallet-LOCAL will placeholders
  before every (re)build in GUI and CLI so their coins are available again.
- check_willexecutors_and_heirs raises HeirNotFoundException outside the
  count_heirs gate: a shortened relative recipe on a signed will now triggers
  a plain rebuild ("no heirs" only when there really are none).
- is_locktime_below_threshold compares the settings on one reference frame
  (resolve_guard_threshold), no longer against the built-will anchor.

Tests: purge unit + call-site, no-heirs, guard, anticipated-rebuild
end-to-end (GUI) + CLI mirror + fixed_percent_lists_amount unit,
relative-preserve sync; conftest restores electrum.constants.net after each
test (cross-file pollution guard).
This commit is contained in:
2026-09-10 17:07:22 -04:00
parent d5f30e89b9
commit 7c9bbfce58
16 changed files with 724 additions and 522 deletions

View File

@@ -1135,13 +1135,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.
RELATIVE recipes ("30d"/"1y") are PRESERVED: they are resolved against
the built will's frozen locktime on every check (via
``Util.resolve_locktime_against_tx`` for the postpone detection and
``resolve_date_to_check(..., built_locktime=...)`` for the reference
timestamp), so they no longer drift away from the built transactions
and never trigger the daily invalidate prompt. Freezing them to an
absolute timestamp here would silently erase the user's relative
choice from WILL_SETTINGS.
We route the update through BalWindow.update_setting_widgets, which is
the single place that (1) stores the value in WILL_SETTINGS, (2)
@@ -1156,72 +1157,46 @@ class BalBuildWillDialog(BalDialog):
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.)
# A RELATIVE stored value ("30d"/"1y") is PRESERVED: it is resolved
# against the built transactions on every check (the post-build
# `resolve_date_to_check` anchoring and `resolve_locktime_against_tx`
# in the postpone detection), so it no longer drifts and must not be
# frozen to an absolute timestamp here. Only an ABSOLUTE stored value
# is compared with the built transactions (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(stored_locktime))
except Exception:
# If the stored value cannot be parsed, fall back to syncing.
current = None
# 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")
):
if not is_relative_locktime:
# Current stored delivery date, as a comparable UNIX timestamp.
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:
current = int(Util.parse_locktime_string(stored_locktime))
except Exception:
# If the stored value cannot be parsed, fall back to syncing.
current = None
# A genuine user-chosen POSTPONE (a later absolute date) is never
# overwritten; a genuine automatic ANTICIPATION (built earlier
# than stored) is synced to the built transactions.
was_anticipation = current is not None and min_locktime < current
if was_anticipation:
_logger.debug(
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
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).
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(
real_threshold, "threshold", update_all=True
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.
def on_accept(self):
try: