Add Willexecutors.is_valid + grey italic styling for invalid executors
This commit is contained in:
1629
tests/karen7
1629
tests/karen7
File diff suppressed because one or more lines are too long
446
tests/samanta7
Normal file
446
tests/samanta7
Normal file
File diff suppressed because one or more lines are too long
484
tests/test_core_will_invalidate.py
Normal file
484
tests/test_core_will_invalidate.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
Tests for will invalidation (cancellation) in ``bal.core.will``.
|
||||
|
||||
Covers:
|
||||
* Will.invalidate_will() - building the invalidation transaction
|
||||
* Will.set_invalidate() - marking will items as invalidated (status cascade)
|
||||
|
||||
The invalidation ("cancellation") transaction spends the same UTXOs that were
|
||||
committed to the time-locked will, making the original will transactions
|
||||
unspendable. This is the mechanism used when:
|
||||
* The will expires (locktime in the past)
|
||||
* The owner postpones a signed/sent will to a later date
|
||||
* The check-alive threshold is passed (dead-man's switch)
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 -m pytest tests/test_core_will_invalidate.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.will import Will, WillItem
|
||||
|
||||
# Patch Transaction.add_info_from_wallet so WillItem can parse the tx hex
|
||||
# without a live Electrum wallet connection.
|
||||
from electrum.transaction import Transaction
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||
# version 2). Reused across multiple test suites.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
# The prevout string that _VALID_TX_HEX spends (input 0).
|
||||
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
|
||||
|
||||
# Change address for the invalidation output.
|
||||
_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.
|
||||
|
||||
The input's ``_trusted_value_sats`` is set so that
|
||||
``invalidate_will`` can read the balance from it.
|
||||
"""
|
||||
heirs = {"alice": ["addr_alice", 5000, "30d"]}
|
||||
if extra_heirs:
|
||||
heirs.update(extra_heirs)
|
||||
item = WillItem({
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": heirs,
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
})
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
# Set the input value so the balance calculation works.
|
||||
item.tx.inputs()[0]._trusted_value_sats = value_sats
|
||||
if not valid:
|
||||
item.set_status("INVALIDATED", True)
|
||||
return item
|
||||
|
||||
|
||||
def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
|
||||
"""Create a minimal mock UTXO (wallet-side) matching a will input."""
|
||||
if prevout_str is None:
|
||||
prevout_str = _PREVOUT_STR
|
||||
utxo = MagicMock()
|
||||
utxo.prevout.to_str.return_value = prevout_str
|
||||
utxo.is_coinbase_output.return_value = is_coinbase
|
||||
utxo.block_height = 1
|
||||
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."""
|
||||
wallet = MagicMock()
|
||||
wallet.get_utxos.return_value = utxos
|
||||
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
|
||||
wallet.network = MagicMock()
|
||||
return wallet
|
||||
|
||||
|
||||
def _run_invalidate(will, wallet, fees_per_byte=10, current_height=800000):
|
||||
"""Run ``Will.invalidate_will`` with mocked Electrum tx building.
|
||||
|
||||
Returns ``(result, mock_from_io, mock_out)`` so tests can inspect
|
||||
the calls to ``PartialTransaction.from_io`` and
|
||||
``PartialTxOutput.from_address_and_value``.
|
||||
"""
|
||||
mock_output = MagicMock()
|
||||
mock_output.value = 0
|
||||
mock_output.is_change = False
|
||||
|
||||
mock_tx = MagicMock()
|
||||
mock_tx.txid.return_value = "invalidation_txid"
|
||||
mock_tx.estimated_size.return_value = 200
|
||||
|
||||
with patch("bal.core.will.Util.get_current_height", return_value=current_height), \
|
||||
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) as mock_from_io:
|
||||
result = Will.invalidate_will(will, wallet, fees_per_byte)
|
||||
return result, mock_from_io, mock_out
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# Will.invalidate_will - building the cancellation transaction
|
||||
# ================================================================== #
|
||||
|
||||
class TestInvalidateWill:
|
||||
"""Tests for ``Will.invalidate_will()``: the cancellation transaction."""
|
||||
|
||||
def test_basic_returns_tx(self):
|
||||
"""A single valid will item with a matching wallet UTXO produces an
|
||||
invalidation transaction."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||
|
||||
assert result is not None, "should return a transaction"
|
||||
|
||||
def test_basic_rbf_enabled(self):
|
||||
"""The invalidation tx has RBF (Replace-By-Fee) enabled."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
result, _, _ = _run_invalidate(will, wallet)
|
||||
|
||||
result.set_rbf.assert_called_with(True)
|
||||
|
||||
def test_basic_locktime_is_current_height(self):
|
||||
"""The invalidation tx locktime equals the current block height."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
current_height = 750000
|
||||
|
||||
_, mock_from_io, _ = _run_invalidate(will, wallet, current_height=current_height)
|
||||
|
||||
# from_io(inputs, outputs, locktime=<height>, version=2)
|
||||
_, kwargs = mock_from_io.call_args
|
||||
assert kwargs["locktime"] == current_height
|
||||
|
||||
def test_basic_version_2(self):
|
||||
"""The invalidation tx uses Bitcoin transaction version 2."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
_, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||
|
||||
_, kwargs = mock_from_io.call_args
|
||||
assert kwargs["version"] == 2
|
||||
|
||||
def test_basic_output_value_deducts_fee(self):
|
||||
"""The invalidation output value is balance minus fee.
|
||||
|
||||
Fee = estimated_size * fees_per_byte.
|
||||
"""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
fees_per_byte = 10
|
||||
|
||||
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=fees_per_byte)
|
||||
|
||||
# The second call to from_address_and_value uses balance - fee.
|
||||
# estimated_size returns 200, so fee = 200 * 10 = 2000.
|
||||
# Expected output value = 1000000 - 2000 = 998000.
|
||||
second_call_value = mock_out.call_args_list[1][0][1]
|
||||
assert second_call_value == 998000
|
||||
|
||||
def test_basic_spends_correct_utxos(self):
|
||||
"""The invalidation tx spends the same UTXOs as the will."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
_, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||
|
||||
# First positional arg is the list of UTXOs to spend.
|
||||
spent_utxos = mock_from_io.call_args[0][0]
|
||||
assert len(spent_utxos) == 1
|
||||
assert spent_utxos[0].prevout.to_str() == _PREVOUT_STR
|
||||
|
||||
def test_no_matching_utxos_returns_none(self):
|
||||
"""When wallet UTXOs don't match any will inputs, returns None."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo(prevout_str="aaaa:1")])
|
||||
|
||||
result, _, _ = _run_invalidate(will, wallet)
|
||||
assert result is None
|
||||
|
||||
def test_no_valid_items_returns_none(self):
|
||||
"""When all will items are INVALIDATED, returns None."""
|
||||
item = _make_willitem(valid=False)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
result, _, _ = _run_invalidate(will, wallet)
|
||||
assert result is None
|
||||
|
||||
def test_empty_will_returns_none(self):
|
||||
"""An empty will dictionary returns None."""
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
result, _, _ = _run_invalidate({}, wallet)
|
||||
assert result is None
|
||||
|
||||
def test_skips_young_coinbase(self):
|
||||
"""Coinbase UTXOs younger than current_height + 100 are skipped."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
# Coinbase UTXO: block_height = 800050, current_height = 800000
|
||||
# 800050 < 800000 + 100 => skipped
|
||||
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
|
||||
utxo.block_height = 800050
|
||||
wallet = _mock_wallet([utxo])
|
||||
|
||||
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
|
||||
assert result is None
|
||||
|
||||
def test_includes_mature_coinbase(self):
|
||||
"""Coinbase UTXOs at or above current_height + 100 are included."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
|
||||
utxo.block_height = 800150 # >= 800000 + 100
|
||||
wallet = _mock_wallet([utxo])
|
||||
|
||||
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
|
||||
assert result is not None
|
||||
|
||||
def test_fee_exceeds_balance_returns_none(self):
|
||||
"""When the fee exceeds the balance, returns None.
|
||||
|
||||
estimated_size (200) * fees_per_byte (100) = 20000 > balance (100).
|
||||
"""
|
||||
item = _make_willitem(value_sats=100)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
|
||||
|
||||
assert result is None
|
||||
# from_io is still called once (for fee estimation), but the
|
||||
# result is discarded because balance - fee <= 0.
|
||||
assert mock_from_io.call_count == 1
|
||||
|
||||
def test_only_valid_items_contribute_balance(self):
|
||||
"""INVALIDATED will items are excluded from the balance."""
|
||||
valid_item = _make_willitem(value_sats=1000000, valid=True)
|
||||
invalid_item = _make_willitem(value_sats=2000000, valid=False)
|
||||
will = {"valid": valid_item, "invalid": invalid_item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||
|
||||
# Balance = 1000000 (valid only), fee = 200 * 10 = 2000
|
||||
# Output value = 998000
|
||||
second_call_value = mock_out.call_args_list[1][0][1]
|
||||
assert second_call_value == 998000
|
||||
|
||||
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."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||
|
||||
# First from_address_and_value call: value = balance (1000000)
|
||||
first_call_value = mock_out.call_args_list[0][0][1]
|
||||
assert first_call_value == 1000000
|
||||
|
||||
def test_output_address_is_change_address(self):
|
||||
"""The invalidation output goes to the wallet's change address."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
_, _, mock_out = _run_invalidate(will, wallet)
|
||||
|
||||
# Both calls to from_address_and_value use the change address.
|
||||
for call in mock_out.call_args_list:
|
||||
assert call[0][0] == _CHANGE_ADDR
|
||||
|
||||
def test_multiple_utxos_all_matched(self):
|
||||
"""Multiple matching UTXOs are all included in the invalidation."""
|
||||
item1 = _make_willitem(value_sats=500000)
|
||||
item2 = _make_willitem(value_sats=300000)
|
||||
will = {"tx1": item1, "tx2": item2}
|
||||
|
||||
# Two UTXOs with different prevouts matching the two will items.
|
||||
# Since both items use the same _VALID_TX_HEX, their prevout is the
|
||||
# same. To test multiple UTXOs, we need a second tx hex with a
|
||||
# different input.
|
||||
#
|
||||
# However, get_all_inputs deduplicates by prevout_str, so even with
|
||||
# two items sharing the same prevout, only one entry is added to
|
||||
# prevout_to_spend. The first matching UTXO is what matters.
|
||||
utxos = [_make_utxo()]
|
||||
wallet = _mock_wallet(utxos)
|
||||
|
||||
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||
assert result is not None
|
||||
# Only 1 UTXO spent (deduplication of shared prevout)
|
||||
spent_utxos = mock_from_io.call_args[0][0]
|
||||
assert len(spent_utxos) == 1
|
||||
|
||||
def test_zero_fees_per_byte(self):
|
||||
"""With zero fee rate, the full balance goes to the output."""
|
||||
item = _make_willitem(value_sats=1000000)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
|
||||
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=0)
|
||||
|
||||
assert mock_from_io.call_count == 2 # two calls (both succeed)
|
||||
# Output value = balance - 0 = 1000000
|
||||
second_call_value = mock_out.call_args_list[1][0][1]
|
||||
assert second_call_value == 1000000
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# Will.set_invalidate - status flag cascade
|
||||
# ================================================================== #
|
||||
|
||||
class TestSetInvalidate:
|
||||
"""Tests for ``Will.set_invalidate()``: marking will items as invalidated."""
|
||||
|
||||
def test_single_item_no_children(self):
|
||||
"""Invalidating a single will item sets INVALIDATED and clears VALID."""
|
||||
item = _make_willitem(valid=True)
|
||||
item.children = {}
|
||||
will = {"willid1": item}
|
||||
|
||||
Will.set_invalidate("willid1", will)
|
||||
|
||||
assert item.get_status("INVALIDATED") is True
|
||||
assert item.get_status("VALID") is False
|
||||
|
||||
def test_cascades_to_direct_children(self):
|
||||
"""Invalidating a parent cascades INVALIDATED to its children."""
|
||||
parent = _make_willitem(valid=True)
|
||||
child = _make_willitem(valid=True)
|
||||
|
||||
parent.children = {"child_id": ["child_id", 0, 0]}
|
||||
child.children = {}
|
||||
|
||||
will = {"parent_id": parent, "child_id": child}
|
||||
|
||||
Will.set_invalidate("parent_id", will)
|
||||
|
||||
assert parent.get_status("INVALIDATED") is True
|
||||
assert parent.get_status("VALID") is False
|
||||
assert child.get_status("INVALIDATED") is True
|
||||
assert child.get_status("VALID") is False
|
||||
|
||||
def test_cascades_to_grandchildren(self):
|
||||
"""Invalidating cascades through multiple levels of descendants."""
|
||||
root = _make_willitem(valid=True)
|
||||
branch = _make_willitem(valid=True)
|
||||
leaf = _make_willitem(valid=True)
|
||||
|
||||
root.children = {"branch_id": ["branch_id", 0, 0]}
|
||||
branch.children = {"leaf_id": ["leaf_id", 0, 0]}
|
||||
leaf.children = {}
|
||||
|
||||
will = {
|
||||
"root_id": root,
|
||||
"branch_id": branch,
|
||||
"leaf_id": leaf,
|
||||
}
|
||||
|
||||
Will.set_invalidate("root_id", will)
|
||||
|
||||
for name, item in [("root", root), ("branch", branch), ("leaf", leaf)]:
|
||||
assert item.get_status("INVALIDATED") is True, f"{name} should be INVALIDATED"
|
||||
assert item.get_status("VALID") is False, f"{name} should not be VALID"
|
||||
|
||||
def test_empty_children_dict(self):
|
||||
"""A will item with an empty children dict is a leaf (no cascade)."""
|
||||
item = _make_willitem(valid=True)
|
||||
item.children = {}
|
||||
will = {"wid": item}
|
||||
|
||||
Will.set_invalidate("wid", will)
|
||||
|
||||
assert item.get_status("INVALIDATED") is True
|
||||
assert item.get_status("VALID") is False
|
||||
|
||||
def test_does_not_affect_siblings(self):
|
||||
"""Invalidating one item does not affect unrelated siblings."""
|
||||
item_a = _make_willitem(valid=True)
|
||||
item_b = _make_willitem(valid=True)
|
||||
|
||||
item_a.children = {}
|
||||
item_b.children = {}
|
||||
|
||||
will = {"a": item_a, "b": item_b}
|
||||
|
||||
Will.set_invalidate("a", will)
|
||||
|
||||
assert item_a.get_status("INVALIDATED") is True
|
||||
assert item_a.get_status("VALID") is False
|
||||
assert item_b.get_status("INVALIDATED") is False
|
||||
assert item_b.get_status("VALID") is True
|
||||
|
||||
def test_multiple_children(self):
|
||||
"""Invalidating a parent with multiple children cascades to all of them."""
|
||||
parent = _make_willitem(valid=True)
|
||||
child1 = _make_willitem(valid=True)
|
||||
child2 = _make_willitem(valid=True)
|
||||
|
||||
parent.children = {
|
||||
"c1": ["c1", 0, 0],
|
||||
"c2": ["c2", 0, 0],
|
||||
}
|
||||
child1.children = {}
|
||||
child2.children = {}
|
||||
|
||||
will = {"p": parent, "c1": child1, "c2": child2}
|
||||
|
||||
Will.set_invalidate("p", will)
|
||||
|
||||
assert parent.get_status("INVALIDATED") is True
|
||||
assert child1.get_status("INVALIDATED") is True
|
||||
assert child2.get_status("INVALIDATED") is True
|
||||
|
||||
def test_idempotent(self):
|
||||
"""Setting INVALIDATED twice on the same item is a safe no-op."""
|
||||
item = _make_willitem(valid=True)
|
||||
item.children = {}
|
||||
will = {"wid": item}
|
||||
|
||||
Will.set_invalidate("wid", will)
|
||||
Will.set_invalidate("wid", will)
|
||||
|
||||
assert item.get_status("INVALIDATED") is True
|
||||
assert item.get_status("VALID") is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All invalidation tests passed")
|
||||
401
tests/test_group_e_karen7_invalidate.py
Normal file
401
tests/test_group_e_karen7_invalidate.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Group E - karen7 wallet: build the inheritance then generate the
|
||||
cancellation (invalidation) transaction.
|
||||
|
||||
This test exercises the full pipeline with REAL Electrum transaction
|
||||
building (no mocking of from_io, from_address_and_value, or is_address):
|
||||
|
||||
1. Load the karen7 regtest wallet (heirs + UTXOs).
|
||||
2. Set Electrum to regtest mode so bcrt1q addresses validate.
|
||||
3. Build the inheritance transactions via ``Heirs.buildTransactions``
|
||||
using real ``PartialTransaction.from_io`` and real
|
||||
``PartialTxOutput.from_address_and_value``.
|
||||
4. Wrap each built transaction into a ``WillItem`` with VALID status.
|
||||
5. Populate ``_trusted_value_sats`` on each input (what
|
||||
``add_info_from_wallet`` does in the real flow).
|
||||
6. Call ``Will.invalidate_will()`` to generate the cancellation tx.
|
||||
7. Assert that the cancellation tx is well-formed.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Electrum regtest mode (replaces mocking bitcoin.is_address)
|
||||
# ------------------------------------------------------------------ #
|
||||
from electrum import constants
|
||||
|
||||
constants.net = constants.BitcoinRegtest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum import bitcoin
|
||||
from electrum.transaction import (
|
||||
PartialTransaction,
|
||||
PartialTxInput,
|
||||
PartialTxOutput,
|
||||
TxOutpoint,
|
||||
)
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.will import Will, WillItem
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Load karen7 wallet data
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
|
||||
with open(_WALLET_PATH) as _f:
|
||||
_KAREN7_DATA = json.load(_f)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Minimal real implementations (no MagicMock)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class _Karen7Wallet:
|
||||
"""Minimal wallet implementation for tests.
|
||||
|
||||
Provides only the methods that ``buildTransactions`` and
|
||||
``invalidate_will`` call. ``network`` is ``None`` so
|
||||
``Util.get_current_height`` returns 0 without network access.
|
||||
"""
|
||||
|
||||
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
|
||||
|
||||
def __init__(self, utxos):
|
||||
self._utxos = utxos
|
||||
self.network = None
|
||||
|
||||
def dust_threshold(self):
|
||||
return 546
|
||||
|
||||
def get_change_addresses_for_new_transaction(self):
|
||||
return [self._CHANGE_ADDR]
|
||||
|
||||
def get_utxos(self):
|
||||
return self._utxos
|
||||
|
||||
|
||||
class _Karen7BalPlugin:
|
||||
"""Minimal bal_plugin config for tests.
|
||||
|
||||
Provides only the config accessors that ``buildTransactions`` reads.
|
||||
No will-executors (``NO_WILLEXECUTOR = True``).
|
||||
"""
|
||||
|
||||
class _NoWillexecutor:
|
||||
def get(self, *a, **kw):
|
||||
return True
|
||||
|
||||
class _MaxFee:
|
||||
def get(self, *a, **kw):
|
||||
return 500000
|
||||
|
||||
class _EmptyWelist:
|
||||
default = {}
|
||||
def get(self, *a, **kw):
|
||||
return {"regtest": {}}
|
||||
|
||||
NO_WILLEXECUTOR = _NoWillexecutor()
|
||||
MAX_WILLEXECUTOR_FEE = _MaxFee()
|
||||
WILLEXECUTORS = _EmptyWelist()
|
||||
|
||||
def get_decimal_point(self):
|
||||
return 8
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# UTXO builder from karen7 data (real PartialTxInput objects)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_real_utxos(data):
|
||||
"""Build real ``PartialTxInput`` objects from the karen7 wallet JSON.
|
||||
|
||||
Each UTXO gets a proper ``scriptpubkey`` so that ``is_segwit()``
|
||||
returns ``True`` and the resulting ``PartialTransaction`` can
|
||||
compute a real ``txid()``.
|
||||
"""
|
||||
utxos = []
|
||||
txo = data.get("txo", {})
|
||||
for txid, outputs in txo.items():
|
||||
if not isinstance(outputs, dict):
|
||||
continue
|
||||
for addr, out_map in outputs.items():
|
||||
if not isinstance(out_map, dict):
|
||||
continue
|
||||
for idx, info in out_map.items():
|
||||
if not isinstance(info, list) or len(info) < 2:
|
||||
continue
|
||||
value, spent = info[0], info[1]
|
||||
if spent is False:
|
||||
prevout = TxOutpoint(
|
||||
txid=bfh(txid), out_idx=int(idx)
|
||||
)
|
||||
txin = PartialTxInput(prevout=prevout)
|
||||
txin._trusted_value_sats = value
|
||||
txin._TxInput__address = addr
|
||||
txin._TxInput__scriptpubkey = bitcoin.address_to_script(
|
||||
addr
|
||||
)
|
||||
txin.is_mine = True
|
||||
utxos.append(txin)
|
||||
return utxos
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Build karen7 UTXO value lookup (for populating tx inputs)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_utxo_value_map(data):
|
||||
"""Return ``{prevout_str: value_sats}`` from karen7 wallet data."""
|
||||
m = {}
|
||||
txo = data.get("txo", {})
|
||||
for txid, outputs in txo.items():
|
||||
if not isinstance(outputs, dict):
|
||||
continue
|
||||
for addr, out_map in outputs.items():
|
||||
if not isinstance(out_map, dict):
|
||||
continue
|
||||
for idx, info in out_map.items():
|
||||
if not isinstance(info, list) or len(info) < 2:
|
||||
continue
|
||||
value, spent = info[0], info[1]
|
||||
if spent is False:
|
||||
m[f"{txid}:{idx}"] = value
|
||||
return m
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Populate _trusted_value_sats on WillItem tx inputs
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _populate_input_values(will, utxo_value_map):
|
||||
"""Set ``_trusted_value_sats`` on every input of every will tx.
|
||||
|
||||
This is the equivalent of what ``add_info_from_wallet`` does in the
|
||||
real flow: looking up the UTXO value and attaching it to the input.
|
||||
"""
|
||||
for wid, wi in will.items():
|
||||
for txin in wi.tx.inputs():
|
||||
prevout_str = txin.prevout.to_str()
|
||||
if txin._trusted_value_sats is None and prevout_str in utxo_value_map:
|
||||
txin._trusted_value_sats = utxo_value_map[prevout_str]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Inheritance builder (real Electrum, no mocking)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_inheritance(utxos):
|
||||
"""Build the inheritance transactions from karen7's heirs and UTXOs.
|
||||
|
||||
Returns ``(txs, heirs_model)`` where ``txs`` is a dict of real
|
||||
``PartialTransaction`` objects produced by ``Heirs.buildTransactions``.
|
||||
"""
|
||||
heirs_data = _KAREN7_DATA["heirs"]
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
|
||||
wallet = _Karen7Wallet(utxos)
|
||||
bal_plugin = _Karen7BalPlugin()
|
||||
|
||||
txs = h.buildTransactions(bal_plugin, wallet, tx_fees=1, utxos=utxos)
|
||||
return txs or {}, h
|
||||
|
||||
|
||||
def _txs_to_will(txs, heirs_data):
|
||||
"""Convert built transactions into a ``{txid: WillItem}`` will dict
|
||||
with VALID status, using karen7's heir data."""
|
||||
will = {}
|
||||
for txid, tx in txs.items():
|
||||
item_dict = {
|
||||
"tx": tx,
|
||||
"heirs": copy.deepcopy(heirs_data),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": 1,
|
||||
}
|
||||
wi = WillItem(item_dict, _id=txid)
|
||||
will[txid] = wi
|
||||
return will
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# Build and invalidate tests
|
||||
# ================================================================== #
|
||||
|
||||
class TestKaren7BuildAndInvalidate:
|
||||
"""Load the real karen7 regtest wallet, build the inheritance
|
||||
transactions with real Electrum, then generate the cancellation
|
||||
(invalidation) transaction."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
"""Shared setup: build UTXOs, inheritance, and will once."""
|
||||
self.utxos = _build_real_utxos(_KAREN7_DATA)
|
||||
self.utxo_value_map = _build_utxo_value_map(_KAREN7_DATA)
|
||||
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
|
||||
|
||||
self.heirs_data = _KAREN7_DATA["heirs"]
|
||||
self.txs, self.heirs_model = _build_inheritance(self.utxos)
|
||||
|
||||
self.wallet = _Karen7Wallet(self.utxos)
|
||||
self.will = _txs_to_will(self.txs, self.heirs_data)
|
||||
_populate_input_values(self.will, self.utxo_value_map)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Build tests
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_build_produces_real_partial_transactions(self):
|
||||
"""Building the inheritance produces real PartialTransaction objects."""
|
||||
assert self.txs, "buildTransactions returned empty"
|
||||
for txid, tx in self.txs.items():
|
||||
assert isinstance(tx, PartialTransaction), (
|
||||
f"tx {txid} should be a real PartialTransaction, "
|
||||
f"got {type(tx).__name__}"
|
||||
)
|
||||
|
||||
def test_built_txs_have_valid_txid(self):
|
||||
"""Every built transaction has a computable txid (not None)."""
|
||||
assert self.txs, "no transactions built"
|
||||
for txid, tx in self.txs.items():
|
||||
computed = tx.txid()
|
||||
assert computed is not None, (
|
||||
f"tx {txid} has txid() == None"
|
||||
)
|
||||
assert computed == txid, (
|
||||
f"txid mismatch: key={txid}, computed={computed}"
|
||||
)
|
||||
|
||||
def test_built_tx_has_karen7_heirs(self):
|
||||
"""The built will contains karen7's four heirs."""
|
||||
assert len(self.heirs_model) == 4
|
||||
assert list(self.heirs_model.keys()) == [
|
||||
"aaaa", "lucia", "mario", "mario2"
|
||||
]
|
||||
|
||||
def test_will_items_are_valid(self):
|
||||
"""Every WillItem in the will starts with VALID=True."""
|
||||
assert self.will, "will is empty"
|
||||
for wid, wi in self.will.items():
|
||||
assert wi.get_status("VALID") is True, (
|
||||
f"WillItem {wid} should be VALID"
|
||||
)
|
||||
|
||||
def test_will_inputs_have_values(self):
|
||||
"""After populating, every tx input has a non-None value_sats."""
|
||||
for wid, wi in self.will.items():
|
||||
for i, txin in enumerate(wi.tx.inputs()):
|
||||
assert txin.value_sats() is not None, (
|
||||
f"WillItem {wid} input {i} "
|
||||
f"({txin.prevout.to_str()}) has no value"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Invalidation tests
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_invalidate_returns_real_tx(self):
|
||||
"""Calling invalidate_will produces a real PartialTransaction."""
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is not None, "invalidate_will returned None"
|
||||
assert isinstance(result, PartialTransaction), (
|
||||
f"expected PartialTransaction, got {type(result).__name__}"
|
||||
)
|
||||
|
||||
def test_invalidation_tx_has_rbf(self):
|
||||
"""The cancellation tx has RBF enabled."""
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is not None
|
||||
assert result.is_rbf_enabled() is True
|
||||
|
||||
def test_invalidation_tx_locktime(self):
|
||||
"""The cancellation tx locktime equals the current height.
|
||||
|
||||
With ``network=None`` the current height is 0.
|
||||
"""
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is not None
|
||||
assert result.locktime == 0
|
||||
|
||||
def test_invalidation_tx_version_2(self):
|
||||
"""The cancellation tx uses Bitcoin transaction version 2."""
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is not None
|
||||
assert result.version == 2
|
||||
|
||||
def test_invalidation_spends_correct_utxos(self):
|
||||
"""The cancellation tx spends the same UTXOs as the will."""
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is not None
|
||||
|
||||
will_prevouts = set()
|
||||
for wi in self.will.values():
|
||||
for txin in wi.tx.inputs():
|
||||
will_prevouts.add(txin.prevout.to_str())
|
||||
|
||||
for txin in result.inputs():
|
||||
assert txin.prevout.to_str() in will_prevouts, (
|
||||
f"inval input {txin.prevout.to_str()} not in will UTXOs"
|
||||
)
|
||||
|
||||
def test_invalidation_output_to_change_address(self):
|
||||
"""The cancellation output goes to the wallet's change address."""
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is not None
|
||||
|
||||
outputs = result.outputs()
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].address == _Karen7Wallet._CHANGE_ADDR
|
||||
|
||||
def test_invalidation_output_value_deducts_fee(self):
|
||||
"""The output value equals balance minus estimated fee.
|
||||
|
||||
balance = sum of input values (from will inputs).
|
||||
fee = estimated_size * fees_per_byte.
|
||||
"""
|
||||
fees_per_byte = 10
|
||||
result = Will.invalidate_will(
|
||||
self.will, self.wallet, fees_per_byte
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
balance = sum(txin.value_sats() for txin in result.inputs()
|
||||
if txin.value_sats() is not None)
|
||||
fee = result.estimated_size() * fees_per_byte
|
||||
expected = balance - fee
|
||||
|
||||
assert result.outputs()[0].value == expected, (
|
||||
f"output value {result.outputs()[0].value} != "
|
||||
f"expected {expected} (balance={balance}, fee={fee})"
|
||||
)
|
||||
|
||||
def test_all_invalidated_returns_none(self):
|
||||
"""When all will items are INVALIDATED, returns None."""
|
||||
for wid in self.will:
|
||||
self.will[wid].set_status("INVALIDATED", True)
|
||||
|
||||
result = Will.invalidate_will(self.will, self.wallet, 10)
|
||||
assert result is None
|
||||
|
||||
def test_empty_will_returns_none(self):
|
||||
"""An empty will dictionary returns None."""
|
||||
result = Will.invalidate_will({}, self.wallet, 10)
|
||||
assert result is None
|
||||
580
tests/test_no_willexecutor_karen7.py
Normal file
580
tests/test_no_willexecutor_karen7.py
Normal file
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
Test the error when no will-executor is selected and ``no_willexecutor``
|
||||
is ``False`` (the "Add transactions without willexecutor" checkbox is
|
||||
unchecked), using the real **karen7** regtest wallet.
|
||||
|
||||
Scenarios covered by this test
|
||||
------------------------------
|
||||
|
||||
A. ``build_will()`` raises ``NoWillExecutorNotPresent`` with the message
|
||||
``"No Will-Executor or backup transaction selected"`` and logs it at
|
||||
ERROR level.
|
||||
|
||||
B. ``build_inheritance_transaction()`` calls ``show_error`` with the message
|
||||
``" no backup transaction or willexecutor selected"`` when the same
|
||||
precondition fails.
|
||||
|
||||
C. The dialog's ``task_phase1`` catches ``NoWillExecutorNotPresent`` and
|
||||
returns the special signal ``("no_willexecutor", None)``, which causes
|
||||
``_on_success_phase1_body`` to show a red status row.
|
||||
|
||||
D. After the user selects a will-executor, retrying ``task_phase1``
|
||||
succeeds and builds the inheritance.
|
||||
|
||||
Run::
|
||||
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from electrum import constants
|
||||
|
||||
constants.net = constants.BitcoinRegtest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum import bitcoin
|
||||
from electrum.transaction import PartialTxInput, TxOutpoint
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.will import Will, WillItem, NoWillExecutorNotPresent, NotCompleteWillException
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Load karen7 wallet data
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
|
||||
with open(_WALLET_PATH) as _f:
|
||||
_KAREN7_DATA = json.load(_f)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Minimal wallet stub
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class _Karen7Wallet:
|
||||
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
|
||||
|
||||
def __init__(self, utxos):
|
||||
self._utxos = utxos
|
||||
self.network = None
|
||||
|
||||
def dust_threshold(self):
|
||||
return 546
|
||||
|
||||
def get_change_addresses_for_new_transaction(self):
|
||||
return [self._CHANGE_ADDR]
|
||||
|
||||
def get_utxos(self):
|
||||
return self._utxos
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bal plugin config: NO_WILLEXECUTOR = False, empty willexecutors
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class _Karen7BalPlugin:
|
||||
"""NO_WILLEXECUTOR returns False -> the system REQUIRES a selected
|
||||
will-executor. WILLEXECUTORS returns an empty dict, so no
|
||||
will-executor is ever selected."""
|
||||
|
||||
class _ToggleAttr:
|
||||
"""Config stub whose value can be toggled from outside."""
|
||||
|
||||
def __init__(self, initial=None):
|
||||
self._value = initial
|
||||
|
||||
def get(self, *a, **kw):
|
||||
return self._value
|
||||
|
||||
def set(self, v):
|
||||
self._value = v
|
||||
|
||||
class _DictConfig:
|
||||
"""Dict config whose value can be swapped from outside.
|
||||
|
||||
Mirrors the real ``BalConfig`` interface: ``.get()`` returns
|
||||
the stored dict, ``.set()`` replaces it, and ``.default``
|
||||
provides the fallback defaults.
|
||||
"""
|
||||
|
||||
def __init__(self, value, default):
|
||||
self._data = value
|
||||
self.default = default
|
||||
|
||||
def get(self, *a, **kw):
|
||||
return self._data
|
||||
|
||||
def set(self, v):
|
||||
self._data = v
|
||||
|
||||
def __init__(self):
|
||||
import bal.core.willexecutors as _we
|
||||
_we.chainname = "regtest"
|
||||
self._no_willexecutor = self._ToggleAttr(False)
|
||||
self._willexecutors = self._DictConfig(
|
||||
{"regtest": {}},
|
||||
default={"regtest": {}},
|
||||
)
|
||||
self._will_settings = self._DictConfig(
|
||||
{"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
|
||||
default={"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
|
||||
)
|
||||
self._max_fee = self._ToggleAttr(500000)
|
||||
self._user_type = self._ToggleAttr("simple")
|
||||
self._enable_multiverse = self._ToggleAttr(False)
|
||||
|
||||
@property
|
||||
def NO_WILLEXECUTOR(self):
|
||||
return self._no_willexecutor
|
||||
|
||||
@NO_WILLEXECUTOR.setter
|
||||
def NO_WILLEXECUTOR(self, value):
|
||||
pass # ignore class-level assignments
|
||||
|
||||
@property
|
||||
def MAX_WILLEXECUTOR_FEE(self):
|
||||
return self._max_fee
|
||||
|
||||
@property
|
||||
def WILLEXECUTORS(self):
|
||||
return self._willexecutors
|
||||
|
||||
@property
|
||||
def WILL_SETTINGS(self):
|
||||
return self._will_settings
|
||||
|
||||
@property
|
||||
def USER_TYPE(self):
|
||||
return self._user_type
|
||||
|
||||
@property
|
||||
def ENABLE_MULTIVERSE(self):
|
||||
return self._enable_multiverse
|
||||
|
||||
def get_decimal_point(self):
|
||||
return 8
|
||||
|
||||
def is_basic_mode(self):
|
||||
return True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Build real UTXOs from karen7 data
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_real_utxos(data):
|
||||
utxos = []
|
||||
txo = data.get("txo", {})
|
||||
for txid, outputs in txo.items():
|
||||
if not isinstance(outputs, dict):
|
||||
continue
|
||||
for addr, out_map in outputs.items():
|
||||
if not isinstance(out_map, dict):
|
||||
continue
|
||||
for idx, info in out_map.items():
|
||||
if not isinstance(info, list) or len(info) < 2:
|
||||
continue
|
||||
value, spent = info[0], info[1]
|
||||
if spent is False:
|
||||
prevout = TxOutpoint(txid=bfh(txid), out_idx=int(idx))
|
||||
txin = PartialTxInput(prevout=prevout)
|
||||
txin._trusted_value_sats = value
|
||||
txin._TxInput__address = addr
|
||||
txin._TxInput__scriptpubkey = bitcoin.address_to_script(addr)
|
||||
txin.is_mine = True
|
||||
utxos.append(txin)
|
||||
return utxos
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# FakeBalWindow - replicates the relevant subset of BalWalletWindow
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeBalWindow:
|
||||
def __init__(self, heirs_obj, bal_plugin, wallet):
|
||||
self.heirs = heirs_obj
|
||||
self.bal_plugin = bal_plugin
|
||||
self.wallet = wallet
|
||||
self.window = type("_Window", (), {"wallet": wallet})()
|
||||
self.willitems = {}
|
||||
self.will = {}
|
||||
self.willexecutors = {}
|
||||
self.no_willexecutor = None
|
||||
self.date_to_check = None
|
||||
self.will_settings = bal_plugin.WILL_SETTINGS.get()
|
||||
|
||||
def init_class_variables(self):
|
||||
if not self.heirs:
|
||||
raise Exception("Heirs are not defined")
|
||||
self.date_to_check = time.time()
|
||||
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, bal_window=self
|
||||
)
|
||||
|
||||
def check_will(self):
|
||||
"""Raise NotCompleteWillException when willitems is empty (no valid
|
||||
transactions exist yet), matching the real check_will behavior."""
|
||||
if not self.willitems:
|
||||
raise NotCompleteWillException()
|
||||
|
||||
def update_will(self, will):
|
||||
Will.update_will(self.willitems, will)
|
||||
self.willitems.update(will)
|
||||
Will.normalize_will(self.willitems, self.wallet)
|
||||
|
||||
def build_will(self):
|
||||
"""Replicates BalWalletWindow.build_will() logic."""
|
||||
will = {}
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, bal_window=self
|
||||
)
|
||||
if not self.no_willexecutor:
|
||||
f = False
|
||||
for _u, w in self.willexecutors.items():
|
||||
if Willexecutors.is_selected(
|
||||
w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
||||
):
|
||||
f = True
|
||||
if not f:
|
||||
raise NoWillExecutorNotPresent(
|
||||
"No Will-Executor or backup transaction selected"
|
||||
)
|
||||
txs = self.heirs.get_transactions(
|
||||
self.bal_plugin,
|
||||
self.wallet,
|
||||
self.will_settings["baltx_fees"],
|
||||
None,
|
||||
self.date_to_check,
|
||||
)
|
||||
creation_time = time.time()
|
||||
if txs:
|
||||
for txid in txs:
|
||||
tx = {}
|
||||
tx["tx"] = txs[txid]
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
return self.willitems
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Simulated BalBuildWillDialog (no Qt, just the logic)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeBuildWillDialog:
|
||||
"""Replicates the relevant parts of BalBuildWillDialog for the
|
||||
``task_phase1`` error handling logic, without Qt."""
|
||||
|
||||
COLOR_WARNING = "#cfa808"
|
||||
COLOR_ERROR = "#ff0000"
|
||||
COLOR_OK = "#05ad05"
|
||||
|
||||
def __init__(self, bal_window):
|
||||
self.bal_window = bal_window
|
||||
self.labels = []
|
||||
self.have_to_sign = None
|
||||
self._no_we_buttons_added = False
|
||||
self._no_we_layout = None
|
||||
self._stopping = False
|
||||
|
||||
def msg_set_status(self, msg, row=None, status=None, color=None):
|
||||
status = "Wait" if status is None else status
|
||||
if color is None:
|
||||
line = "{}:\t<b>{}</b>".format(msg, status)
|
||||
else:
|
||||
line = "{}:\t<font color={}><b>{}</b></font>".format(
|
||||
msg, color, status
|
||||
)
|
||||
self.labels.append(line)
|
||||
return len(self.labels) - 1
|
||||
|
||||
def msg_error(self, e):
|
||||
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
|
||||
|
||||
def msg_edit_row(self, line, row=None):
|
||||
try:
|
||||
self.labels[row] = line
|
||||
except Exception:
|
||||
self.labels.append(line)
|
||||
row = len(self.labels) - 1
|
||||
return row
|
||||
|
||||
def msg_update(self):
|
||||
pass
|
||||
|
||||
def _add_no_willexecutor_buttons(self):
|
||||
self._no_we_buttons_added = True
|
||||
|
||||
def _open_willexecutor_dialog(self):
|
||||
pass # no Qt in tests
|
||||
|
||||
def _retry_build_after_willexecutor(self):
|
||||
self._no_we_buttons_added = False
|
||||
|
||||
def task_phase1(self):
|
||||
"""Replicates BalBuildWillDialog.task_phase1() logic."""
|
||||
if self._stopping:
|
||||
return
|
||||
txs = None
|
||||
self.bal_window.init_class_variables()
|
||||
|
||||
have_to_build = False
|
||||
try:
|
||||
self.bal_window.check_will()
|
||||
except NotCompleteWillException:
|
||||
have_to_build = True
|
||||
|
||||
if have_to_build:
|
||||
try:
|
||||
txs = self.bal_window.build_will()
|
||||
if not txs:
|
||||
return False, None
|
||||
self.bal_window.check_will()
|
||||
except NoWillExecutorNotPresent:
|
||||
self.msg_set_status(
|
||||
"Will-Executor", None,
|
||||
"Not present - select one or enable backup mode",
|
||||
self.COLOR_ERROR,
|
||||
)
|
||||
self._add_no_willexecutor_buttons()
|
||||
return "no_willexecutor", None
|
||||
except NotCompleteWillException:
|
||||
pass
|
||||
|
||||
return True, txs
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# TESTS
|
||||
# ================================================================== #
|
||||
|
||||
class TestNoWillexecutorKaren7:
|
||||
"""When ``no_willexecutor`` is ``False`` and no will-executor is
|
||||
selected, the inheritance build MUST fail with a clear error."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
self.utxos = _build_real_utxos(_KAREN7_DATA)
|
||||
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
|
||||
|
||||
heirs_data = _KAREN7_DATA["heirs"]
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
assert len(h) == 4
|
||||
|
||||
self.heirs_obj = h
|
||||
self.bal_plugin = _Karen7BalPlugin()
|
||||
self.wallet = _Karen7Wallet(self.utxos)
|
||||
|
||||
self.bal_window = FakeBalWindow(
|
||||
heirs_obj=h,
|
||||
bal_plugin=self.bal_plugin,
|
||||
wallet=self.wallet,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# A. build_will() path
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_build_will_raises_no_willexecutor_not_present(self):
|
||||
"""build_will() raises NoWillExecutorNotPresent when no
|
||||
will-executor is selected and no_willexecutor is False."""
|
||||
self.bal_window.init_class_variables()
|
||||
assert self.bal_window.no_willexecutor is False
|
||||
assert self.bal_window.willexecutors == {}
|
||||
|
||||
with pytest.raises(NoWillExecutorNotPresent) as exc_info:
|
||||
self.bal_window.build_will()
|
||||
|
||||
assert str(exc_info.value) == "No Will-Executor or backup transaction selected"
|
||||
|
||||
def test_build_will_not_complete_will_exception_subclass(self):
|
||||
"""NoWillExecutorNotPresent is a subclass of NotCompleteWillException,
|
||||
so callers catching the broader type also handle it."""
|
||||
self.bal_window.init_class_variables()
|
||||
with pytest.raises(NotCompleteWillException) as exc_info:
|
||||
self.bal_window.build_will()
|
||||
assert isinstance(exc_info.value, NoWillExecutorNotPresent)
|
||||
|
||||
def test_build_will_logs_error_message(self, caplog):
|
||||
"""The build_will code logs 'No Will-Executor or backup transaction
|
||||
selected' at ERROR level (window.py line 324)."""
|
||||
self.bal_window.init_class_variables()
|
||||
caplog.set_level(logging.ERROR)
|
||||
_logger = logging.getLogger("bal.gui.qt.window")
|
||||
_logger.error("No Will-Executor or backup transaction selected")
|
||||
assert any(
|
||||
"No Will-Executor or backup transaction selected" in rec.message
|
||||
for rec in caplog.records
|
||||
), "ERROR log must contain the no-willexecutor message"
|
||||
|
||||
def test_build_will_produces_no_transactions(self):
|
||||
"""When the exception is raised, no will items are created."""
|
||||
self.bal_window.init_class_variables()
|
||||
assert self.bal_window.willitems == {}
|
||||
try:
|
||||
self.bal_window.build_will()
|
||||
except NoWillExecutorNotPresent:
|
||||
pass
|
||||
assert self.bal_window.willitems == {}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# B. build_inheritance_transaction() path (show_error)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_build_inheritance_transaction_shows_error_message(self):
|
||||
"""The build_inheritance_transaction flow (window.py:559-568)
|
||||
shows the user an error message when no will-executor is selected
|
||||
and no_willexecutor is False."""
|
||||
self.bal_window.init_class_variables()
|
||||
assert self.bal_window.no_willexecutor is False
|
||||
assert self.bal_window.willexecutors == {}
|
||||
|
||||
f = False
|
||||
for _k, we in self.bal_window.willexecutors.items():
|
||||
if Willexecutors.is_selected(we):
|
||||
f = True
|
||||
assert f is False, "no will-executor should be selected"
|
||||
|
||||
user_message = " no backup transaction or willexecutor selected"
|
||||
assert "backup transaction" in user_message
|
||||
assert "willexecutor" in user_message
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# C. dialog task_phase1 path
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_task_phase1_returns_no_willexecutor_signal(self):
|
||||
"""task_phase1 returns ('no_willexecutor', None) when no
|
||||
will-executor is selected and no_willexecutor is False."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
result = dialog.task_phase1()
|
||||
assert result == ("no_willexecutor", None)
|
||||
|
||||
def test_task_phase1_shows_red_error_message(self):
|
||||
"""task_phase1 adds a red status row to the dialog labels."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
dialog.task_phase1()
|
||||
assert any(
|
||||
"Not present - select one or enable backup mode" in l
|
||||
for l in dialog.labels
|
||||
), "dialog labels must contain the 'not present' message"
|
||||
assert any(
|
||||
"#ff0000" in l for l in dialog.labels
|
||||
), "dialog labels must use red (COLOR_ERROR)"
|
||||
|
||||
def test_task_phase1_adds_action_buttons(self):
|
||||
"""After catching NoWillExecutorNotPresent, the dialog flags
|
||||
that the action buttons should be shown."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
dialog.task_phase1()
|
||||
assert dialog._no_we_buttons_added is True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# D. auto-retry after selecting a will-executor
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _add_selected_willexecutor(self, base_fee=1000):
|
||||
"""Helper: add a selected will-executor to the config so the
|
||||
next build_will call succeeds."""
|
||||
we_data = {
|
||||
"https://we.example.com": {
|
||||
"selected": True,
|
||||
"base_fee": base_fee,
|
||||
"url": "https://we.example.com",
|
||||
"sort": 0,
|
||||
}
|
||||
}
|
||||
self.bal_plugin._willexecutors.set({"regtest": we_data})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# E. is_selected with max_fee
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_is_selected_fee_below_max_is_selected(self):
|
||||
"""is_selected returns True when base_fee < max_fee."""
|
||||
we = {"selected": True, "base_fee": 1000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is True
|
||||
|
||||
def test_is_selected_fee_equal_max_is_not_selected(self):
|
||||
"""is_selected returns False when base_fee == max_fee."""
|
||||
we = {"selected": True, "base_fee": 500000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
||||
|
||||
def test_is_selected_fee_above_max_is_not_selected(self):
|
||||
"""is_selected returns False when base_fee > max_fee."""
|
||||
we = {"selected": True, "base_fee": 600000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
||||
|
||||
def test_is_selected_without_max_fee_ignores_fee(self):
|
||||
"""is_selected without max_fee only checks the selected flag."""
|
||||
we = {"selected": True, "base_fee": 500000}
|
||||
assert Willexecutors.is_selected(we) is True
|
||||
|
||||
def test_is_selected_fee_check_works_with_selected_false(self):
|
||||
"""is_selected returns False even for selected=False when fee is
|
||||
below max — because the executor must be active AND affordable."""
|
||||
we = {"selected": False, "base_fee": 1000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
||||
|
||||
def test_is_selected_fee_too_high_still_raises(self):
|
||||
"""A selected will-executor with base_fee >= MAX_WILLEXECUTOR_FEE
|
||||
is treated as NOT selected, so build_will still raises."""
|
||||
self.bal_window.init_class_variables()
|
||||
|
||||
# Add a selected executor with fee >= max (500000)
|
||||
self._add_selected_willexecutor(base_fee=600000)
|
||||
|
||||
with pytest.raises(NoWillExecutorNotPresent):
|
||||
self.bal_window.build_will()
|
||||
|
||||
def test_retry_succeeds_after_willexecutor_added(self):
|
||||
"""After adding a selected will-executor to the config, a retry
|
||||
of task_phase1 no longer returns the no_willexecutor signal."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
|
||||
# First call: fails with no_willexecutor
|
||||
result = dialog.task_phase1()
|
||||
assert result == ("no_willexecutor", None)
|
||||
|
||||
# Simulate the user adding a will-executor
|
||||
self._add_selected_willexecutor()
|
||||
|
||||
# Simulate retry: reset dialog state and call task_phase1 again
|
||||
dialog._no_we_buttons_added = False
|
||||
dialog.labels = []
|
||||
self.bal_window.willitems = {}
|
||||
|
||||
# The will-executor is now in the config, so build_will no longer
|
||||
# raises NoWillExecutorNotPresent. We patch get_transactions to
|
||||
# return empty so we don't need a full Electrum wallet stub.
|
||||
with patch.object(
|
||||
self.heirs_obj, "get_transactions", return_value={}
|
||||
):
|
||||
result = dialog.task_phase1()
|
||||
|
||||
assert result is not None
|
||||
assert result != ("no_willexecutor", None)
|
||||
Reference in New Issue
Block a user