""" Test: modifying an heir (address or amount) on a signed/pushed will triggers a rebuild (HeirNotFoundException) but NOT an on-chain invalidation transaction. Scenario: The user has a signed/pushed will. Changing the address or the percentage/amount of an existing heir changes the transaction's output script or value, so the old transaction must be rebuilt. In ``check_willexecutors_and_heirs``, the condition ``heir[0] == their[0] and heir[1] == their[1]`` (address and amount) is False for the modified heir → that heir is never counted in ``heirs_found`` → ``HeirNotFoundException`` is raised at line 911. This holds as long as the locktime is not also postponed (which would raise ``WillPostponedException`` instead, requesting an on-chain invalidation transaction). The threshold ("30d") also stays in the future, so ``CheckAliveError`` / ``WillExpiredException`` must not fire either. Two sub-tests: 1. ``test_change_address_triggers_rebuild`` - Same percentage, same locktime → only the address differs. 2. ``test_change_amount_triggers_rebuild`` - Same address, same locktime → only the percentage/amount differs. Both must raise ``HeirNotFoundException`` and NOT ``WillPostponedException``. """ import copy import os import sys import time import itertools import pytest from unittest.mock import MagicMock, patch from datetime import datetime from electrum import constants sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) @pytest.fixture(autouse=True) def _regtest_network(): previous = constants.net constants.BitcoinRegtest.set_as_network() try: yield finally: previous.set_as_network() import bal_fixtures as fx # noqa: E402 from bal.core.heirs import Heirs # noqa: E402 from bal.core.will import ( # noqa: E402 Will, WillItem, HeirNotFoundException, WillPostponedException, WillExpiredException, ) from bal.core.willexecutors import Willexecutors # noqa: E402 from bal.core.plugin_base import BalTimestamp # noqa: E402 from electrum.transaction import PartialTransaction # noqa: E402 DAY = 86400 # ------------------------------------------------------------------ # # Helpers # ------------------------------------------------------------------ # def _willexecutors(specs): out = {} for url, address, base_fee in specs: out[url] = { "url": url, "address": address, "base_fee": base_fee, "selected": True, "status": 200, } return out def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100): wallet = fx.fake_wallet() utxos = fx.make_utxos(4, 5_000_000) plugin = fx.fake_bal_plugin(willexecutors) if from_locktime is None: from_locktime = int(time.time()) counter = itertools.count(1) def fake_txid(self): if not hasattr(self, "_test_txid"): self._test_txid = f"{next(counter):064x}" return self._test_txid with patch.object(Willexecutors, "get_willexecutors", return_value=willexecutors), \ patch.object(PartialTransaction, "txid", fake_txid): txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos, from_locktime) return txs or {} def _tx_for_we(txs, url): for tx in txs.values(): we = getattr(tx, "willexecutor", None) if we and we.get("url") == url: return tx return None def _build_signed_pushed_will(heirs_dict, wes, now): """Build, sign (COMPLETE) and push (PUSHED) a will. Return the willitems dict and the will-item creation time *w.time*.""" txs = _build(heirs_dict, wes, from_locktime=now) tx = _tx_for_we(txs, "https://we.example") assert tx is not None, "no will-executor transaction built" txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid() item = WillItem.__new__(WillItem) item.tx = tx item._id = txid item.heirs = copy.deepcopy(tx.heirs) item.we = copy.deepcopy(tx.willexecutor) item.status = "" item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) item.tx_fees = 100 item.time = now item.father = None item.children = {} item.description = "" item.change = "" item.set_status("VALID", True) item.set_status("COMPLETE", True) item.set_status("PUSHED", True) return {txid: item}, now # ------------------------------------------------------------------ # # Tests # ------------------------------------------------------------------ # def test_change_address_triggers_rebuild(): """Changing the address of an existing heir triggers HeirNotFoundException (plain rebuild), NOT WillPostponedException (on-chain invalidation).""" now = int(time.time()) ha = fx.heir_addresses(4) we_addr = fx.willexecutor_addresses(1)[0] wes = _willexecutors([("https://we.example", we_addr, 100000)]) addr_alice_old = ha[0] addr_alice_new = ha[3] # different address # ── 1. Build signed/pushed will ───────────────────────────────── heirs_initial = { "alice": [addr_alice_old, "40%", "300d"], "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_initial = Heirs.__new__(Heirs) dict.__init__(h_initial) h_initial.db = MagicMock() h_initial.wallet = MagicMock() h_initial.update(copy.deepcopy(heirs_initial)) willitems, w_time = _build_signed_pushed_will(h_initial, wes, now) # ── 2. Change only alice's address ────────────────────────────── heirs_changed = { "alice": [addr_alice_new, "40%", "300d"], # same %, same locktime "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_changed = Heirs.__new__(Heirs) dict.__init__(h_changed) h_changed.db = MagicMock() h_changed.wallet = MagicMock() h_changed.update(copy.deepcopy(heirs_changed)) check_date = BalTimestamp("30d").to_timestamp() # ── 3. Assert HeirNotFoundException, NOT WillPostponedException ─ with pytest.raises(HeirNotFoundException) as exc_info: Will.check_willexecutors_and_heirs( willitems, h_changed, wes, False, # no_willexecutor check_date, 100, ) # The exception should name the modified heir. assert "alice" in str(exc_info.value) # Double-check: WillPostponedException must NOT be raised. try: Will.check_willexecutors_and_heirs( willitems, h_changed, wes, False, check_date, 100, ) except HeirNotFoundException: pass # expected except WillPostponedException: pytest.fail("changing address must NOT request an invalidation " "transaction (WillPostponedException)") except Exception: pass # other rebuild-related exceptions are acceptable def test_change_amount_triggers_rebuild(): """Changing the percentage/amount of an existing heir triggers HeirNotFoundException (plain rebuild), NOT WillPostponedException (on-chain invalidation).""" now = int(time.time()) ha = fx.heir_addresses(3) we_addr = fx.willexecutor_addresses(1)[0] wes = _willexecutors([("https://we.example", we_addr, 100000)]) # ── 1. Build signed/pushed will ───────────────────────────────── heirs_initial = { "alice": [ha[0], "40%", "300d"], "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_initial = Heirs.__new__(Heirs) dict.__init__(h_initial) h_initial.db = MagicMock() h_initial.wallet = MagicMock() h_initial.update(copy.deepcopy(heirs_initial)) willitems, w_time = _build_signed_pushed_will(h_initial, wes, now) # ── 2. Change only alice's percentage ─────────────────────────── heirs_changed = { "alice": [ha[0], "50%", "300d"], # same addr, same locktime "bob": [ha[1], "30%", "300d"], "carol": [ha[2], "30%", "300d"], } h_changed = Heirs.__new__(Heirs) dict.__init__(h_changed) h_changed.db = MagicMock() h_changed.wallet = MagicMock() h_changed.update(copy.deepcopy(heirs_changed)) check_date = BalTimestamp("30d").to_timestamp() # ── 3. Assert HeirNotFoundException, NOT WillPostponedException ─ with pytest.raises(HeirNotFoundException) as exc_info: Will.check_willexecutors_and_heirs( willitems, h_changed, wes, False, check_date, 100, ) assert "alice" in str(exc_info.value) try: Will.check_willexecutors_and_heirs( willitems, h_changed, wes, False, check_date, 100, ) except HeirNotFoundException: pass # expected except WillPostponedException: pytest.fail("changing amount must NOT request an invalidation " "transaction (WillPostponedException)") except Exception: pass