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

@@ -0,0 +1,296 @@
"""
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 chosen with an Electrum ``FeePolicy`` (Feerate / ETA /
Mempool; default ``"eta:2"``, i.e. confirm within 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 usable rate 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.fee_policy import FeePolicy, FeeTimeEstimates
from electrum.transaction import Transaction
from bal.core.will import (
INVALIDATION_FEE_ETA_BLOCKS,
INVALIDATION_FEE_POLICY_DEFAULT,
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_eta_estimate(fee_per_kb):
"""Wallet whose ETA fee lookup returns ``fee_per_kb`` (sat/kvbyte).
Electrum's ``FeePolicy`` (method ``eta``) resolves the target through
``network.fee_estimates.eta_to_fee``. ``fee_per_kb`` may also be an
Exception instance: the call then raises it (broken network object), or a
non-int-convertible object to exercise the invalid-value path.
"""
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_to_fee.side_effect = fee_per_kb
else:
network.fee_estimates.eta_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, descriptor=None):
"""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,
fee_policy_descriptor=descriptor,
)
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 - policy selection
# ------------------------------------------------------------------ #
def test_default_policy_is_two_block_eta():
"""The owner's decision: default fee policy confirms within 2 blocks.
Uses a REAL ``FeeTimeEstimates`` holding data only for the 2-block target,
proving the default policy really asks Electrum for target 2.
"""
assert INVALIDATION_FEE_ETA_BLOCKS == 2
assert INVALIDATION_FEE_POLICY_DEFAULT == "eta:2"
network = MagicMock()
estimates = FeeTimeEstimates()
estimates.set_data(2, 3000)
network.fee_estimates = estimates
wallet = MagicMock()
wallet.network = network
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
def test_default_policy_descriptor_is_parsed_by_electrum_fee_policy():
"""The setting is a real Electrum FeePolicy descriptor."""
policy = FeePolicy(INVALIDATION_FEE_POLICY_DEFAULT)
assert policy.method.name.lower() == "eta"
assert policy.value == 2
def test_network_estimate_is_used():
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
wallet = _wallet_with_eta_estimate(3000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
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_eta_estimate(45000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "eta")
def test_fractional_estimate_is_kept():
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
wallet = _wallet_with_eta_estimate(1500)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "eta")
def test_fixed_feerate_policy_works_offline():
"""A fixed 'feerate' policy needs no network at all."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "feerate:5000"
) == (5.0, "feerate")
def test_mempool_policy_uses_depth_estimate():
"""A 'mempool' policy asks Electrum for a depth-based estimate."""
wallet = MagicMock()
network = MagicMock()
network.mempool_fees.depth_to_fee.return_value = 3000
wallet.network = network
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "mempool:1000000"
) == (3.0, "mempool")
def test_offline_wallet_falls_back_to_baltx_fees():
"""No network object (offline) with a dynamic policy: 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_eta_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_eta_estimate(RuntimeError("server gone"))
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_non_positive_or_unconvertible_estimates_fall_back():
"""Only a positive integer rate is trusted."""
for bad in (0, -1000, object()):
wallet = _wallet_with_eta_estimate(bad)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
), bad
def test_invalid_policy_descriptor_uses_the_default():
"""A corrupt stored descriptor must not break anything: default is used."""
wallet = _wallet_with_eta_estimate(3000)
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "not-a-policy"
) == (3.0, "eta")
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_eta_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_eta_estimate(None)
assert _invalidation_output_value(wallet) == _BALANCE - 4000
def test_invalidation_uses_fixed_feerate_policy_offline():
"""A fixed feerate pays exactly that rate even without a network."""
wallet = MagicMock()
wallet.network = None
assert _invalidation_output_value(
wallet, descriptor="feerate:5000"
) == _BALANCE - 1000 # 200 vbytes * 5 sat/vbyte
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_eta_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

