From 1d0ba16aac49d3b3d62a7a3d43731bff7c13e407 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Thu, 30 Jul 2026 17:49:28 -0400 Subject: [PATCH] Add Willexecutors.is_valid + grey italic styling for invalid executors --- .gitignore | 21 + bal/core/heirs.py | 29 +- bal/core/plugin_base.py | 3 + bal/core/will.py | 13 +- bal/core/willexecutors.py | 20 +- bal/gui/qt/common.py | 3 +- bal/gui/qt/dialogs.py | 95 +++- bal/gui/qt/lists.py | 13 +- bal/gui/qt/plugin.py | 71 ++- bal/gui/qt/window.py | 20 +- opencode.json | 4 + package-lock.json | 42 ++ package.json | 5 + pyproject.toml | 7 + pyrightconfig.json | 6 + tests/test_core_will_invalidate.py | 484 ++++++++++++++++++++ tests/test_group_e_karen7_invalidate.py | 401 ++++++++++++++++ tests/test_no_willexecutor_karen7.py | 580 ++++++++++++++++++++++++ 18 files changed, 1781 insertions(+), 36 deletions(-) create mode 100644 opencode.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 pyrightconfig.json create mode 100644 tests/test_core_will_invalidate.py create mode 100644 tests/test_group_e_karen7_invalidate.py create mode 100644 tests/test_no_willexecutor_karen7.py diff --git a/.gitignore b/.gitignore index 8c0bc4f..a987342 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,24 @@ bal-electrum-plugin.zip electrum-src/ preview_*.png .env + +# Virtual environment +venv/ +.venv/ + +# Node modules +node_modules/ + +# Editor temp files +*.swp +*.swo +*.bak + +# Debug / scratch files +debug.py +init.ol +temp* +tmp* + +# Release artifacts +bal_v*.zip.* diff --git a/bal/core/heirs.py b/bal/core/heirs.py index 837ad7f..d934b43 100644 --- a/bal/core/heirs.py +++ b/bal/core/heirs.py @@ -230,6 +230,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos): # TODO calculate de minimum inputs to be invalidated def invalidate_inheritance_transactions(wallet): + print("invalidate tx in heir method") # listids = [] utxos = {} dtxs = {} @@ -478,7 +479,8 @@ class Heirs(dict, Logger): ) def prepare_lists( - self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 + self, balance, total_fees, wallet, willexecutor=False, from_locktime=0, + max_fee=None, ): if balance int(from_locktime): try: base_fee = int(willexecutor["base_fee"]) + if max_fee is not None and base_fee > max_fee: + raise WillExecutorFeeTooHighException( + willexecutor, max_fee + ) willexecutors_amount += base_fee h = [None] * 4 h[HEIR_AMOUNT] = base_fee @@ -629,7 +635,7 @@ class Heirs(dict, Logger): break elif 0 <= j: url, willexecutor = willexecutorsitems[j] - if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): + if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())): continue else: willexecutor["url"] = url @@ -651,11 +657,15 @@ class Heirs(dict, Logger): # newbalance = balance try: locktimes, onlyfixed = self.prepare_lists( - balance, total_fees, wallet, willexecutor, from_locktime + balance, total_fees, wallet, willexecutor, from_locktime, + max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), ) except WillExecutorFeeException: i = 10 continue + except WillExecutorFeeTooHighException: + i = 10 + continue if locktimes: try: txs = prepare_transactions( @@ -874,6 +884,19 @@ class WillExecutorFeeException(Exception): return "WillExecutorFeeException: {} fee:{}".format( self.willexecutor["url"], self.willexecutor["base_fee"] ) + +class WillExecutorFeeTooHighException(Exception): + def __init__(self, willexecutor, max_fee): + self.willexecutor = willexecutor + self.max_fee = max_fee + + def __str__(self): + return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format( + self.willexecutor["url"], + self.willexecutor["base_fee"], + self.max_fee, + ) + class BalanceTooLowException(Exception): def __init__(self,balance, dust_threshold, fees): self.balance=balance diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index a0828d6..0c4d42e 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -258,6 +258,9 @@ class BalPlugin(BasePlugin): # follows what is saved in that wallet (the default only applies when no # value has been stored yet, i.e. new wallets). self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False) + self.MAX_WILLEXECUTOR_FEE = BalConfig( + config, "bal_max_willexecutor_fee", 500000 + ) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) diff --git a/bal/core/will.py b/bal/core/will.py index 2d7ecad..8b0597d 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -45,6 +45,7 @@ from electrum.util import ( from .util import Util from .willexecutors import Willexecutors +from .heirs import WillExecutorFeeTooHighException MIN_LOCKTIME = 1 MIN_BLOCK = 1 @@ -456,6 +457,7 @@ class Will: @staticmethod def invalidate_will(will, wallet, fees_per_byte): + print("invalidate tx in will module") will_only_valid = Will.only_valid_list(will) inputs = Will.get_all_inputs(will_only_valid) utxos = wallet.get_utxos() @@ -472,11 +474,13 @@ class Will: utxo_to_spend = [] for utxo in utxos: if utxo.is_coinbase_output() and utxo.block_height < current_height+100: + print("is not mature coinbase output") continue utxo_str = utxo.prevout.to_str() if utxo_str in prevout_to_spend: balance += inputs[utxo_str][0][2].value_sats() utxo_to_spend.append(utxo) + print("utxo to spend",utxo_to_spend) if len(utxo_to_spend) > 0: change_addresses = wallet.get_change_addresses_for_new_transaction() out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) @@ -598,7 +602,8 @@ class Will: # Will.reflect_to_children(wc) @staticmethod - def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): + def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust, + max_fee=None): fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) ) @@ -614,7 +619,9 @@ class Will: raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") for url, wex in willexecutors.items(): - if Willexecutors.is_selected(wex): + if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust): + if max_fee is not None and int(wex["base_fee"]) > max_fee: + raise WillExecutorFeeTooHighException(wex, max_fee) temp_balance = wallet_balance - int(wex["base_fee"]) if fixed_amount >= temp_balance: raise FixedAmountException( @@ -937,7 +944,7 @@ class Will: if self_willexecutor and no_willexecutor == 0: raise NoWillExecutorNotPresent("Backup tx") for url, we in willexecutors.items(): - if Willexecutors.is_selected(we): + if Willexecutors.is_selected(we) and Willexecutors.is_valid(we): if url not in willexecutors_found: _logger.debug(f"will-executor: {url} not fount") raise WillExecutorNotPresent(url) diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py index 143c2a1..15e4747 100644 --- a/bal/core/willexecutors.py +++ b/bal/core/willexecutors.py @@ -205,17 +205,35 @@ class Willexecutors: return w_sorted @staticmethod - def is_selected(willexecutor, value=None): + def is_selected(willexecutor, value=None, max_fee=None): if not willexecutor: return False if value is not None: willexecutor["selected"] = value + if max_fee is not None: + base_fee = willexecutor.get("base_fee", 0) + if int(base_fee) >= max_fee: + return False try: return willexecutor["selected"] except Exception: willexecutor["selected"] = False return False + @staticmethod + def is_valid(willexecutor, max_fee=None, dust=None): + if not willexecutor: + return False + address = willexecutor.get("address", "") + if not address or not bitcoin.is_address(address, net=constants.net): + return False + base_fee = int(willexecutor.get("base_fee", 0)) + if dust is not None and base_fee <= dust: + return False + if max_fee is not None and base_fee >= max_fee: + return False + return True + @staticmethod def get_willexecutor_transactions(will, force=False): willexecutors = {} diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 87ee993..b17ce1e 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -63,7 +63,8 @@ from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox, # --- Core (GUI-free) logic layer --- from ...core.plugin_base import BalPlugin, BalTimestamp from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, - HeirAmountIsDustException, Heirs) + HeirAmountIsDustException, Heirs, + WillExecutorFeeTooHighException) from ...core.util import Util from ...core.will import (AmountException, HeirChangeException, HeirNotFoundException, NoHeirsException, diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index cf60cd7..9acdc3f 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -648,6 +648,7 @@ class BalBuildWillDialog(BalDialog): self.bal_window.window.wallet.get_utxos(), self.bal_window.date_to_check, self.bal_window.window.wallet.dust_threshold(), + max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), ) _logger.debug("variables ok") self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK) @@ -659,6 +660,10 @@ class BalBuildWillDialog(BalDialog): + "Your settings require an adjustment of the amounts" ) ) + except WillExecutorFeeTooHighException as e: + self.msg_set_checking( + self.msg_warning(f"Will-executor fee too high: {e}") + ) self.msg_set_checking() have_to_build = False @@ -800,6 +805,15 @@ class BalBuildWillDialog(BalDialog): _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR ) + except NoWillExecutorNotPresent: + _logger.debug("no will-executor selected, build interrupted") + self.msg_set_status( + _("Will-Executor"), None, + _("Not present - select one or enable backup mode"), + self.COLOR_ERROR, + ) + return "no_willexecutor", None + except WillExpiredException as e: # An expired will is an EXPECTED situation (the locktime has # passed). After adding/changing an heir the will is rebuilt @@ -1113,7 +1127,14 @@ class BalBuildWillDialog(BalDialog): selected = { url: we for url, we in willexecutors.items() - if Willexecutors.is_selected(self.bal_window.willexecutors.get(url)) + if Willexecutors.is_selected( + self.bal_window.willexecutors.get(url), + max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), + ) and Willexecutors.is_valid( + self.bal_window.willexecutors.get(url), + max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), + dust=self.bal_window.window.wallet.dust_threshold(), + ) } # Servers that report "already present" need their stored tx @@ -1243,6 +1264,7 @@ class BalBuildWillDialog(BalDialog): def invalidate_task(self, password, bal_window, tx): if self._stopping: return + print("invalidate task") _logger.debug(f"invalidate tx: {tx}") # fee_per_byte = bal_window.will_settings.get("baltx_fees", 1) tx = self.bal_window.wallet.sign_transaction(tx, password) @@ -1346,6 +1368,10 @@ class BalBuildWillDialog(BalDialog): QTimer.singleShot(0, self.bal_window.invalidate_will) return + if self.have_to_sign == "no_willexecutor": + self._add_no_willexecutor_buttons() + return + _logger.debug("have to sign {}".format(self.have_to_sign)) password = None if self.have_to_sign is None: @@ -1479,6 +1505,73 @@ class BalBuildWillDialog(BalDialog): self.vbox.addLayout(button_row) self._close_button.setFocus() + # ------------------------------------------------------------------ # + # No-willexecutor error handling + # ------------------------------------------------------------------ # + + def _add_no_willexecutor_buttons(self): + """Add "Will-Executor" and "Close" buttons when no executor is + selected and ``no_willexecutor`` is ``False``.""" + if getattr(self, "_no_we_buttons_added", False): + return + self._no_we_buttons_added = True + btn_row = QHBoxLayout() + btn_row.addStretch(1) + + we_btn = QPushButton(_("Will-Executor")) + we_btn.clicked.connect(self._open_willexecutor_dialog) + btn_row.addWidget(we_btn) + + download_btn = QPushButton(_("\U0001f52e Wizard")) + download_btn.clicked.connect(self._open_willexecutor_download_widget) + btn_row.addWidget(download_btn) + + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + btn_row.addWidget(close_btn) + + self._no_we_layout = btn_row + self.vbox.addLayout(btn_row) + self.resize(self.vbox.sizeHint()) + + def _open_willexecutor_dialog(self): + """Open the will-executor management dialog, then auto-retry + the build when it closes.""" + d = WillExecutorDialog(self.bal_window, parent=self) + d.exec() + self._retry_build_after_willexecutor() + + def _open_willexecutor_download_widget(self): + """Close the build-will dialog and re-open the wizard at the + will-executor download step so the user can add one.""" + self.close() + wizard = BalWizardDialog(self.bal_window) + wizard.on_next_heir() + wizard.on_next_locktimeandfee() + wizard.exec() + + def _retry_build_after_willexecutor(self): + """Remove the no-willexecutor buttons, reset the message panel, + and re-run ``task_phase1`` on the same thread.""" + self._no_we_buttons_added = False + if self._no_we_layout: + while self._no_we_layout.count(): + item = self._no_we_layout.takeAt(0) + w = item.widget() + if w: + w.setParent(None) + w.deleteLater() + self.vbox.removeItem(self._no_we_layout) + self._no_we_layout = None + self.labels = [] + self.msg_update() + self.thread.add( + self.task_phase1, + on_success=self.on_success_phase1, + on_done=self.on_accept, + on_error=self.on_error_phase1, + ) + def _ics_provider(self): """Return the .ics content for the current will data.""" from datetime import datetime, timedelta diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index b627ad3..c253ec6 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -888,7 +888,7 @@ class WillExecutorListWidget(MyTreeView): # are shown unchanged. display_url = url if len(url) <= 40 else url[:37] + "\u2026" labels[self.Columns.URL] = display_url - if Willexecutors.is_selected(value): + if Willexecutors.is_selected(value, max_fee=float("inf")): labels[self.Columns.SELECTED] = [ read_QIcon_from_bytes( @@ -929,6 +929,17 @@ class WillExecutorListWidget(MyTreeView): pass else: items.append(QStandardItem(e)) + + max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get() + dust = self._bal_parent.bal_window.window.wallet.dust_threshold() + if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust): + grey = QColor("#808080") + for item in items: + font = item.font() + font.setItalic(True) + item.setFont(font) + item.setForeground(grey) + items[self.Columns.SELECTED].setEditable(False) items[self.Columns.URL].setEditable(True) items[self.Columns.ADDRESS].setEditable(True) diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 294c86e..25a1629 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -436,6 +436,13 @@ class Plugin(BalPlugin): # persisted NUM_REMINDERS config (default 3), with a range of 1..5. heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5) + # Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for + # a single will-executor. If a will-executor charges more, the will + # will not be built. Default 500,000 satoshi (0.005 BTC). + heir_max_willexecutor_fee = BalSpinBox( + self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000 + ) + # "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR # config (default ON, see plugin_base.py), the SAME config used by the # checkbox inside the "Build your will" wizard's will-executor download @@ -636,13 +643,28 @@ class Plugin(BalPlugin): ), ) grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3) + # Max willexecutor fee: maximum fee (in satoshi) allowed for a single + # will-executor. Visible to all users (BASIC and ADVANCED). + add_widget( + grid, + "Max Will-Executor Fee (satoshi)", + heir_max_willexecutor_fee, + 5, + ( + "Maximum fee (in satoshi) allowed to be paid to a single " + "will-executor. If a will-executor charges more than this, " + "the will will not be built.\n" + "Default: 500,000 satoshi (0.005 BTC)." + ), + ) + grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 5, 3) # User Type selector placed BEFORE the advanced-only settings so the # user chooses basic/advanced first, then sees the relevant options. add_widget( grid, "User Type", user_type_combo, - 5, + 6, ( "Choose how much detail the plugin shows.\n\n" "BASIC: simplified interface, safe configuration for most " @@ -653,7 +675,7 @@ class Plugin(BalPlugin): "editable." ), ) - grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 3) + grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3) # Number of reminders, event summary and event description are visible # only in ADVANCED mode. In BASIC mode the factory defaults are always # used and these settings are hidden. @@ -662,11 +684,11 @@ class Plugin(BalPlugin): "How many reminder alarms the exported calendar (.ics) event " "contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode." ) - grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0) - grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1) - grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2) + grid.addWidget(_hide_if_basic(lbl_num_reminders), 7, 0) + grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1) + grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2) reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin") - grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3) + grid.addWidget(_hide_if_basic(reset_btn_6), 7, 3) lbl_event_summary = QLabel(_("Event summary")) help_event_summary = HelpButton( @@ -676,11 +698,11 @@ class Plugin(BalPlugin): " $heirs_complete: list of heirs name,address,amount\n" "Only used in ADVANCED mode." ) - grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0) - grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1) - grid.addWidget(_hide_if_basic(help_event_summary), 7, 2) + grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0) + grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1) + grid.addWidget(_hide_if_basic(help_event_summary), 8, 2) reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line") - grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3) + grid.addWidget(_hide_if_basic(reset_btn_7), 8, 3) lbl_event_description = QLabel(_("Event description")) help_event_description = HelpButton( @@ -690,11 +712,11 @@ class Plugin(BalPlugin): " $heirs_complete: list of heirs name,address,amount\n" "Only used in ADVANCED mode." ) - grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0) - grid.addWidget(_hide_if_basic(edit_event_description), 8, 1) - grid.addWidget(_hide_if_basic(help_event_description), 8, 2) + grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0) + grid.addWidget(_hide_if_basic(edit_event_description), 9, 1) + grid.addWidget(_hide_if_basic(help_event_description), 9, 2) reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text") - grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3) + grid.addWidget(_hide_if_basic(reset_btn_8), 9, 3) # Welist server URL: shown only in ADVANCED mode. In BASIC mode the # factory default is always used and the setting is hidden. lbl_welist_server = QLabel(_("Welist Server URL")) @@ -702,11 +724,11 @@ class Plugin(BalPlugin): "URL of the server that provides the will-executor list. " "Only available in ADVANCED mode." ) - grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0) - grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1) - grid.addWidget(_hide_if_basic(help_welist_server), 9, 2) + grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0) + grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1) + grid.addWidget(_hide_if_basic(help_welist_server), 10, 2) reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line") - grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3) + grid.addWidget(_hide_if_basic(reset_btn_9), 10, 3) lbl_calendar_app = QLabel(_("Calendar app command")) help_calendar_app = HelpButton( @@ -714,11 +736,11 @@ class Plugin(BalPlugin): "Leave empty to use the system default (xdg-open/open/start).\n" "Only used in ADVANCED mode." ) - grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0) - grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1) - grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2) + grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0) + grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1) + grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2) reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line") - grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3) + grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3) # NOTE: the ADVANCED-only widgets above have ALREADY been given their # correct initial visibility inline (via _hide_if_basic) BEFORE being @@ -727,12 +749,12 @@ class Plugin(BalPlugin): # the Windows relayout flicker. Do NOT reintroduce a post-hoc # setVisible() loop here. - grid.addWidget(heir_repush, 11, 0) + grid.addWidget(heir_repush, 12, 0) grid.addWidget( HelpButton( "Broadcast all transactions to willexecutors including those already pushed" ), - 11, + 12, 2, ) @@ -761,6 +783,7 @@ class Plugin(BalPlugin): (self.EDITABLE_DATES, heir_editable_dates, "check"), (self.NUM_REMINDERS, heir_num_reminders, "spin"), (self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), + (self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), (self.EVENT_SUMMARY, edit_event_summary, "line"), (self.EVENT_DESCRIPTION, edit_event_description, "text"), (self.WELIST_SERVER, edit_welist_server, "line"), diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 62cb438..3375fd2 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -318,7 +318,12 @@ class BalWindow: f = False for _u, w in self.willexecutors.items(): - if Willexecutors.is_selected(w): + if Willexecutors.is_selected( + w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get() + ) and Willexecutors.is_valid( + w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), + dust=self.window.wallet.dust_threshold() + ): f = True if not f: _logger.error("No Will-Executor or backup transaction selected") @@ -532,6 +537,7 @@ class BalWindow: self.window.wallet.get_utxos(), self.date_to_check, self.window.wallet.dust_threshold(), + max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), ) except AmountException as e: self.show_warning( @@ -539,6 +545,11 @@ class BalWindow: f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" ) ) + except WillExecutorFeeTooHighException as e: + self.show_error( + _(f"Will-executor fee too high: {e}") + ) + return except CheckAliveError: self.show_error( _( @@ -553,7 +564,12 @@ class BalWindow: if not self.no_willexecutor: f = False for _k, we in self.willexecutors.items(): - if Willexecutors.is_selected(we): + if Willexecutors.is_selected( + we, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get() + ) and Willexecutors.is_valid( + we, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), + dust=self.window.wallet.dust_threshold() + ): f = True if not f: self.show_error( diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..9cc8361 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0bf2724 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "bal-electrum-plugin", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "pyright": "^1.1.411" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pyright": { + "version": "1.1.411", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.411.tgz", + "integrity": "sha512-03S/vmS5lF1S/tVbKc2WNXCMq8JWCwta/qIYjj1jvqbQhoy+N3NgBzHTSmUlbYD6DJwqQ5XHf108QujoqeURvw==", + "license": "MIT", + "bin": { + "pyright": "index.js", + "pyright-langserver": "langserver.index.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4def390 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "pyright": "^1.1.411" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..94075bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select =["E", "W", "F", "I", "N", "B"] +ignore = ["E501"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..19c875f --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "python.analysis": { + "extraPaths": ["../electrum"] + } +} + diff --git a/tests/test_core_will_invalidate.py b/tests/test_core_will_invalidate.py new file mode 100644 index 0000000..768a15e --- /dev/null +++ b/tests/test_core_will_invalidate.py @@ -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=, 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") diff --git a/tests/test_group_e_karen7_invalidate.py b/tests/test_group_e_karen7_invalidate.py new file mode 100644 index 0000000..9ab508e --- /dev/null +++ b/tests/test_group_e_karen7_invalidate.py @@ -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 diff --git a/tests/test_no_willexecutor_karen7.py b/tests/test_no_willexecutor_karen7.py new file mode 100644 index 0000000..162ce22 --- /dev/null +++ b/tests/test_no_willexecutor_karen7.py @@ -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{}".format(msg, status) + else: + line = "{}:\t{}".format( + msg, color, status + ) + self.labels.append(line) + return len(self.labels) - 1 + + def msg_error(self, e): + return "{}".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)