gui+cli+core: anticipated-delivery warning, task #67 amount checks, CLI tools

GUI - "Building your will" row (owner request):
- when a rebuild anticipates the delivery date by one day, show
  "Anticipated - new delivery date: <date>" on the "Building your will" row
  instead of a plain "Ok";
- the detection compares the will's minimum locktime BEFORE and AFTER the
  rebuild, so it also covers relative recipes ("1y"/"30d") that the previous
  stored-vs-built comparison missed;
- the sign explanation stays on its own "Signing your will" row so it no
  longer overwrites the anticipation warning.

Task #67 - "Checking variables" stuck on "Wait":
- core: Will.check_amounts only requires a fixed amount to be funded when it
  is > 0, and only requires percentages to total 100% when the will actually
  has percentage heirs; fixed-only / empty-wallet wills no longer raise;
- gui: the amount warning is written on the "Checking variables" row itself
  (COLOR_WARNING) instead of being overwritten by the next row; the redundant
  "adjustment of the amounts" sentence was dropped.

CLI - will-executor helpers:
- willexecutors_update accepts promo_code;
- willexecutors_select supports bulk apply with only_valid;
- willexecutors_export supports selected/valid subsets;
- import overwrites existing records with sane defaults;
- raise NoServersForChainError with a clear user-facing message; map
  NotCompleteWill / HeirChange / WillexecutorChange in _user_facing.

Tests and docs:
- tests for the anticipation row, the CLI helpers and the amount checks;
- i18n catalogs refreshed; CHANGELOG section 67; agent task memory updated.
This commit is contained in:
2026-10-15 21:55:03 -04:00
parent c9f3f77a15
commit f3deccb626
14 changed files with 1396 additions and 1010 deletions

View File

