Add Willexecutors.is_valid + grey italic styling for invalid executors
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user