core+gui+cli: network-fee invalidation, will-tree fixes, i18n refresh

- #64: the on-chain invalidation now pays the current network fee through a
  configurable fee-policy setting (default eta:2); baltx_fees is only the
  offline fallback. The change output is dust-guarded: coins are appended until
  the change clears the wallet dust threshold.
- #65: a will item whose father is missing/stale is handled defensively in
  check_invalidated and discarded by add_willtree instead of raising KeyError.
- #66: children are stored as a list (dict accepted for older data/tests).
- Refresh the translation catalogs and fix stale source line references.
- New tests for the invalidation fee policy, the dust guard and the father
  link handling.
This commit is contained in:
2026-10-12 15:16:50 -04:00
parent 346aeee62b
commit 3d9757fe2d
14 changed files with 2296 additions and 974 deletions

View File

@@ -417,6 +417,7 @@ class BalController:
will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check
),
fee_policy_descriptor=self.plugin.INVALIDATION_FEE_POLICY.get(),
)
if tx is not None:
try:

View File

@@ -251,6 +251,24 @@ class BalPlugin(BasePlugin):
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
# INVALIDATION_FEE_POLICY (advanced): the fee rate the on-chain
# INVALIDATION transaction pays. It is expressed as an Electrum
# ``FeePolicy`` descriptor "<method>:<value>", the same format Electrum
# uses for its own ``FEE_POLICY`` setting, so the standard fee slider
# (Feerate / ETA / Mempool) can edit it directly:
# * "eta:2" - fee estimated for confirmation within 2 blocks
# (the default: Electrum's own default target);
# * "mempool:N" - fee targeting a depth of N vbytes in the mempool;
# * "feerate:N" - a fixed rate of N sat/kvbyte (works offline).
# WHY it is separate from the will's ``baltx_fees``: the will is built
# with a deliberately high rate because the will-executors may broadcast
# the inheritance years from now, while the invalidation is sent TODAY
# and must pay the current network fee. ``baltx_fees`` remains only the
# fallback used when no estimate is available.
self.INVALIDATION_FEE_POLICY = BalConfig(
config, "bal_invalidation_fee_policy", "eta:2"
)
self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)

View File

