i18n review fixes (#63) and invalidation/Check fixes (#64-#67)

#63: Truman's i18n review (release gate runs i18n/CLI tests, Babel check, ruff known-first-party, no translated text stored in the wallet). #64: invalidation pays the current network fee (2-block estimate). #65: Check KeyError on a stale father link. #66: invalidation cascade with list-shaped children. #67: deselected will-executor (delete allowed if never received; broadcast skips it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 11:20:04 +02:00
parent 827a1d1e54
commit 2bb9fe812c
14 changed files with 1089 additions and 25 deletions

View File

@@ -385,7 +385,9 @@ class BalController:
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = _("New")
# Stored history: always English (translated only for display by
# format_status_history); never _() here.
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -765,7 +767,11 @@ class BalController:
if not willitems:
raise UserFacingException(_("No transaction matches the given txids"))
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
# Skip executors the user deselected after building the will (the
# controller's list is read from the config for every command).
willexecutors = Willexecutors.get_willexecutor_transactions(
willitems, force=force, current_willexecutors=self.willexecutors
)
if not willexecutors:
return {}

View File

@@ -26,6 +26,7 @@ The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed.
"""
import math
from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
@@ -50,6 +51,13 @@ from .willexecutors import Willexecutors
MIN_LOCKTIME = 1
MIN_BLOCK = 1
# Confirmation target (in blocks) for the on-chain INVALIDATION transaction.
# WHY: the invalidation is sent TODAY (it spends the will's coins so the old
# pre-signed inheritance txs become unusable), so it must pay the CURRENT
# network fee for a quick confirmation - not ``baltx_fees``, which is a
# deliberately high rate reserved for inheritance txs that may be broadcast
# years from now. 2 blocks is Electrum's own default target (owner decision).
INVALIDATION_FEE_ETA_BLOCKS = 2
_logger = get_logger(__name__)
@@ -68,6 +76,22 @@ class Will:
# build a tree with parent transactions
@staticmethod
def add_willtree(will):
"""(Re)build the parent/child links between the will transactions.
Every item gets ``children`` (the will txs spending its outputs) and,
when it spends the output of another will tx, a ``father``.
WHY the reset below: ``father`` is persisted in the wallet (the
``"Father"`` field of ``WillItem.to_dict``) and was only ever set,
never cleared. When the father tx was later removed from the will
(rebuild, merge, replacement) the child kept pointing to a txid that
no longer exists, and ``check_invalidated`` crashed with a
``KeyError`` on that txid (reported on a real wallet: the Check
wizard and the window refresh both failed). The tree is therefore
always recomputed from scratch from the transactions themselves.
"""
for willid in will:
will[willid].father = None
for willid in will:
will[willid].children = Will.get_children(will, willid)
for child in will[willid].children:
@@ -438,9 +462,68 @@ class Will:
out[inp.prevout.to_str()] = inp
return out
@staticmethod
def get_invalidation_fee_rate(wallet, fallback_fee_per_byte):
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
``fee_rate`` is in sat/vbyte. ``source`` is ``"network"`` when the rate
comes from Electrum's live fee estimate, ``"fallback"`` otherwise.
WHY: the invalidation is broadcast NOW, so it pays the current network
fee for a confirmation within ``INVALIDATION_FEE_ETA_BLOCKS`` blocks.
The will's ``baltx_fees`` is kept only as a fallback, used when no
estimate is available (offline wallet, estimates not received yet,
server error), which is exactly the behaviour of previous versions.
The estimate is read from ``network.fee_estimates.eta_target_to_fee``
(Electrum 4.7.2 / 4.8.x), which returns sat/kvbyte and already clamps
the value between Electrum's conservative minimum relay fee (1
sat/vbyte) and its maximum dynamic fee. This method never raises.
Args:
wallet: The Electrum wallet (its ``network`` may be ``None``).
fallback_fee_per_byte: Rate (sat/vbyte) used when there is no
usable estimate - normally the will's ``baltx_fees``.
"""
network = getattr(wallet, "network", None) if wallet else None
fee_per_kb = None
if network is not None:
try:
fee_per_kb = network.fee_estimates.eta_target_to_fee(
INVALIDATION_FEE_ETA_BLOCKS
)
except Exception as e:
_logger.warning(f"invalidation fee: no network estimate ({e!r})")
fee_per_kb = None
# Accept only a real positive integer (Electrum's type for sat/kvbyte);
# ``bool`` is excluded explicitly because it is a subclass of ``int``.
if (
isinstance(fee_per_kb, int)
and not isinstance(fee_per_kb, bool)
and fee_per_kb > 0
):
return fee_per_kb / 1000, "network"
return fallback_fee_per_byte, "fallback"
@staticmethod
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
"""Build the (unsigned) on-chain invalidation transaction.
The transaction spends every coin used by the valid will items back to
one of the wallet's own change addresses, so the pre-signed
inheritance transactions held by the will-executors become unusable.
Fee: the CURRENT network fee for a confirmation within
``INVALIDATION_FEE_ETA_BLOCKS`` blocks (see
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
``baltx_fees``) is only the FALLBACK used when no network estimate is
available. RBF stays enabled, so the fee can still be raised from
Electrum's History ("Increase fee") if the transaction gets stuck.
Returns the ``PartialTransaction``, or ``None`` when there is nothing
to invalidate or the balance does not cover the fee.
"""
_logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid)
@@ -477,7 +560,17 @@ class Will:
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
fee = tx.estimated_size() * fees_per_byte
fee_rate, fee_source = Will.get_invalidation_fee_rate(
wallet, fees_per_byte
)
# The network rate can be fractional (e.g. 1.5 sat/vbyte): round
# the total fee UP to whole satoshis so the output value is an
# integer and the rate is never below the one requested.
fee = int(math.ceil(tx.estimated_size() * fee_rate))
_logger.info(
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source}), "
f"total {fee} sat"
)
if balance - fee > 0:
out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee
@@ -562,9 +655,15 @@ class Will:
def set_invalidate(wid, will=None):
will = will if will is not None else {}
will[wid].set_status("INVALIDATED", True)
if will[wid].children:
for c in will[wid].children.items():
Will.set_invalidate(c[0], will)
children = will[wid].children or []
# add_willtree builds a LIST of [child_id, input_idx, prevout_idx];
# a dict keyed by child id is still accepted (older data / tests).
child_ids = children.keys() if isinstance(children, dict) else [
c[0] for c in children
]
for child_id in child_ids:
if child_id in will:
Will.set_invalidate(child_id, will)
@staticmethod
def check_tx_height(tx, wallet):
@@ -575,10 +674,15 @@ class Will:
@staticmethod
def check_invalidated(willtree, utxos_list, wallet):
for wid, w in willtree.items():
# Defensive: a father that is not (or no longer) part of the will
# is treated as "no father" instead of raising KeyError, so the
# item's inputs are checked against the wallet UTXOs like any
# root transaction. See add_willtree for the root cause.
father = willtree.get(w.father) if w.father else None
if (
not w.father
or willtree[w.father].get_status("CONFIRMED")
or willtree[w.father].get_status("MEMPOOL")
father is None
or father.get_status("CONFIRMED")
or father.get_status("MEMPOOL")
):
for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp)
@@ -1364,6 +1468,37 @@ class WillItem(Logger):
# format_status_history() still translates them.
LEGACY_STATUS_LABELS = (N_("New"),)
# Translations of "New" that test builds of the i18n work saved INTO the
# status history (window.py / the CLI used ``_("New")`` when building).
# They are mapped back to "New" on load so the history is language
# neutral again. Keep in sync with the "New" msgstr of every catalog in
# bal/locale (tests/test_core_status_language_neutral.py checks it).
TRANSLATED_NEW_LABELS = ("Nuovo", "Nuevo")
# Fields that Willexecutors.get_willexecutor_transactions /
# push_transactions_to_willexecutor used to write into the item's own
# executor record (older versions); they are transient push data.
TRANSIENT_PUSH_FIELDS = ("txs", "txsids", "broadcast_status")
@staticmethod
def _untranslate_new_status(status):
"""Map a translated leading "New" of a saved history back to "New"."""
head, sep, rest = status.partition(".")
if head in WillItem.TRANSLATED_NEW_LABELS:
return "New" + sep + rest
return status
@staticmethod
def _without_transient_push_fields(we):
"""Return ``we`` without the transient push fields (a copy if needed)."""
if not isinstance(we, dict) or not any(
k in we for k in WillItem.TRANSIENT_PUSH_FIELDS
):
return we
return {
k: v for k, v in we.items() if k not in WillItem.TRANSIENT_PUSH_FIELDS
}
def set_status(self, status, value=True):
"""Set a status flag and apply the related side effects.
@@ -1445,8 +1580,10 @@ class WillItem(Logger):
w = data
self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None)
self.we = w.get("willexecutor", None)
self.status = w.get("status") or ""
self.we = WillItem._without_transient_push_fields(
w.get("willexecutor", None)
)
self.status = WillItem._untranslate_new_status(w.get("status") or "")
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)

View File

@@ -230,7 +230,21 @@ class Willexecutors:
return True
@staticmethod
def get_willexecutor_transactions(will, force=False):
def get_willexecutor_transactions(will, force=False, current_willexecutors=None):
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
raw txs, one per line) and ``txsids``.
``current_willexecutors`` is the CURRENT executor list from the config
(``{url: record}``). When given, an executor that is no longer in it or
no longer selected is skipped. WHY: every will item keeps its own copy
of the executor record taken at build time (``selected`` = True), so
without this check a deselected executor would still receive the will.
``None`` keeps the previous behaviour (only the item's own copy).
The returned records are COPIES: the will items are never modified.
"""
willexecutors = {}
for wid, willitem in will.items():
if willitem.get_status("VALID"):
@@ -238,12 +252,26 @@ class Willexecutors:
if not willitem.get_status("PUSHED") or force:
if willexecutor := willitem.we:
url = willexecutor["url"]
if current_willexecutors is not None and not (
Willexecutors.is_selected(
current_willexecutors.get(url)
)
):
continue
if willexecutor and Willexecutors.is_selected(willexecutor):
if url not in willexecutors:
willexecutor["txs"] = ""
willexecutor["txsids"] = []
willexecutor["broadcast_status"] = _("Waiting...")
willexecutors[url] = willexecutor
# Work on a COPY of the record: the item's
# own ``we`` dict is saved in the wallet, so
# the transient push fields below (raw txs,
# txids and the - translated - broadcast
# status shown in the push dialog) must
# never end up in it (stored data must not
# depend on the UI language, see bal/i18n.py).
record = dict(willexecutor)
record["txs"] = ""
record["txsids"] = []
record["broadcast_status"] = _("Waiting...")
willexecutors[url] = record
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
willexecutors[url]["txsids"].append(wid)