@@ -47,6 +47,24 @@ _VALID_TX_HEX = (
# The prevout string that _VALID_TX_HEX spends (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# A second transaction identical to _VALID_TX_HEX but spending a DIFFERENT
# prevout. It stands for a superseded (REPLACED) will transaction that commits
# different coins than the current valid one.
_REPLACED_TX_HEX = "0100000001ff" + _VALID_TX_HEX[len("01000000012a"):]
_REPLACED_PREVOUT_STR = _PREVOUT_STR[:-4] + "ff:0"
# A 2-input transaction: version 2 with two parallel inputs. Built from
# _VALID_TX_HEX by duplicating its (298-hex) input with a DIFFERENT prevout
# (first byte "ff", so the second input spends _REPLACED_PREVOUT_STR) and
# keeping the unchanged outputs.
_INPUT_LEN = 298
_TWO_INPUT_TX_HEX = (
"0100000002"
+ _VALID_TX_HEX[10:10 + _INPUT_LEN]
+ "ff" + _VALID_TX_HEX[10 + 2:10 + _INPUT_LEN]
+ _VALID_TX_HEX[10 + _INPUT_LEN:]
)
# Change address for the invalidation output.
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
@@ -55,8 +73,9 @@ _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
# Helpers
# ------------------------------------------------------------------ #
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
"""Create a WillItem from _VALID_TX_HEX with a known input value.
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None,
tx_hex=_VALID_TX_HEX):
"""Create a WillItem from ``tx_hex`` with a known input value.
The input's ``_trusted_value_sats`` is set so that
``invalidate_will`` can read the balance from it.
@@ -65,7 +84,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
if extra_heirs:
heirs.update(extra_heirs)
item = WillItem({
"tx": _VALID_TX_HEX,
"tx": tx_hex,
"heirs": heirs,
"willexecutor": None,
"status": "",
@@ -93,16 +112,24 @@ def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
utxo.prevout.to_str.return_value = prevout_str
utxo.is_coinbase_output.return_value = is_coinbase
utxo.block_height = 1
utxo.block_txpos = 0
utxo.value_sats.return_value = value_sats
return utxo
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
"""Create a mock wallet with the given UTXOs and change address."""
"""Create a mock wallet with the given UTXOs and change address.
``network`` is left ``None`` so ``Will.invalidate_will`` uses the
``fees_per_byte`` FALLBACK instead of Electrum's live estimate: these tests
exercise the pure fee arithmetic (``fee = size * fees_per_byte``). The
policy-based rate selection has its own tests in
``tests/test_core_invalidation_fee.py``.
"""
wallet = MagicMock()
wallet.get_utxos.return_value = utxos
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
wallet.network = MagicMock()
wallet.network = None
return wallet
@@ -292,6 +319,77 @@ class TestInvalidateWill:
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_replaced_item_inputs_are_invalidated(self):
"""A REPLACED (superseded) item's funding UTXO is also spent.
Regression: a will rebuilt after an heir change supersedes the old
signed transaction, which is kept but marked REPLACED (VALID cleared).
The old transaction may have been pushed to the will-executors and may
spend DIFFERENT coins than the current valid one, so its UTXO must be
included in the invalidation. Otherwise it would stay unspent and a
will-executor could still broadcast the superseded transaction.
"""
valid_item = _make_willitem(value_sats=1000000)
replaced_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
)
replaced_item.set_status("REPLACED", True)
assert replaced_item.get_status("VALID") is False
will = {"valid": valid_item, "replaced": replaced_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR in spent
def test_replaced_item_contributes_balance(self):
"""The balance of a REPLACED item is included in the invalidation."""
valid_item = _make_willitem(value_sats=1000000)
replaced_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
)
replaced_item.set_status("REPLACED", True)
will = {"valid": valid_item, "replaced": replaced_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
_, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# Balance = 1000000 + 2000000 = 3000000, fee = 200 * 10 = 2000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 2998000
def test_invalidated_item_inputs_are_not_invalidated(self):
"""A purely INVALIDATED item (already neutralised) is still excluded."""
valid_item = _make_willitem(value_sats=1000000)
invalid_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX, valid=False
)
assert invalid_item.get_status("INVALIDATED") is True
will = {"valid": valid_item, "invalid": invalid_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR not in spent
def test_first_from_io_uses_full_balance(self):
"""The first from_io call uses the full balance (before fee deduction)
to estimate the fee."""
@@ -353,6 +451,152 @@ class TestInvalidateWill:
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 1000000
def test_invalidation_spends_oldest_input_per_tx(self):
"""For each will tx, only its OLDEST funding coin is spent.
A will item whose transaction has two inputs (old and new coin) must
spend only the coin that has been in the wallet longest, leaving the
newer coin unspent for the rebuilt (postponed) will.
"""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
new_utxo.block_height = 500 # newer than the old coin
wallet = _mock_wallet([old_utxo, new_utxo])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_PREVOUT_STR]
def test_invalidation_fallback_appends_next_oldest(self):
"""If the oldest coin cannot cover the fee, the next-oldest is appended.
The invalidation must not be dropped just because the single oldest
coin is small: it keeps adding will coins, oldest first, until the
balance covers the estimated fee.
"""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1000000)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
# estimated_size (200) * fees_per_byte (10) = 2000 > balance (1000)
result, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR in spent
# balance = 1000 + 1000000, fee = 2000
final_value = mock_out.call_args[0][1]
assert final_value == 999000
def test_invalidation_treats_unconfirmed_as_newest(self):
"""An unconfirmed (unknown-height) coin is never chosen as the oldest."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
confirmed = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
confirmed.block_height = 10
unconfirmed = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
unconfirmed.block_height = None
wallet = _mock_wallet([unconfirmed, confirmed])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_PREVOUT_STR]
def test_invalidation_same_height_order_is_deterministic(self):
"""Coins at the same height are ordered by block position, not list order."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
later = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
later.block_height = 10
later.block_txpos = 5
earlier = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
earlier.block_height = 10
earlier.block_txpos = 1
wallet = _mock_wallet([later, earlier])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_REPLACED_PREVOUT_STR] # block_txpos 1 < 5 => older
def test_sub_dust_change_returns_none(self):
"""A change output below the dust limit is never created.
Regression: previously any positive change (balance - fee > 0) was
accepted, which could produce a non-standard (unrelayable) output and
leave the will un-invalidated.
"""
item = _make_willitem(value_sats=2200)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(value_sats=2200)])
wallet.dust_threshold.return_value = 546
# fee = estimated_size (200) * fees_per_byte (10) = 2000;
# balance - fee = 200 < dust (546) -> no transaction.
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is None
def test_change_equal_to_dust_is_allowed(self):
"""Change exactly at the dust threshold is accepted (>= comparison)."""
item = _make_willitem(value_sats=2546)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(value_sats=2546)])
wallet.dust_threshold.return_value = 546
# balance - fee = 2546 - 2000 = 546 == dust -> transaction.
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
assert mock_out.call_args_list[1][0][1] == 546
def test_fallback_stops_when_change_still_dust(self):
"""Coins are appended until the change clears dust; else returns None."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1200)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
wallet.dust_threshold.return_value = 546
# fee = 2000; total balance = 1000 + 1200 = 2200;
# change = 200 < dust (546) and no coin is left -> None.
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is None
def test_fallback_succeeds_once_change_clears_dust(self):
"""Appending the next coin yields a change above dust -> transaction."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2546)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
wallet.dust_threshold.return_value = 546
# fee = 2000; total balance = 3546; change = 1546 >= dust -> transaction.
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
assert mock_out.call_args_list[-1][0][1] == 1546
# ================================================================== #
# Will.set_invalidate - status flag cascade

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