@@ -26,9 +26,11 @@ 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
from electrum.fee_policy import FeePolicy
from electrum.logging import Logger, get_logger
from electrum.transaction import (
PartialTransaction,
@@ -50,6 +52,18 @@ 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
# Default fee policy for the invalidation tx, as an Electrum ``FeePolicy``
# descriptor ("<method>:<value>"). The user can change it from the plugin's
# graphical settings, which uses Electrum's standard fee slider (Feerate / ETA /
# Mempool). "eta:2" = fee estimated for confirmation within 2 blocks.
INVALIDATION_FEE_POLICY_DEFAULT = f"eta:{INVALIDATION_FEE_ETA_BLOCKS}"
_logger = get_logger(__name__)
@@ -68,6 +82,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,61 +468,220 @@ class Will:
out[inp.prevout.to_str()] = inp
return out
@staticmethod
def get_invalidation_fee_rate(
wallet, fallback_fee_per_byte, fee_policy_descriptor=None
):
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
``fee_rate`` is in sat/vbyte. ``source`` names where the rate came from:
the ``FeeMethod`` used by the policy (``"feerate"``, ``"eta"``,
``"mempool"``) or ``"fallback"`` when no usable rate was available.
WHY: the invalidation is broadcast NOW, so it pays the current network
fee according to the user's chosen ``FeePolicy`` (default ``"eta:2"``,
i.e. a confirmation within 2 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 rate is computed by Electrum's own ``FeePolicy`` (the module behind
the standard fee slider), so the graphical settings can offer Feerate /
ETA / Mempool. It returns sat/kvbyte and already clamps dynamic
estimates between Electrum's conservative minimum relay fee and its
maximum dynamic fee; a fixed "feerate" policy needs no network at all.
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 rate - normally the will's ``baltx_fees``.
fee_policy_descriptor: Electrum fee policy descriptor (e.g.
``"eta:2"``). ``None`` uses ``INVALIDATION_FEE_POLICY_DEFAULT``.
"""
descriptor = fee_policy_descriptor or INVALIDATION_FEE_POLICY_DEFAULT
# FeePolicy.parse handles malformed descriptors internally (it logs a
# warning and falls back to "eta:2"), so this never raises.
policy = FeePolicy(descriptor)
network = getattr(wallet, "network", None) if wallet else None
fee_per_kb = None
try:
fee_per_kb = policy.fee_per_kb(network)
except Exception as e:
_logger.warning(f"invalidation fee: no 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, policy.method.name.lower()
return fallback_fee_per_byte, "fallback"
@staticmethod
def _invalidation_age_key(utxo):
"""Sort key for invalidation coins: smaller = in the wallet longest.
"Oldest" is approximated by the funding transaction's confirmation
height (confirmed coins with the lowest height were received first).
Unconfirmed, local or unknown-height coins have no mined position and
are treated as the newest. Deterministic tie-breakers: position within
the block, then the prevout string.
"""
height = getattr(utxo, "block_height", None)
if isinstance(height, int) and height > 0:
txpos = getattr(utxo, "block_txpos", 0) or 0
return (0, height, txpos, utxo.prevout.to_str())
return (1, 0, 0, utxo.prevout.to_str())
@staticmethod
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
will_locktime=None, fee_policy_descriptor=None):
"""Build the (unsigned) on-chain invalidation transaction.
The transaction spends, per will transaction, only the OLDEST funding
coin (the one that has been in the wallet longest): spending a single
input already makes the pre-signed transaction unspendable, while the
newer coins stay available for the rebuilt (postponed) will. Both VALID
and REPLACED will items are considered, because a REPLACED item's
pre-signed transaction may have been pushed to the will-executors and
may spend different coins. The outputs go back to one of the wallet's
own change addresses. When the chosen coins would not cover the fee
while leaving a change output at or above the dust threshold, additional
(newer) candidate coins are added as fallback inputs; if the change
still cannot clear dust once the coins are exhausted, no invalidation
transaction is returned.
Fee: the CURRENT network fee according to the user's fee policy
(``fee_policy_descriptor``, default ``"eta:2"``; see
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
``baltx_fees``) is only the FALLBACK used when no usable rate 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)
# Invalidating only the VALID items is not enough. A will rebuilt after
# an heir change (or a rebuild) supersedes the previously signed
# transaction: the old item is kept but marked REPLACED, which CLEARS
# VALID (see set_status). That old transaction may have been pushed to
# the will-executors and may spend DIFFERENT coins than the current
# valid one, so its funding UTXOs must be spent too, otherwise a
# will-executor could still broadcast it from a UTXO left unspent.
# REPLACED items therefore join the VALID ones; inputs that are already
# spent are naturally dropped by the wallet-UTXO filter below.
will_to_invalidate = {
wid: wi
for wid, wi in will.items()
if wi.get_status("VALID") or wi.get_status("REPLACED")
}
if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else:
utxos = wallet.get_utxos()
filtered_inputs = []
prevout_to_spend = []
current_height = Util.get_current_height(wallet.network)
for prevout_str, ws in inputs.items():
for w in ws:
if w[0] not in filtered_inputs:
filtered_inputs.append(w[0])
if prevout_str not in prevout_to_spend:
prevout_to_spend.append(prevout_str)
balance = 0
utxo_to_spend = []
utxo_by_prevout = {}
for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
_logger.debug("is not mature coinbase output")
continue
utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend:
balance += utxo.value_sats()
utxo_to_spend.append(utxo)
utxo_by_prevout[utxo.prevout.to_str()] = utxo
# Candidate coins: the wallet UTXOs that fund one of the will txs.
candidates = []
seen = set()
for wi in will_to_invalidate.values():
for txin in wi.tx.inputs():
prevout_str = txin.prevout.to_str()
if prevout_str in seen or prevout_str not in utxo_by_prevout:
continue
seen.add(prevout_str)
candidates.append(utxo_by_prevout[prevout_str])
# Spend, per will transaction, only its OLDEST funding coin (the one
# that has been in the wallet longest): spending a single input is
# enough to make the pre-signed transaction unspendable, and the newer
# coins stay available for the rebuilt (postponed) will.
chosen = {} # prevout_str -> UTXO, one (the oldest) per will transaction
for wi in will_to_invalidate.values():
item_candidates = [
utxo_by_prevout[txin.prevout.to_str()]
for txin in wi.tx.inputs()
if txin.prevout.to_str() in utxo_by_prevout
]
if item_candidates:
oldest = min(item_candidates, key=Will._invalidation_age_key)
chosen[oldest.prevout.to_str()] = oldest
utxo_to_spend = list(chosen.values())
balance = sum(u.value_sats() for u in utxo_to_spend)
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
out.is_change = True
locktime = current_height
tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2
fee_rate, fee_source = Will.get_invalidation_fee_rate(
wallet, fees_per_byte, fee_policy_descriptor
)
tx.set_rbf(True)
fee = tx.estimated_size() * fees_per_byte
if balance - fee > 0:
out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee
)
_logger.info(
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source})"
)
# The change output must stay at or above the dust threshold,
# otherwise the invalidation transaction would be non-standard and
# could not be relayed.
try:
dust = int(wallet.dust_threshold())
except (TypeError, ValueError):
dust = 0
# Remaining candidate coins, oldest first, for the fee fallback.
remaining = sorted(
(c for c in candidates if c.prevout.to_str() not in chosen),
key=Will._invalidation_age_key,
)
while True:
# 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.
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
out.is_change = True
tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
fee = int(math.ceil(tx.estimated_size() * fee_rate))
if balance - fee >= dust:
out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee
)
tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
_logger.debug(f"invalidation tx: {tx}")
return tx
_logger.debug(f"invalidation tx: {tx}")
return tx
else:
_logger.debug(f"balance({balance}) - fee({fee}) <=0")
pass
# The selected coins cannot cover the fee while leaving a
# spendable (non-dust) change: keep spending the next-oldest
# will coin until the invalidation is economically viable or no
# coin is left.
if not remaining:
_logger.debug(
"balance({})-fee({})<dust({}), no more will coins".format(
balance, fee, dust
)
)
break
extra = remaining.pop(0)
_logger.debug(
"balance({})-fee({})<dust({}): adding {}".format(
balance, fee, dust, extra.prevout.to_str()
)
)
chosen[extra.prevout.to_str()] = extra
utxo_to_spend = list(chosen.values())
balance += extra.value_sats()
else:
_logger.debug("len utxo_to_spend <=0")
pass
@@ -562,9 +751,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 +770,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)

