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

@@ -0,0 +1,82 @@
"""
Tests for ``Willexecutors.get_willexecutor_transactions`` with the CURRENT
executor selection (``current_willexecutors``).
Every will item keeps its own copy of the executor record taken at build time
(``selected`` = True). Without the current selection, an executor the user
deselected after building the will would still receive the will.
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_deselected_executor.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.willexecutors import Willexecutors
class FakeItem:
"""Minimal will item: statuses, a tx and the build-time executor copy."""
def __init__(self, url, **flags):
self._status = {"VALID": True, "COMPLETE": True}
self._status.update(flags)
self.we = {"url": url, "selected": True}
self.tx = f"rawtx-{url}"
def get_status(self, name):
return self._status.get(name, False)
A = "https://we.a.example"
B = "https://we.b.example"
def _will():
return {"tx_a": FakeItem(A), "tx_b": FakeItem(B)}
def test_without_current_list_previous_behaviour():
assert set(Willexecutors.get_willexecutor_transactions(_will())) == {A, B}
def test_deselected_executor_is_skipped():
current = {A: {"selected": True}, B: {"selected": False}}
out = Willexecutors.get_willexecutor_transactions(
_will(), current_willexecutors=current
)
assert set(out) == {A}
assert out[A]["txsids"] == ["tx_a"]
def test_executor_removed_from_list_is_skipped():
current = {A: {"selected": True}}
out = Willexecutors.get_willexecutor_transactions(
_will(), current_willexecutors=current
)
assert set(out) == {A}
def test_already_pushed_still_excluded_unless_forced():
will = {"tx_a": FakeItem(A, PUSHED=True)}
current = {A: {"selected": True}}
assert Willexecutors.get_willexecutor_transactions(
will, current_willexecutors=current
) == {}
assert set(
Willexecutors.get_willexecutor_transactions(
will, force=True, current_willexecutors=current
)
) == {A}
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All deselected-executor tests passed")

View File

@@ -0,0 +1,231 @@
"""
Tests for the fee of the on-chain INVALIDATION transaction.
The invalidation is broadcast TODAY (it spends the will's coins so the old
pre-signed inheritance transactions become unusable), therefore it must pay the
CURRENT network fee for a confirmation within
``INVALIDATION_FEE_ETA_BLOCKS`` (2) blocks. The will's ``baltx_fees`` (a
deliberately high rate meant for inheritance transactions broadcast years from
now) is only the FALLBACK used when no network estimate is available.
Covers:
* Will.get_invalidation_fee_rate() - rate selection and fallback rules
* Will.invalidate_will() - the chosen rate is really used for the fee
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_invalidation_fee.py
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_invalidation_fee.py -q
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import INVALIDATION_FEE_ETA_BLOCKS, Will, WillItem
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The outpoint spent by _VALID_TX_HEX (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
_BALANCE = 1000000
# The will's baltx_fees used as the fallback rate in every test (sat/vbyte).
_BALTX_FEES = 20
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _wallet_with_estimate(fee_per_kb):
"""Wallet whose network returns ``fee_per_kb`` (sat/kvbyte) for any ETA.
``fee_per_kb`` may also be an Exception instance: the estimate call then
raises it, to simulate a broken network object.
"""
wallet = MagicMock()
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
network = MagicMock()
if isinstance(fee_per_kb, Exception):
network.fee_estimates.eta_target_to_fee.side_effect = fee_per_kb
else:
network.fee_estimates.eta_target_to_fee.return_value = fee_per_kb
wallet.network = network
return wallet
def _make_will():
"""One VALID will item spending _PREVOUT_STR with a known input value."""
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": {"alice": ["addr_alice", 5000, "30d"]},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": _BALTX_FEES,
})
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.inputs()[0]._TxInput__value_sats = _BALANCE
return {"willtxid1": item}
def _make_utxo():
"""Wallet-side UTXO matching the will input."""
utxo = MagicMock()
utxo.prevout.to_str.return_value = _PREVOUT_STR
utxo.is_coinbase_output.return_value = False
utxo.block_height = 1
utxo.value_sats.return_value = _BALANCE
return utxo
def _invalidation_output_value(wallet, estimated_size=200):
"""Run Will.invalidate_will and return the value of the final output.
Electrum's tx building is mocked (as in test_core_will_invalidate.py):
``estimated_size`` is the vsize the fee is computed from.
"""
wallet.get_utxos.return_value = [_make_utxo()]
mock_output = MagicMock()
mock_tx = MagicMock()
mock_tx.estimated_size.return_value = estimated_size
with patch("bal.core.will.Util.get_current_height", return_value=800000), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx):
result = Will.invalidate_will(_make_will(), wallet, _BALTX_FEES)
assert result is mock_tx
# 1st call: full balance (size estimation); 2nd call: balance - fee.
return mock_out.call_args_list[1][0][1]
# ------------------------------------------------------------------ #
# Will.get_invalidation_fee_rate
# ------------------------------------------------------------------ #
def test_target_is_two_blocks():
"""The owner's decision: confirm within 2 blocks."""
assert INVALIDATION_FEE_ETA_BLOCKS == 2
wallet = _wallet_with_estimate(3000)
Will.get_invalidation_fee_rate(wallet, _BALTX_FEES)
wallet.network.fee_estimates.eta_target_to_fee.assert_called_once_with(2)
def test_network_estimate_is_used():
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
wallet = _wallet_with_estimate(3000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "network")
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
"""No cap: if the market is above baltx_fees, the market rate is used."""
wallet = _wallet_with_estimate(45000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "network")
def test_fractional_estimate_is_kept():
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
wallet = _wallet_with_estimate(1500)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "network")
def test_offline_wallet_falls_back_to_baltx_fees():
"""No network object (offline): previous behaviour, baltx_fees."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_missing_estimate_falls_back():
"""Estimates not received yet: Electrum returns None."""
wallet = _wallet_with_estimate(None)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_estimate_error_falls_back_and_does_not_raise():
"""A broken network object must never break the invalidation."""
wallet = _wallet_with_estimate(RuntimeError("server gone"))
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_non_integer_or_invalid_estimates_fall_back():
"""Only a positive int (Electrum's sat/kvbyte type) is trusted."""
for bad in (0, -1000, True, "3000", 3.5, MagicMock()):
wallet = _wallet_with_estimate(bad)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
), bad
def test_no_wallet_falls_back():
assert Will.get_invalidation_fee_rate(None, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
# ------------------------------------------------------------------ #
# Will.invalidate_will uses the chosen rate
# ------------------------------------------------------------------ #
def test_invalidation_pays_network_fee_not_baltx_fees():
"""3 sat/vbyte on 200 vbytes = 600 sat (not 200 * 20 = 4000 sat)."""
wallet = _wallet_with_estimate(3000)
assert _invalidation_output_value(wallet) == _BALANCE - 600
def test_invalidation_offline_pays_baltx_fees():
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
wallet = _wallet_with_estimate(None)
assert _invalidation_output_value(wallet) == _BALANCE - 4000
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
wallet = _wallet_with_estimate(1500)
value = _invalidation_output_value(wallet, estimated_size=201)
assert value == _BALANCE - 302
assert isinstance(value, int)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation fee tests passed")

View File

@@ -0,0 +1,136 @@
"""
Stored will data must not depend on the GUI language (rule in bal/i18n.py).
Covers the two findings of the i18n review:
* the status history of a new will item is saved as English "New" (not
``_("New")``), and histories saved by test builds as "Nuovo"/"Nuevo" are
mapped back to "New" on load;
* the push helper no longer writes transient fields (``txs``, ``txsids`` and
the translated ``broadcast_status``) into the will item's executor record,
and such fields saved by older versions are dropped on load.
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_status_language_neutral.py
"""
import os
import re
import sys
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import WillItem
from bal.core.willexecutors import Willexecutors
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
ROOT = Path(__file__).resolve().parent.parent
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
URL = "https://we.example"
def _item(status="", we=None):
return WillItem(
{"tx": _VALID_TX_HEX, "heirs": {}, "willexecutor": we, "status": status}
)
# ------------------------------------------------------------------ #
# Status history "New"
# ------------------------------------------------------------------ #
def test_builders_store_english_new():
"""window.py and the CLI controller must not translate the stored status."""
for rel in ("bal/gui/qt/window.py", "bal/cli/controller.py"):
src = (ROOT / rel).read_text(encoding="utf-8")
assert re.search(r'tx\["status"\]\s*=\s*"New"', src), rel
assert not re.search(r'tx\["status"\]\s*=\s*_\(', src), rel
def test_translated_new_is_mapped_back_on_load():
assert _item("Nuovo.Signed.Pushed").status == "New.Signed.Pushed"
assert _item("Nuevo").status == "New"
assert _item("New.Signed").status == "New.Signed"
def test_only_the_leading_new_is_touched():
assert _item(".Nuovo").status == ".Nuovo"
assert _item("Signed.Nuovo").status == "Signed.Nuovo"
def test_translated_new_labels_match_the_catalogs():
"""Every catalog's translation of "New" must be recognised on load."""
found = set()
for po in (ROOT / "bal" / "locale").glob("*/LC_MESSAGES/bal.po"):
text = po.read_text(encoding="utf-8")
m = re.search(r'^msgid "New"\nmsgstr "(.*)"$', text, re.MULTILINE)
if m and m.group(1):
found.add(m.group(1))
assert found, "no catalog translates 'New'"
assert found <= set(WillItem.TRANSLATED_NEW_LABELS), found
# ------------------------------------------------------------------ #
# Transient push fields
# ------------------------------------------------------------------ #
class FakeItem:
def __init__(self):
self.we = {"url": URL, "selected": True, "address": "a", "base_fee": 1}
self.tx = "rawtx"
def get_status(self, name):
return name in ("VALID", "COMPLETE")
def test_push_helper_does_not_modify_the_will_item():
item = FakeItem()
before = dict(item.we)
out = Willexecutors.get_willexecutor_transactions({"t1": item})
assert out[URL]["txsids"] == ["t1"]
assert "broadcast_status" in out[URL]
assert item.we == before
assert out[URL] is not item.we
def test_transient_fields_saved_by_older_versions_are_dropped_on_load():
we = {
"url": URL,
"selected": True,
"txs": "raw\n",
"txsids": ["t1"],
"broadcast_status": "Riuscito",
}
item = _item("New", we)
assert item.we == {"url": URL, "selected": True}
assert "broadcast_status" not in item.to_dict()["willexecutor"]
def test_clean_executor_record_is_kept_as_is():
we = {"url": URL, "selected": True}
assert _item("New", we).we is we
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All language-neutral status tests passed")

View File

@@ -0,0 +1,145 @@
"""
Tests for the parent/child tree of the will transactions (``father`` links).
Bug reproduced from a real wallet (Electrum log, Check wizard):
File "bal/core/will.py", in check_invalidated
or willtree[w.father].get_status("CONFIRMED")
KeyError: 'a65a682f…'
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
after the father tx was removed from the will the child pointed to a txid that
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
``Will.check_invalidated`` treats a missing father as "no father".
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_willtree_father.py
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import Will, WillItem
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
# the parent's txid, so the child spends the parent's output 0.
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
)
# A txid that is not in the will (the "removed father" of the bug report).
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
def _item(tx_hex=_VALID_TX_HEX, father=None):
"""WillItem built from ``tx_hex``, optionally with a stored father."""
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
if father is not None:
data["Father"] = father
return WillItem(data)
def _wallet_without_adb():
"""Wallet whose check_invalidated path skips on-chain lookups."""
wallet = MagicMock()
wallet.adb = None
return wallet
def test_stale_father_is_discarded_by_add_willtree():
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.add_willtree(will)
assert child.father is None
def test_check_invalidated_does_not_crash_on_stale_father():
"""The exact failure of the bug report, now without KeyError."""
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.add_willtree(will)
Will.check_invalidated(will, [], _wallet_without_adb())
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.check_invalidated(will, [], _wallet_without_adb())
assert child.father == _MISSING_ID
def test_real_parent_child_link_is_built():
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
assert child.father == _PARENT_ID
assert [c[0] for c in parent.children] == [child._id]
assert parent.father is None
def test_stale_father_is_replaced_by_the_real_parent():
parent = _item()
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
assert child.father == _PARENT_ID
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
wallet = MagicMock()
# Funding not spent on-chain: nothing gets invalidated, we only observe
# which items had their inputs checked.
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
patch.object(Will, "_funding_really_spent", return_value=False):
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
# Only the parent (a root) looked up its height; the child was skipped.
checked = [call.args[0] for call in height.call_args_list]
assert checked == [parent.tx]
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
"""add_willtree builds children as a LIST: invalidation must cascade."""
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {parent._id: parent, child._id: child}
Will.add_willtree(will)
Will.set_invalidate(parent._id, will)
assert parent.get_status("INVALIDATED") is True
assert child.get_status("INVALIDATED") is True
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All will-tree father tests passed")

View File

@@ -92,6 +92,31 @@ def test_can_delete_none_false():
assert _can_delete(None) is False
def _signed_valid(we="https://we.example", **flags):
"""A VALID + COMPLETE (signed) item, optionally with a will-executor."""
item = FakeWillItem(VALID=True, COMPLETE=True, **flags)
item.we = {"url": we, "selected": True} if we else None
return item
def test_can_delete_signed_but_never_received_by_executor():
"""Push failed / "Not on server": the executor does not hold it."""
assert _can_delete(_signed_valid(PUSH_FAIL=True, CHECK_FAIL=True)) is True
assert _can_delete(_signed_valid()) is True # signed, never sent
def test_can_delete_signed_and_pushed_false():
"""The executor holds it: deleting locally would not cancel it."""
assert _can_delete(_signed_valid(PUSHED=True)) is False
assert _can_delete(_signed_valid(PUSHED=True, CHECKED=True)) is False
assert _can_delete(_signed_valid(CHECKED=True)) is False
def test_can_delete_signed_backup_tx_without_executor_false():
"""Signed txs without a will-executor (backup) stay protected."""
assert _can_delete(_signed_valid(we=None)) is False
# ------------------------------------------------------------------ #
# _apply_select_all
# ------------------------------------------------------------------ #