@@ -0,0 +1,208 @@
"""
Tests for the "Invalidation fee policy" settings-dialog row (ADVANCED only).
Covers:
- The row is hidden in BASIC mode and visible in ADVANCED mode (like the
other advanced-only rows).
- The Electrum fee widget (``FeeSlider`` + ``FeeComboBox``) is bound to the
``INVALIDATION_FEE_POLICY`` config: choosing a Feerate / ETA / Mempool
policy persists its descriptor (``"<method>:<value>"``).
- "Reset to Default Setting" restores the policy to its default ("eta:2").
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_invalidation_fee.py
"""
import sys
import tempfile
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import patch
from PyQt6.QtWidgets import QApplication, QComboBox, QLabel, QPushButton
_app = QApplication.instance() or QApplication(sys.argv)
from electrum.fee_policy import FeeMethod, FeePolicy
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
from electrum.simple_config import SimpleConfig
import bal.gui.qt.plugin as plugin_mod
from bal.gui.qt.plugin import Plugin
LABEL_TEXT = "Invalidation fee policy"
def _isolated_config(**overrides):
"""An in-memory SimpleConfig that never touches the real Electrum config."""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
return SimpleConfig(opts)
def _build_dialog(user_type, config=None, **config_overrides):
"""Build the plugin settings dialog for *user_type* and return it.
*config* (optional) lets a test pass a pre-populated SimpleConfig; overrides
are used to build one otherwise. Note that overrides become command-line
options, which ``set()`` refuses to modify - use *config* when the test needs
a mutable stored value.
"""
cfg = config if config is not None else _isolated_config(**config_overrides)
plugin = Plugin(None, cfg, "bal")
plugin.get_window_title = lambda s: s
plugin.read_file = lambda *a: b""
plugin.broadcast_transactions = lambda *a, **k: None
plugin.update_all = lambda *a, **k: None
plugin.USER_TYPE.set(user_type)
captured = {}
with patch.object(
plugin_mod, "show_modal", lambda d: captured.setdefault("d", d) or True
):
plugin.settings_dialog(None)
return plugin, captured["d"]
def _find_label(dialog):
"""Return the "Invalidation fee policy" QLabel (wrapped by _hide_if_basic)."""
for lbl in dialog.findChildren(QLabel):
if lbl.text() == LABEL_TEXT:
return lbl
raise AssertionError("invalidation fee policy label not found")
# ------------------------------------------------------------------ #
# Visibility
# ------------------------------------------------------------------ #
def test_row_hidden_in_basic_and_visible_in_advanced():
_plugin, basic = _build_dialog("basic")
assert _find_label(basic).isHidden() is True
basic.close()
_plugin, advanced = _build_dialog("advanced")
assert _find_label(advanced).isHidden() is False
advanced.close()
def _user_type_combo(dialog):
"""Return the BASIC/ADVANCED selector combo of the settings dialog."""
for combo in dialog.findChildren(QComboBox):
if combo.itemText(0) == "BASIC" and combo.itemText(1) == "ADVANCED":
return combo
raise AssertionError("user type combo not found")
def test_row_follows_live_mode_switch():
"""The row must show/hide when the mode is toggled at runtime (not only
the initial creation-time visibility)."""
# BASIC -> ADVANCED (with the confirmation phrase).
plugin, dialog = _build_dialog("basic")
combo = _user_type_combo(dialog)
with patch(
"PyQt6.QtWidgets.QInputDialog.getText",
return_value=("at My Risk", True),
):
combo.setCurrentIndex(1)
assert plugin.USER_TYPE.get() == "advanced"
assert _find_label(dialog).isHidden() is False
dialog.close()
# ADVANCED -> BASIC (no confirmation needed).
plugin, dialog = _build_dialog("advanced")
combo = _user_type_combo(dialog)
combo.setCurrentIndex(0)
assert plugin.USER_TYPE.get() == "basic"
assert _find_label(dialog).isHidden() is True
dialog.close()
# ------------------------------------------------------------------ #
# Persistence
# ------------------------------------------------------------------ #
def test_default_policy_is_eta_two():
plugin, dialog = _build_dialog("advanced")
assert plugin.INVALIDATION_FEE_POLICY.default == "eta:2"
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:2"
dialog.close()
def test_hand_edited_policy_is_not_rewritten_on_open():
"""Opening the dialog must not normalize (snap/persist) a hand-edited
descriptor; only an explicit user change should do that."""
# "eta:3" is not a slider target (FEE_ETA_TARGETS = [1008, 144, 25, 10,
# 5, 2, 1]), so a naive slider.update() would snap it to "eta:2". The value
# is written to the isolated config (not passed as an override, which would
# become a non-modifiable command-line option and hide the bug).
config = _isolated_config()
config.set_key("bal_invalidation_fee_policy", "eta:3")
plugin, dialog = _build_dialog("advanced", config=config)
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:3"
dialog.close()
def test_choosing_a_fixed_feerate_is_persisted():
plugin, dialog = _build_dialog("advanced")
combo = dialog.findChildren(FeeComboBox)[0]
slider = dialog.findChildren(FeeSlider)[0]
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.FEERATE))
slider.fee_policy.value = 5000 # sat/kvB
slider.update()
assert plugin.INVALIDATION_FEE_POLICY.get() == "feerate:5000"
dialog.close()
def test_choosing_an_eta_target_is_persisted():
plugin, dialog = _build_dialog("advanced")
combo = dialog.findChildren(FeeComboBox)[0]
slider = dialog.findChildren(FeeSlider)[0]
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.ETA))
slider.fee_policy.value = 10 # blocks (valid FixedTargets entry)
slider.update()
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:10"
dialog.close()
# ------------------------------------------------------------------ #
# Reset to default
# ------------------------------------------------------------------ #
def test_reset_to_default_restores_eta_two():
plugin, dialog = _build_dialog("advanced")
combo = dialog.findChildren(FeeComboBox)[0]
slider = dialog.findChildren(FeeSlider)[0]
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.FEERATE))
slider.fee_policy.value = 5000
slider.update()
assert plugin.INVALIDATION_FEE_POLICY.get() == "feerate:5000"
reset_btn = [
b for b in dialog.findChildren(QPushButton)
if b.text() == "Reset to Default Setting"
][0]
reset_btn.click()
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:2"
assert slider.fee_policy.get_descriptor() == "eta:2"
assert FeePolicy("eta:2").method == FeeMethod.ETA
dialog.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation fee settings tests passed")