View File

@@ -93,13 +93,27 @@ def _can_broadcast(will_item):
def _can_delete(will_item):
"""True if a will transaction may be deleted (invalid or unsigned).
"""True if a will transaction may be deleted.
Valid AND fully-signed transactions are never deletable: removing them
would silently drop a committed inheritance.
Deletable:
* invalid or not fully signed transactions (unchanged rule);
* valid AND fully signed transactions that their will-executor NEVER
received: not PUSHED and not CHECKED (e.g. "Push failed" /
"Not on server", or never sent).
Not deletable: a valid, signed transaction that the will-executor holds
(PUSHED or CHECKED). WHY: deleting it only on this PC would not remove it
from the executor's server, so the user would wrongly believe that
inheritance was cancelled; that case needs an on-chain invalidation.
Signed transactions without a will-executor (backup txs) also stay
protected, as before.
"""
return bool(will_item) and (
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
if not will_item:
return False
if not will_item.get_status("VALID") or not will_item.get_status("COMPLETE"):
return True
return bool(getattr(will_item, "we", None)) and not (
will_item.get_status("PUSHED") or will_item.get_status("CHECKED")
)

View File

@@ -551,7 +551,9 @@ class BalWindow:
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = _("New")
# Stored history: always English (translated only for display by
# format_status_history); never _() here.
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -1630,7 +1632,15 @@ class BalWindow:
willitems = {
t: willitems[t] for t in txids if t in willitems
}
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
# Read the CURRENT executor list from the config, so an executor the
# user deselected after building the will is not contacted anymore.
willexecutors = Willexecutors.get_willexecutor_transactions(
willitems,
force=force,
current_willexecutors=Willexecutors.get_willexecutors(
self.bal_plugin, update=False, task=False
),
)
def getMsg(willexecutors):
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"