@@ -238,24 +238,26 @@ async def willexecutors_update(
wallet,
"willexecutors_update",
url,
address,
base_fee,
info,
promo_code,
rename_to,
address=address,
base_fee=base_fee,
info=info,
promo_code=promo_code,
rename_to=rename_to,
)
@plugin_command("nw", plugin_name)
async def willexecutors_select(
self, url, value=True, wallet=None, plugin=None
self, url=None, value=True, only_valid=False, wallet=None, plugin=None
):
"""Select (or deselect) a will-executor.
"""Select (or deselect) a will-executor, or bulk-apply a selection.
arg:str:url:The will-executor URL.
arg:str:url:The will-executor URL; omit to act on every executor.
arg:bool:value:True to select, False to deselect.
arg:bool:only_valid:When selecting/deselecting all, only change the valid executors (the invalid ones are deselected/left alone).
"""
return _call(plugin, wallet, "willexecutors_select", [url], value)
urls = [url] if url is not None else None
return _call(plugin, wallet, "willexecutors_select", urls, value, only_valid)
@plugin_command("nw", plugin_name)
@@ -298,12 +300,13 @@ async def willexecutors_import(self, path, wallet=None, plugin=None):
@plugin_command("nw", plugin_name)
async def willexecutors_export(self, path, wallet=None, plugin=None):
async def willexecutors_export(self, path, subset=None, wallet=None, plugin=None):
"""Export the will-executors to a JSON file.
arg:str:path:Destination file path.
arg:str:subset:Optional filter: ``selected`` (only selected executors) or ``valid`` (only valid ones); omit to export all.
"""
return _call(plugin, wallet, "willexecutors_export", path)
return _call(plugin, wallet, "willexecutors_export", path, subset)
# --------------------------------------------------------------------------- #

View File

@@ -88,6 +88,27 @@ def _user_facing(e):
return UserFacingException(_("Will-Executor not present"))
if isinstance(e, will_mod.NoWillExecutorNotPresent):
return UserFacingException(_("No backup transaction or will-executor selected"))
if isinstance(e, will_mod.HeirChangeException):
return UserFacingException(_("The heirs changed: a new will must be prepared."))
if isinstance(e, will_mod.WillexecutorChangeException):
return UserFacingException(
_("A will-executor changed: a new will must be prepared.")
)
if isinstance(e, will_mod.NotCompleteWillException):
# ``WillException.__init__`` stores the detail in ``self.msg`` but calls
# ``Exception.__init__(self)`` without it, so ``e.args`` is always empty;
# the default sentinel message is "WillException".
detail = getattr(e, "msg", None)
if detail and detail != "WillException":
return UserFacingException(
_(
"The wallet contains funds that the current will does not "
"cover yet: a new will must be prepared."
)
)
return UserFacingException(
_("The will contains no valid transaction: a new will must be prepared.")
)
if isinstance(e, will_mod.AmountException):
return UserFacingException(
_(
@@ -121,6 +142,21 @@ def _user_facing(e):
"but less than the locktime"
)
)
if isinstance(e, Willexecutors.NoServersForChainError):
if e.url:
return UserFacingException(
_(
"Could not reach the configured welist server.\n\n"
"Server: {url}\n"
"Error: {reason}\n\n"
"Please verify the welist server URL in the plugin settings."
).format(url=e.url, reason=e.reason or "empty response")
)
return UserFacingException(
_("No active will-executor found for the {chain} network.").format(
chain=e.chain
)
)
return e
@@ -352,6 +388,7 @@ class BalController:
self.date_to_check = resolve_date_to_check(
self.plugin.is_basic_mode(), self.will_settings,
built_locktime=_new_locktime,
creation_date=Will.get_creation_time(self.willitems),
)
self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=False, task=False
@@ -472,6 +509,11 @@ class BalController:
self.wallet.dust_threshold(),
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
)
except will_mod.AmountException as e:
# Mirror the GUI (BalWindow.build_inheritance_transaction): an
# amount mismatch is only a WARNING here, the flow continues (the
# rebuild/normalisation below reconciles the shares).
_logger.warning(f"prepare will: check_amounts: {e}")
except Exception as e:
raise _user_facing(e) from e
@@ -949,6 +991,8 @@ class BalController:
heir.append(locktime)
else:
heir.append(self.will_settings["locktime"])
if not self.plugin.ENABLE_MULTIVERSE.get():
heir[3] = self.will_settings["locktime"]
if is_op_return_address(address):
heir[2] = "0"
return Heirs.validate_heir(heir[0], heir[1:])
@@ -1034,7 +1078,7 @@ class BalController:
return self.willexecutors_show(url)
def willexecutors_update(self, url, address=None, base_fee=None, info=None,
rename_to=None):
promo_code=None, rename_to=None):
if url not in self.willexecutors:
raise UserFacingException(_("Will-Executor not found: {}").format(url))
we = self.willexecutors[url]
@@ -1045,6 +1089,8 @@ class BalController:
we["base_fee"] = int(base_fee)
if info is not None:
we["info"] = info.strip() or "New Will Executor"
if promo_code is not None:
we["promo_code"] = promo_code
if rename_to and rename_to != url:
if rename_to in self.willexecutors:
raise UserFacingException(
@@ -1065,17 +1111,38 @@ class BalController:
Willexecutors.save(self.plugin, self.willexecutors)
return {"deleted": deleted}
def willexecutors_select(self, urls=None, select=True):
"""Select/deselect one, many or all will-executors."""
selected = {}
targets = urls if urls is not None else list(self.willexecutors)
def willexecutors_select(self, urls=None, select=True, only_valid=False):
"""Select or deselect will-executors.
Mirrors the GUI's single-toggle and ``set_select_all(select,
only_valid)`` bulk actions. ``urls`` is the set of records to act on
(all of them when omitted); with ``only_valid`` the selection is
restricted by validity: selecting sets the valid ones to True and the
invalid ones to False, while deselecting only clears the invalid ones.
"""
targets = (
list(self.willexecutors)
if urls is None
else [url for url in urls if url in self.willexecutors]
)
for url in targets:
if url not in self.willexecutors:
continue
self.willexecutors[url]["selected"] = bool(select)
selected[url] = bool(select)
we = self.willexecutors[url]
if only_valid:
is_valid = Willexecutors.is_valid(
we,
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
)
if select:
we["selected"] = bool(is_valid)
elif not is_valid:
we["selected"] = False
else:
we["selected"] = bool(select)
Willexecutors.save(self.plugin, self.willexecutors)
return selected
return {
url: bool(self.willexecutors[url].get("selected")) for url in targets
}
def willexecutors_ping(self, urls=None):
"""Ping the (selected) will-executor servers and refresh their info.
@@ -1125,10 +1192,12 @@ class BalController:
result = {}
last_error = None
any_server_reached = False
try:
resp = Willexecutors.send_request(
"get", url, timeout=10, max_retries=1, retry_sleep=1
)
any_server_reached = True
if not isinstance(resp, dict):
last_error = "invalid response format"
_logger.warning(
@@ -1154,12 +1223,17 @@ class BalController:
last_error = str(e)
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
if not result and not basic:
raise UserFacingException(
_("Could not reach the configured welist server.\nServer: {}\nError: {}").format(
url, last_error or "empty response"
if not result:
if not basic:
# Advanced mode: always raise with full details.
raise Willexecutors.NoServersForChainError(
chainname,
url=url,
reason=last_error or "empty response",
)
)
if any_server_reached:
# Basic mode: the server answered but has no data for this chain.
raise Willexecutors.NoServersForChainError(chainname)
return result
def willexecutors_download(self):
@@ -1179,18 +1253,41 @@ class BalController:
raise UserFacingException(
_("Invalid entry {} in will-executors file").format(url)
)
if url not in self.willexecutors:
we = dict(we)
we.setdefault("selected", False)
we.setdefault("status", "New")
we.setdefault("promo_code", None)
self.willexecutors[url] = we
# Mirror the GUI import (``willexecutors_list.update(data)``):
# existing URLs are overwritten with the imported record, missing
# fields get sane defaults.
we = dict(we)
we.setdefault("selected", False)
we.setdefault("status", "New")
we.setdefault("promo_code", None)
self.willexecutors[url] = we
Willexecutors.save(self.plugin, self.willexecutors)
return {"imported": len(data), "total": len(self.willexecutors)}
def willexecutors_export(self, path):
write_json_file(path, self.willexecutors)
return {"exported": len(self.willexecutors), "path": path}
def willexecutors_export(self, path, subset=None):
"""Export the will-executors to ``path``.
``subset`` mirrors the GUI export filter: ``"selected"`` writes only
the selected records, ``"valid"`` only the valid ones, anything else
(or ``None``) writes every record.
"""
data = self.willexecutors
if subset == "selected":
data = {
url: we for url, we in data.items() if Willexecutors.is_selected(we)
}
elif subset == "valid":
data = {
url: we
for url, we in data.items()
if Willexecutors.is_valid(
we,
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
)
}
write_json_file(path, data)
return {"exported": len(data), "path": path}
# ------------------------------------------------------------------ #
# Settings
@@ -1290,11 +1387,3 @@ class BalController:
)
return value
return str(raw)
def get_decimal_point(plugin):
"""Standalone helper returning Electrum's configured BTC decimal point."""
try:
return plugin.config.BTC_AMOUNTS_DECIMAL_POINT
except AttributeError:
return 8

View File

@@ -823,11 +823,19 @@ class Will:
for utxo in all_utxos:
wallet_balance += utxo.value_sats()
if fixed_amount >= wallet_balance:
if fixed_amount > 0 and fixed_amount >= wallet_balance:
# Only a REAL fixed amount needs to be funded: with no fixed heirs
# (all percentages) fixed_amount is 0 and ``0 >= 0`` would wrongly
# flag an empty/fully-committed wallet as an amount error.
raise FixedAmountException(
f"Fixed amount({fixed_amount}) >= {wallet_balance}"
)
if perc_amount != 100:
if perc_heirs and perc_amount != 100:
# Percentages only have to add up to 100% when the will actually
# uses percentage heirs. A will made ONLY of fixed-amount heirs has
# no percentages, so perc_amount is 0 - which used to raise here and
# left "Checking variables" stuck on "Wait" for every fixed-only
# will (the build normalises the fixed shares anyway).
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items():
@@ -835,7 +843,7 @@ class Will:
if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance:
if fixed_amount > 0 and fixed_amount >= temp_balance:
raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
)