View File

@@ -747,6 +747,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
)
if tx:
_logger.debug(
@@ -842,6 +843,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
)
except NoHeirsException:
_logger.debug("no heirs")

View File

@@ -14,6 +14,8 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
and cached in ``self.bal_windows``.
"""
from electrum.fee_policy import FeeMethod, FeePolicy
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
from electrum.gui.qt.main_window import StatusBarButton
from electrum.plugin import hook
from electrum.util import EventListener, event_listener
@@ -594,7 +596,9 @@ class Plugin(BalPlugin, EventListener):
lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
reset_btn_11, reset_btn_12, reset_btn_auto_sign,
lbl_invalidation_fee, invalidation_fee_box,
help_invalidation_fee, btn_reset_invalidation_fee):
w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -911,6 +915,78 @@ class Plugin(BalPlugin, EventListener):
)
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# ----------------------------------------------------------------- #
# Invalidation fee policy (ADVANCED only): which fee rate the on-chain #
# invalidation transaction pays. It reuses Electrum's standard fee #
# widget (the same Feerate / ETA / Mempool slider used in the send #
# tab), so the user picks a fixed rate, a block target or a mempool #
# depth. The chosen policy is persisted as an Electrum "FeePolicy" #
# descriptor ("<method>:<value>"). #
# ----------------------------------------------------------------- #
invalidation_fee_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.get())
settings_network = (
getattr(window, "network", None) if window is not None else None
)
def on_invalidation_fee_change(_fee_rate):
"""Persist the fee policy descriptor chosen with the slider."""
self.INVALIDATION_FEE_POLICY.set(
invalidation_fee_policy.get_descriptor()
)
invalidation_fee_slider = FeeSlider(
parent=d,
network=settings_network,
fee_policy=invalidation_fee_policy,
callback=on_invalidation_fee_change,
)
invalidation_fee_combo = FeeComboBox(invalidation_fee_slider)
# Do NOT call invalidation_fee_slider.update() here: the slider and combo
# already initialise themselves before their signals are connected, and
# update() fires moved() -> on_invalidation_fee_change, which would
# persist (and snap) the descriptor just for opening the dialog.
invalidation_fee_box = QWidget()
invalidation_fee_box_layout = QHBoxLayout(invalidation_fee_box)
invalidation_fee_box_layout.setContentsMargins(0, 0, 0, 0)
invalidation_fee_box_layout.addWidget(invalidation_fee_slider, 1)
invalidation_fee_box_layout.addWidget(invalidation_fee_combo)
def reset_invalidation_fee():
"""Restore the fee policy to its default ("eta:2").
The live ``FeePolicy`` object is mutated in place (method + value)
so the slider keeps working with the new defaults; the stored
descriptor and the combo are then re-synced.
"""
default_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.default)
invalidation_fee_policy.method = default_policy.method
invalidation_fee_policy.value = default_policy.value
self.INVALIDATION_FEE_POLICY.set(self.INVALIDATION_FEE_POLICY.default)
invalidation_fee_combo.setCurrentIndex(
FeeMethod.slider_index_of_method(default_policy.method)
)
invalidation_fee_slider.update()
lbl_invalidation_fee = QLabel(_("Invalidation fee policy"))
help_invalidation_fee = HelpButton(_(
"Fee rate the on-chain invalidation transaction pays.\n"
"The invalidation is sent TODAY (it spends the will's coins so the "
"old inheritance transactions become unusable), so it should pay "
"the current network fee - not the will's mining fee rate.\n\n"
"Feerate: a fixed rate (sat/vByte).\n"
"ETA: fee based on the estimated confirmation time (blocks).\n"
"Mempool: fee targeting a depth in the memory pool.\n"
"Only used in ADVANCED mode."
))
btn_reset_invalidation_fee = QPushButton("\u21ba")
btn_reset_invalidation_fee.setFixedWidth(24)
btn_reset_invalidation_fee.setToolTip(_("Reset to default"))
btn_reset_invalidation_fee.clicked.connect(reset_invalidation_fee)
grid.addWidget(_hide_if_basic(lbl_invalidation_fee), 16, 0)
grid.addWidget(_hide_if_basic(invalidation_fee_box), 16, 1)
grid.addWidget(_hide_if_basic(help_invalidation_fee), 16, 2)
grid.addWidget(_hide_if_basic(btn_reset_invalidation_fee), 16, 3)
# ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS #
@@ -968,6 +1044,8 @@ class Plugin(BalPlugin, EventListener):
# reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Reset the invalidation fee policy to its default ("eta:2") too.
reset_invalidation_fee()
# Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are)
# reflects the reset values.

View File

@@ -1089,6 +1089,7 @@ class BalWindow:
will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check
),
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
)
msg = _("Calculating Transactions")
self.waiting_dialog = BalWaitingDialog(
@@ -1424,6 +1425,7 @@ class BalWindow:
willitems,
default_value=getattr(self, "date_to_check", None),
),
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
)
except Exception as e:
_logger.error("auto invalidate: could not build tx: {}".format(e))

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