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:
296
tests/test_core_invalidation_fee.py
Normal file
296
tests/test_core_invalidation_fee.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
145
tests/test_core_willtree_father.py
Normal file
145
tests/test_core_willtree_father.py
Normal 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")
|
||||
208
tests/test_settings_invalidation_fee.py
Normal file
208
tests/test_settings_invalidation_fee.py
Normal 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")
|
||||
Reference in New Issue
Block a user