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:
155
bal/core/will.py
155
bal/core/will.py
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user