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:
@@ -43,6 +43,7 @@ from ..core.checkalive import (
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from ..core.heirs import Heirs, is_op_return_address
|
||||
from ..core.plugin_base import BalConfig, BalPlugin
|
||||
@@ -314,6 +315,28 @@ class BalController:
|
||||
executor.
|
||||
"""
|
||||
will = {}
|
||||
# Drop stale wallet-LOCAL will placeholders (mirror of the GUI
|
||||
# build_will) so their coins are available to this build.
|
||||
Will.remove_stale_wallet_history(
|
||||
self.wallet, self.plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
# A (re)build may have anticipated the delivery (shorter heir recipes)
|
||||
# while ``date_to_check`` is still anchored to the OLD built will.
|
||||
# Recompute it for the will being built (earliest future delivery among
|
||||
# the CURRENT heirs), mirroring ``BalWindow.build_will``, so the
|
||||
# anticipated dates pass the build filter.
|
||||
_new_locktime = min(
|
||||
(
|
||||
Util.parse_locktime_string(h[2])
|
||||
for h in self.heirs.values()
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
if _new_locktime:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.plugin.is_basic_mode(), self.will_settings,
|
||||
built_locktime=_new_locktime,
|
||||
)
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.plugin, update=False, task=False
|
||||
)
|
||||
@@ -434,7 +457,13 @@ class BalController:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
if locktime < date_to_check:
|
||||
threshold_ts = resolve_guard_threshold(
|
||||
self.plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
if threshold_ts is not None:
|
||||
if locktime < threshold_ts:
|
||||
raise UserFacingException(_("locktime is lower than threshold"))
|
||||
elif locktime < date_to_check:
|
||||
raise UserFacingException(_("locktime is lower than threshold"))
|
||||
|
||||
if not self.no_willexecutor:
|
||||
|
||||
@@ -96,6 +96,54 @@ def resolve_date_to_check(
|
||||
return threshold.to_timestamp()
|
||||
|
||||
|
||||
def resolve_guard_threshold(
|
||||
is_basic_mode: bool,
|
||||
will_settings: Any,
|
||||
now: float | None = None,
|
||||
) -> float | None:
|
||||
"""Resolve the "locktime is lower than threshold" guard's reference.
|
||||
|
||||
The guard compares the stored settings on ONE reference frame: the
|
||||
delivery (``locktime``, kept as at the call site) against this 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.
|
||||
|
||||
* 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.
|
||||
|
||||
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
|
||||
missing stored value).
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return None
|
||||
threshold_raw = will_settings.get("threshold")
|
||||
if threshold_raw is None:
|
||||
return None
|
||||
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
|
||||
)
|
||||
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
|
||||
) -> bool:
|
||||
|
||||
@@ -28,6 +28,7 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
@@ -847,6 +848,48 @@ class Will:
|
||||
except Exception as e:
|
||||
_logger.error(f"save_valid_transactions_to_history failed: {e}")
|
||||
|
||||
@staticmethod
|
||||
def remove_stale_wallet_history(wallet, history_label):
|
||||
"""Delete wallet-LOCAL will transactions saved under ``history_label``.
|
||||
|
||||
``save_valid_transactions_to_history`` stores the not-yet-signed
|
||||
inheritance txs into the wallet's local history; those local
|
||||
placeholders nominally spend the coins they reference. When the will is
|
||||
REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the
|
||||
stale placeholders must be removed so the coins become available again
|
||||
to the new build (see ``Util.get_available_utxos``). Only
|
||||
wallet-local/future (non-broadcast) txs whose label matches the history
|
||||
label template are removed; confirmed/broadcast history is never
|
||||
touched. Returns the txids that were removed.
|
||||
"""
|
||||
if not wallet or not getattr(wallet, "adb", None):
|
||||
return []
|
||||
removed = []
|
||||
for txid, label in Will._wallet_labels(wallet):
|
||||
if not label or not Util._label_matches_history(label, history_label):
|
||||
continue
|
||||
try:
|
||||
height = int(wallet.adb.get_tx_height(txid).height())
|
||||
except Exception:
|
||||
continue
|
||||
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
||||
continue
|
||||
try:
|
||||
wallet.adb.remove_transaction(txid)
|
||||
removed.append(txid)
|
||||
except Exception as e:
|
||||
_logger.error(f"remove from history failed for {txid}: {e}")
|
||||
continue
|
||||
try:
|
||||
wallet.set_label(txid, None)
|
||||
except Exception as e:
|
||||
_logger.error(f"set_label failed for {txid}: {e}")
|
||||
try:
|
||||
wallet.save_db()
|
||||
except Exception as e:
|
||||
_logger.error(f"save_db failed after history purge: {e}")
|
||||
return removed
|
||||
|
||||
@staticmethod
|
||||
def _add_transaction_to_history(wallet, tx, txid):
|
||||
"""Store *tx* into the wallet's local history via ``adb``.
|
||||
@@ -1213,9 +1256,9 @@ class Will:
|
||||
|
||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||
count_heirs += 1
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if not count_heirs:
|
||||
raise NoHeirsException("there are not valid heirs")
|
||||
if self_willexecutor and no_willexecutor == 0:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,6 +23,7 @@ from ...core.checkalive import (
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from .common import (
|
||||
OP_RETURN_PREFIX,
|
||||
@@ -466,6 +467,31 @@ class BalWindow:
|
||||
|
||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||
_logger.debug("building will...")
|
||||
# Drop stale wallet-LOCAL will placeholders saved by previous prepares
|
||||
# so their coins are available to this build (see remove_stale...).
|
||||
Will.remove_stale_wallet_history(
|
||||
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
# A (re)build may have anticipated the delivery (shorter heir recipes)
|
||||
# while ``date_to_check`` is still anchored to the OLD built will. Using
|
||||
# that stale anchor as the build filter would block every future
|
||||
# delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will
|
||||
# that is being built: its locktime is the earliest future delivery
|
||||
# among the CURRENT heirs. The checks of the EXISTING will keep their
|
||||
# anchored ``date_to_check`` (set in init_class_variables).
|
||||
_new_locktime = min(
|
||||
(
|
||||
Util.parse_locktime_string(h[2])
|
||||
for h in self.heirs.values()
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
if _new_locktime:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.bal_plugin.is_basic_mode(),
|
||||
self.will_settings,
|
||||
built_locktime=_new_locktime,
|
||||
)
|
||||
will = {}
|
||||
# willtodelete = []
|
||||
# willtoappend = {}
|
||||
@@ -745,6 +771,27 @@ class BalWindow:
|
||||
|
||||
raise e
|
||||
|
||||
def is_locktime_below_threshold(self) -> bool:
|
||||
"""True when the stored settings make the delivery earlier than the
|
||||
Check Alive threshold (the "locktime is lower than threshold" guard).
|
||||
|
||||
Compares the delivery against the settings-derived threshold on the
|
||||
SAME reference frame (see ``resolve_guard_threshold``), never against
|
||||
the built-will-anchored ``date_to_check``: anchoring the guard to an
|
||||
old, longer built will would wrongly fire right after the delivery was
|
||||
shortened. The anchored reference still governs the validity and
|
||||
expiry checks, which is where ``date_to_check`` belongs.
|
||||
In BASIC mode there is no threshold, so the locktime is checked against
|
||||
``date_to_check`` (= now) exactly as before.
|
||||
"""
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
threshold_ts = resolve_guard_threshold(
|
||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
if threshold_ts is not None:
|
||||
return locktime < threshold_ts
|
||||
return self.date_to_check is not None and locktime < self.date_to_check
|
||||
|
||||
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
||||
try:
|
||||
_logger.info(
|
||||
@@ -757,6 +804,11 @@ class BalWindow:
|
||||
if not self.heirs:
|
||||
_logger.warning("not heirs {}".format(self.heirs))
|
||||
return
|
||||
# Free the coins locked by stale wallet-LOCAL will placeholders
|
||||
# BEFORE the amount/UTXO checks below (Step 1) see them.
|
||||
Will.remove_stale_wallet_history(
|
||||
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
try:
|
||||
self.init_class_variables()
|
||||
Will.check_amounts(
|
||||
@@ -791,8 +843,7 @@ class BalWindow:
|
||||
)
|
||||
)
|
||||
return
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
if locktime < self.date_to_check:
|
||||
if self.is_locktime_below_threshold():
|
||||
self.show_error(_("locktime is lower than threshold"))
|
||||
return
|
||||
if not self.no_willexecutor:
|
||||
|
||||
Reference in New Issue
Block a user