View File

@@ -691,11 +691,22 @@ class BalBuildWillDialog(BalDialog):
# Manual next-steps hint (Sign / Broadcast) shown to the user after the
# dialog finishes; None when nothing is left to do.
self._next_steps_hint = None
# Set to True by _sync_locktime_to_built_txs when the delivery date was
# automatically anticipated during a rebuild. Used to explain to the
# user WHY signing is being requested (otherwise the sign prompt appears
# without any reason, as the owner reported).
# Set to True by _build_success_report when the rebuild moved the
# delivery date one day earlier than the previous will. Used both to
# show the "Anticipated" warning on the "Building your will" row and to
# explain WHY signing is being requested (otherwise the sign prompt
# appears without any reason, as the owner reported).
self._date_was_anticipated = False
# The anticipated locktime (UNIX timestamp) captured by
# _build_success_report, formatted into the "Anticipated - new
# delivery date: ..." warning. None when the last build did not
# anticipate the delivery.
self._anticipated_locktime = None
# The will's minimum locktime captured BEFORE the rebuild (see
# task_phase1). _build_success_report compares it with the rebuilt one
# to tell whether the delivery was anticipated by one day. None when no
# build ran yet.
self._previous_min_locktime = None
# Set to True right after we broadcast an automatic invalidation
# transaction (the "postpone" path). On the very next phase-1 re-check
# Electrum may not have seen the invalidation tx yet, so it would still
@@ -789,18 +800,32 @@ class BalBuildWillDialog(BalDialog):
_logger.debug("variables ok")
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
except AmountException:
self.msg_set_checking(
self.msg_warning(
_(
"In the inheritance process, "
"the entire wallet will always be fully emptied. \n"
"Your settings require an adjustment of the amounts"
)
)
# The amount check is a WARNING, not a hard failure: the build can
# still normalise the shares (see BalWindow.build_inheritance_
# transaction, which shows the same warning and continues). Report
# it on the "Checking variables" row itself, with the warning colour.
# Previously this was routed to msg_set_checking(), i.e. the
# "Checking your will" row, and then immediately overwritten by the
# msg_set_checking() call below - so the warning was lost AND the
# "Checking variables" row stayed stuck on its initial "Wait".
self.msg_set_status(
_("Checking variables"),
varrow,
_(
"In the inheritance process, "
"the entire wallet will always be fully emptied."
),
self.COLOR_WARNING,
)
except WillExecutorFeeTooHighException as e:
self.msg_set_checking(
self.msg_warning(_("Will-executor fee too high: {}").format(e))
# Same reasoning as the AmountException branch above: show the
# warning on the "Checking variables" row so it is neither lost nor
# left stuck on "Wait".
self.msg_set_status(
_("Checking variables"),
varrow,
_("Will-executor fee too high: {}").format(e),
self.COLOR_WARNING,
)
self.msg_set_checking()
@@ -897,6 +922,18 @@ class BalBuildWillDialog(BalDialog):
if have_to_build:
self.msg_set_building()
# Capture the delivery date of the CURRENT (previous) will BEFORE
# the rebuild overwrites it, so _build_success_report can tell
# whether the rebuild ANTICIPATED the delivery by one day. The core
# anticipates by moving the rebuilt txs one day EARLIER than the
# previous will that spends the same coins (Will.check_anticipate /
# Will.search_anticipate), so comparing the two is the only reliable
# signal: a relative recipe ("1y"/"30d") is anchored to the built tx,
# which is why comparing the built locktime with the STORED value
# (see _sync_locktime_to_built_txs) misses the anticipation.
self._previous_min_locktime = Will.get_min_locktime(
self.bal_window.willitems
)
try:
txs = self.bal_window.build_will()
if not txs:
@@ -1085,7 +1122,40 @@ class BalBuildWillDialog(BalDialog):
# auto-anticipated) transactions, otherwise the next Check would wrongly
# ask to invalidate. See _sync_locktime_to_built_txs for the full why.
self._sync_locktime_to_built_txs()
self.msg_set_building(self.msg_ok())
# Detect an automatic anticipation by comparing the will's delivery date
# BEFORE the rebuild (captured in task_phase1) with the one AFTER it:
# the core anticipates by moving the rebuilt txs one day earlier than
# the previous will that spends the same coins (Will.check_anticipate).
# This is format-agnostic: it works whether the stored recipe is an
# absolute timestamp or a relative "1y"/"30d" recipe (anchoring makes a
# relative recipe unusable for a stored-vs-built comparison).
new_min_locktime = Will.get_min_locktime(self.bal_window.willitems)
previous_min_locktime = self._previous_min_locktime
self._date_was_anticipated = (
previous_min_locktime is not None
and new_min_locktime is not None
and int(new_min_locktime) < int(previous_min_locktime)
)
self._anticipated_locktime = (
int(new_min_locktime) if self._date_was_anticipated else None
)
# Owner request: when the rebuild anticipated the delivery, do NOT show
# a plain "Ok" on the "Building your will" row - show a warning that
# names the anticipation and the NEW delivery date, so the user is told
# the inheritance will be delivered one day earlier. The sign step then
# explains WHY signing is requested on its OWN row ("Signing your
# will"), leaving this warning visible.
if self._anticipated_locktime is not None:
new_date = BalTimestamp._safe_fromtimestamp(
self._anticipated_locktime
).strftime("%Y-%m-%d")
self.msg_set_building(
self.msg_alert(
_("Anticipated - new delivery date: {}").format(new_date)
)
)
else:
self.msg_set_building(self.msg_ok())
# List EACH heir on its OWN line, green + bold (owner request: revert the
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
# now that the report area scrolls (allegato1) there is no need to cram
@@ -1177,10 +1247,10 @@ class BalBuildWillDialog(BalDialog):
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
# NOTE: this only SYNCS the stored date. Whether the rebuild
# ANTICIPATED the delivery is decided in _build_success_report
# by comparing the previous and the rebuilt locktimes (the only
# format-agnostic signal, see there).
# update_setting_widgets stores the value, persists it and
# refreshes the date widgets in all panels/wizard (the .ics
# calendar too).
@@ -1624,19 +1694,18 @@ class BalBuildWillDialog(BalDialog):
# If the will was rebuilt with an automatically anticipated
# delivery date, explain WHY we are now asking to sign:
# otherwise the sign prompt appears with no reason (owner
# feedback). The note is shown on the "Building your will"
# row (orange warning) before the modal password prompt
# opens, so the user can read it.
# feedback). This note goes on its OWN "Signing your will" row
# so it does NOT overwrite the "Anticipated - new delivery
# date" warning already shown on the "Building your will" row,
# and it is readable before the modal password prompt opens.
if self._date_was_anticipated:
self.msg_set_building(
"<b>{}</b>".format(
_(
"The delivery date was automatically moved "
"one day earlier so the updated will can "
"correctly replace the previous one.\n"
"Please sign (and broadcast) to confirm "
"the change."
)
self.msg_set_signing(
_(
"The delivery date was automatically moved "
"one day earlier so the updated will can "
"correctly replace the previous one.\n"
"Please sign (and broadcast) to confirm "
"the change."
)
)
password = self.bal_window.get_wallet_password(

View File

@@ -835,11 +835,11 @@ class BalWindow:
self.window.wallet.dust_threshold(),
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
)
except AmountException as e:
except AmountException:
self.show_warning(
_(
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}"
).format(e)
"In the inheritance process, the entire wallet will always be fully emptied."
)
)
except WillExecutorFeeTooHighException as e:
self.show_error(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff