""" bal.gui.qt.window ================= The :class:`BalWindow` controller: one instance per Electrum wallet window. This is the orchestration layer that ties together the heirs list, the will preview, the will-executors and the various dialogs. It owns the per-wallet state (``heirs``, ``will``, ``willitems``, ``will_settings``) and exposes the high-level actions (build / check / sign / broadcast / invalidate the will, import/export, etc.) that the menus, tabs and dialogs invoke. The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates it with the GUI. """ import threading from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog, BalWaitingDialog, BalWizardDialog, WillDetailDialog, WillExecutorDialog) from .lists import HeirListWidget, PreviewList, WillExecutorWidget class BalWindow: def __init__(self, bal_plugin: "BalPlugin", window: "ElectrumWindow"): self.bal_plugin = bal_plugin self.window = window self.heirs = {} self.will = {} self.willitems = {} self.willexecutors = {} self.will_settings = None self.ok = False self.disable_plugin = True # Guard against wiring the menu/tabs more than once for the same window. # Electrum may invoke both the ``init_menubar`` hook and our hot-init # path (``init_qt`` -> ``_setup_window``) for the same window, e.g. when # Electrum restarts with the plugin already enabled. Calling # ``init_menubar_tools`` twice would add the Heirs/Will tabs and the # menu actions twice, producing the garbled/condensed menu entry. self._menubar_initialized = False self.bal_plugin.get_decimal_point = self.window.get_decimal_point if self.window.wallet: self.wallet = self.window.wallet if not self.will_settings: self.will_settings = self.bal_plugin.WILL_SETTINGS.get() Util.fix_will_settings_tx_fees(self.will_settings) self.heirs = Heirs(self.wallet) self.heirs_tab = self.create_heirs_tab() self.will_tab = self.create_will_tab() self.heirs_tab.wallet = self.wallet self.will_tab.wallet = self.wallet def init_menubar_tools(self, tools_menu): # Idempotent: only wire the tabs + menu actions once per window. # A second call (e.g. init_menubar hook *and* the hot-init path both # firing) would otherwise duplicate the Heirs/Will tabs and the # Will-Executors / toggle actions, which Qt renders as a broken, # condensed menu entry under the Electrum logo. if self._menubar_initialized: _logger.info("init_menubar_tools: already initialised, skipping") return self._menubar_initialized = True self.tools_menu = tools_menu def add_optional_tab(tabs, tab, icon, description): tab.tab_icon = icon tab.tab_description = description tab.tab_pos = len(tabs) if tab.is_shown_cv.get(): tabs.addTab(tab, icon, description.replace("&", "")) def add_toggle_action(tab): is_shown = tab.is_shown_cv.get() tab.menu_action = self.window.view_menu.addAction( tab.tab_description, lambda: self.window.toggle_tab(tab) ) tab.menu_action.setCheckable(True) tab.menu_action.setChecked(is_shown) add_optional_tab( self.window.tabs, self.heirs_tab, read_QIcon_from_bytes(self.bal_plugin.read_file("icons/heir.png")), _("&Heirs"), ) add_optional_tab( self.window.tabs, self.will_tab, read_QIcon_from_bytes(self.bal_plugin.read_file("icons/will.png")), _("&Will"), ) tools_menu.addSeparator() self.tools_menu.willexecutors_action = tools_menu.addAction( _("&Will-Executors"), self.show_willexecutor_dialog ) self.window.view_menu.addSeparator() add_toggle_action(self.heirs_tab) add_toggle_action(self.will_tab) def load_willitems(self): self.willitems = {} for wid, w in self.will.items(): self.willitems[wid] = WillItem(w, wallet=self.wallet) if self.willitems: self.will_list_widget.will = self.willitems self.will_list_widget.update_will(self.willitems) self.will_tab.update() def save_willitems(self): keys = list(self.will.keys()) for k in keys: del self.will[k] for wid, w in self.willitems.items(): self.will[wid] = w.to_dict() def init_will(self): _logger.info("********************init_____will____________**********") if not self.willexecutors: self.willexecutors = Willexecutors.get_willexecutors( self.bal_plugin, update=False, bal_window=self ) if not self.heirs: self.heirs = Heirs._validate(Heirs(self.wallet)) self.heirs_tab.update() if not self.will: self.will = self.wallet.db.get_dict("will") Util.fix_will_tx_fees(self.will) if self.will: self.willitems = {} try: self.load_willitems() except Exception: self.disable_plugin = True self.show_warning( _("Please restart Electrum to activate the BAL plugin"), title=_("Success"), ) self.close_wallet() return # if not self.will_settings: # self.will_settings = self.wallet.db.get_dict("will_settings") # Util.fix_will_settings_tx_fees(self.will_settings) # _logger.info("will_settings: {}".format(self.will_settings)) # if not self.will_settings: # Util.copy(self.will_settings, self.bal_plugin.default_will_settings()) # _logger.debug("not_will_settings {}".format(self.will_settings)) # self.bal_plugin.validate_will_settings(self.will_settings) # self.heir_list_widget.update_will_settings() # self.heir_list_widget.update() def init_wizard(self): wizard_dialog = BalWizardDialog(self) wizard_dialog.exec() def show_willexecutor_dialog(self): self.willexecutor_dialog = WillExecutorDialog(self) # Keep it in front of Electrum (window-modal) instead of letting it # fall behind the main window. show_on_top(self.willexecutor_dialog) def create_heirs_tab(self): if not self.heirs: self.heirs = Heirs(self.wallet) self.heir_list_widget = HeirListWidget(self, self.window) tab = self.window.create_list_tab(self.heir_list_widget) tab.is_shown_cv = shown_cv(False) return tab def create_will_tab(self): self.will_list_widget = PreviewList(self, self.window, None) tab = self.window.create_list_tab(self.will_list_widget) tab.is_shown_cv = shown_cv(True) return tab def new_heir_dialog(self, heir_key=None): heir = self.heirs.get(heir_key) title = "New heir" if heir: title = f"Edit: {heir_key}" d = BalDialog( self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title)) ) vbox = QVBoxLayout(d) grid = QGridLayout() heir_name = QLineEdit() heir_name.setFixedWidth(32 * char_width_in_lineedit()) heir_address = QLineEdit() heir_address.setFixedWidth(32 * char_width_in_lineedit()) heir_amount = PercAmountEdit(self.window.get_decimal_point) if heir: heir_name.setText(str(heir_key)) heir_address.setText(str(heir[0])) heir_amount.setText( str(Util.decode_amount(heir[1], self.window.get_decimal_point())) ) self.heir_locktime = LockTimeWidget(self, self.window, heir[2]) # heir_is_xpub = QCheckBox() new_heir_button = QPushButton(_("Add another heir")) self.add_another_heir = False def new_heir(): self.add_another_heir = True d.accept() new_heir_button.clicked.connect(new_heir) new_heir_button.setDefault(True) grid.addWidget(QLabel(_("Name")), 1, 0) grid.addWidget(heir_name, 1, 1) grid.addWidget(HelpButton(_("Unique name or description about heir")), 1, 2) grid.addWidget(QLabel(_("Address")), 2, 0) grid.addWidget(heir_address, 2, 1) grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2) grid.addWidget(QLabel(_("Amount")), 3, 0) grid.addWidget(heir_amount, 3, 1) grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2) locktime_label = QLabel(_("Locktime")) enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get() if enable_multiverse: grid.addWidget(locktime_label, 4, 0) grid.addWidget(self.heir_locktime, 4, 1) grid.addWidget(HelpButton(_("locktime")), 4, 2) vbox.addLayout(grid) buttons = [CancelButton(d), OkButton(d)] if not heir: buttons.append(new_heir_button) vbox.addLayout(Buttons(*buttons)) while d.exec(): # TODO SAVE HEIR heir = [ heir_name.text(), heir_address.text(), Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()), str(self.will_settings["locktime"]), ] try: self.set_heir(heir) if self.add_another_heir: self.new_heir_dialog() break except Exception as e: self.show_error(str(e)) def set_heir(self, heir): heir = list(heir) if not self.bal_plugin.ENABLE_MULTIVERSE.get(): heir[3] = self.will_settings["locktime"] h = Heirs.validate_heir(heir[0], heir[1:]) self.heirs[heir[0]] = h self.heir_list_widget.update() return True def delete_heirs(self, heirs): for heir in heirs: try: del self.heirs[heir] except Exception as e: _logger.debug(f"error deleting heir: {heir} {e}") pass self.heirs.save() self.heir_list_widget.update() return True def import_heirs(self): import_meta_gui( self.window, _("heirs"), self.heirs.import_file, self.heir_list_widget.update, ) def export_heirs(self): export_meta_gui(self.window, "heirs.json", self.heirs.export_file) def prepare_will(self, ignore_duplicate=False, keep_original=False): will = self.build_inheritance_transaction( ignore_duplicate=ignore_duplicate, keep_original=keep_original ) return will def delete_not_valid(self, txid, s_utxo): raise NotImplementedError() 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, ignore_duplicate=True, keep_original=True): _logger.debug("building will...") will = {} # willtodelete = [] # willtoappend = {} if not self.will_settings: self.will_settings = self.bal_plugin.WILL_SETTINGS.get() Util.fix_will_settings_tx_fees(self.will_settings) try: 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): f = True if not f: _logger.error("No Will-Executor or backup transaction selected") raise NoWillExecutorNotPresent( "No Will-Executor or backup transaction selected" ) txs = self.heirs.get_transactions( self.bal_plugin, self.window.wallet, self.will_settings["baltx_fees"], None, self.date_to_check, ) _logger.info(f"txs built: {txs}") creation_time = time.time() if txs: for txid in txs: # txtodelete = [] _break = False 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) else: _logger.info("No transactions was built") _logger.info(f"will-settings: {self.will_settings}") _logger.info(f"date_to_check:{self.date_to_check}") _logger.info(f"heirs: {self.heirs}") return {} except Exception as e: _logger.info(f"Exception build_will: {e}") raise e pass return self.willitems def check_will(self): return Will.is_will_valid( self.willitems, self.date_to_check, self.will_settings["baltx_fees"], self.window.wallet.get_utxos(), heirs=self.heirs, willexecutors=self.willexecutors, self_willexecutor=self.no_willexecutor, wallet=self.wallet, callback_not_valid_tx=self.delete_not_valid, ) def show_message(self, text): self.window.show_message(text) def show_warning(self, text, parent=None): self.window.show_warning(text, parent=None) def show_error(self, text): self.window.show_error(text) def show_critical(self, text): self.window.show_critical(text) def update_combo_setting_widgets( self, new_value, field, update_all=False, update_will_dialog=False, update_heirs_dialog=False, ): if (update_all or update_will_dialog) and hasattr(self,'will_list_widget'): self.update_widget_combo(self.will_list_widget,field,new_value) if update_all or update_heirs_dialog and hasattr(self,'heir_list_widget'): self.update_widget_combo(self.heir_list_widget,field,new_value) def update_widget_combo(self,widget,field,value): try: widget.will_settings_widget.widgets[field].set_index(value) except Exception as _e: pass def update_widget_value(self, widget, field, value): try: widget.will_settings_widget.widgets[field].set_value(value) except Exception as _e: pass def update_setting_widgets( self, new_value, field, update_all=False, update_will_dialog=False, update_heirs_dialog=False, ): if update_all or update_heirs_dialog: self.update_widget_value(self.heir_list_widget, field, new_value) if update_all or update_will_dialog: self.update_widget_value(self.will_list_widget, field, new_value) self.will_settings[field] = new_value self.bal_plugin.WILL_SETTINGS.set(self.will_settings) def init_heirs_to_locktime(self, multiverse=False): if multiverse: return # Coerce the locktime to a plain serializable scalar: will_settings is # read from Electrum's config and a non-primitive value here would end # up inside the heirs dict and break json_db persistence (this was one # path to the "cannot pickle '_thread.RLock' object" error). locktime = self.will_settings["locktime"] if not isinstance(locktime, (int, float, str)): locktime = str(locktime) # Iterate over a snapshot of the keys: assigning to self.heirs[...] # triggers Heirs.__setitem__ -> save(), which mutates the mapping while # we iterate it. Building the new values first and applying them after # the loop avoids "dict changed size during iteration" and the repeated # save() on every heir. updates = { heir: [self.heirs[heir][0], self.heirs[heir][1], locktime] for heir in list(self.heirs) } for heir, value in updates.items(): self.heirs[heir] = value def init_class_variables(self): if not self.heirs: raise NoHeirsException(_("Heirs are not defined")) if not self.will_settings: self.will_settings = self.bal_plugin.WILL_SETTINGS.get() Util.fix_will_settings_tx_fees(self.will_settings) try: self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get() self.willexecutors = Willexecutors.get_willexecutors( self.bal_plugin, update=True, bal_window=self, task=False ) if self.date_to_check < datetime.now().timestamp(): raise CheckAliveError(self.date_to_check) self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) except Exception as e: log_error(e ) _logger.error(f"init_class_variables: {e}") raise e def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): try: if self.disable_plugin: _logger.info("plugin is disabled") return if not self.heirs: _logger.warning("not heirs {}".format(self.heirs)) return try: self.init_class_variables() Will.check_amounts( self.heirs, self.willexecutors, self.window.wallet.get_utxos(), self.date_to_check, self.window.wallet.dust_threshold(), ) except AmountException as e: self.show_warning( _( f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" ) ) except CheckAliveError: self.show_error( _( "CheckAlive is in the past please update it to a date in the future but less than locktime" ) ) return locktime = Util.parse_locktime_string(self.will_settings["locktime"]) if locktime < self.date_to_check: self.show_error(_("locktime is lower than threshold")) return if not self.no_willexecutor: f = False for _k, we in self.willexecutors.items(): if Willexecutors.is_selected(we): f = True if not f: self.show_error( _(" no backup transaction or willexecutor selected") ) return try: self.check_will() except WillExpiredException: self.invalidate_will() return except NoHeirsException: return except WillPostponedException as e: # The will was already signed/sent and is being postponed. # We do NOT rebuild automatically: the user must first sign and # broadcast the invalidation tx (so the old, earlier-locktime tx # can never be used by a will-executor), then press "Prepare" # again # to create the new postponed inheritance. _logger.info(f"will postponed: {e}") self.show_message( _( "This inheritance was already signed/sent to " "will-executors and you are postponing it.\n\n" "The previously committed coins must be invalidated " "on-chain FIRST, otherwise a will-executor could " "broadcast the old (earlier) transaction and execute " "the inheritance too early.\n\n" "Please sign and broadcast the invalidation transaction " "now, then press 'Prepare' again to create the new " "(postponed) inheritance." ) ) self.invalidate_will() return except NotCompleteWillException as e: _logger.info("{}:{}".format(type(e), e)) message = False if isinstance(e, HeirChangeException): message = "Heirs changed:" elif isinstance(e, WillExecutorNotPresent): message = "Will-Executor not present:" elif isinstance(e, WillexecutorChangeException): message = "Will-Executor changed" elif isinstance(e, TxFeesChangedException): message = "Txfees are changed" elif isinstance(e, HeirNotFoundException): message = "Heir not found" if message: self.show_message( f"{_(message)}:\n {e}\n{_('will have to be built')}" ) _logger.info("build will") self.build_will(ignore_duplicate, keep_original) # Track whether the rebuild produced a coherent, ready-to-sign # will, so we can guide the user through the remaining manual # steps (Sign + Broadcast) afterwards. rebuilt_ok = False try: self.check_will() for wid, _w in self.willitems.items(): self.wallet.set_label(wid, "BAL Transaction") rebuilt_ok = True except WillExpiredException as e: self.invalidate_will() except NotCompleteWillException as e: self.show_error( "Error:{}\n {}".format( str(e), _("Please, check your heirs, locktime and threshold!"), ) ) self.window.history_list.update() self.window.utxo_list.update() # Guide the user: the inheritance was just (re)built and is now # in the "New" state, so it must be SIGNED and then BROADCAST # again -- two manual steps the user has to perform. Without # this hint the user is left with a freshly rebuilt will and no # indication that it still needs to be signed and re-sent to the # will-executors. if rebuilt_ok: if self.no_willexecutor: next_steps = _( "Your inheritance has been rebuilt and now needs " "to be signed again.\n\n" "Next step (manual):\n" " 1. Press 'Sign' to sign the new transaction." ) else: next_steps = _( "Your inheritance has been rebuilt and now needs " "to be signed and re-sent to the will-executors.\n\n" "Next steps (manual):\n" " 1. Press 'Sign' to sign the new transaction.\n" " 2. Press 'Broadcast' to send it to the " "will-executors." ) self.show_message(next_steps) self.update_all() return self.willitems except Exception as e: raise e def show_transaction_real( self, tx: Transaction, *, parent: "ElectrumWindow", prompt_if_unsaved: bool = False, external_keypairs: Mapping[bytes, bytes] = None, payment_identifier: "PaymentIdentifier" = None, ): try: d = TxDialog( tx, parent=parent, prompt_if_unsaved=prompt_if_unsaved, external_keypairs=external_keypairs, # payment_identifier=payment_identifier, ) d.setWindowIcon( read_QIcon_from_bytes(self.bal_plugin.read_file("icons/bal16x16.png")) ) except SerializationError as e: _logger.error("unable to deserialize the transaction") parent.show_critical( _("Electrum was unable to deserialize the transaction:") + "\n" + str(e) ) else: # Electrum's own TxDialog: keep it in front of the main window. show_on_top(d, modal_to_window=False) return d def show_transaction(self, tx=None, txid=None, parent=None): if not parent: parent = self.window if txid is not None and txid in self.willitems: tx = self.willitems[txid].tx if not tx: raise Exception(_("no tx")) return self.show_transaction_real(tx, parent=parent) def invalidate_will(self): def on_success(result): if result: self.show_message( _( "Please sign and broadcast this transaction to invalidate current will" ) ) self.wallet.set_label(result.txid(), "BAL Invalidate") self.show_transaction(result) else: self.show_message(_("No transactions to invalidate")) def on_failure(exec_info): log_error(exec_info, self.bal_window) fee_per_byte = self.will_settings.get("baltx_fees", 1) task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte) msg = _("Calculating Transactions") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() def sign_transactions(self, password): try: txs = {} signed = None tosign = None def get_message(): msg = "" if signed: msg = _(f"signed: {signed}\n") return msg + _(f"signing: {tosign}") for txid in Will.only_valid(self.willitems): wi = self.willitems[txid] tx = copy.deepcopy(wi.tx) if wi.get_status("COMPLETE"): txs[txid] = tx continue tosign = txid try: self.waiting_dialog.update(get_message()) except Exception: pass for txin in tx.inputs(): prevout = txin.prevout.to_json() if prevout[0] in self.willitems: change = self.willitems[prevout[0]].tx.outputs()[prevout[1]] txin._trusted_value_sats = change.value try: txin.script_descriptor = change.script_descriptor except Exception: pass txin.is_mine = True txin._TxInput__address = change.address txin._TxInput__scriptpubkey = change.scriptpubkey txin._TxInput__value_sats = change.value self.wallet.sign_transaction(tx, password, ignore_warnings=True) signed = tosign # is_complete = False if tx.is_complete(): # is_complete = True wi.set_status("COMPLETE", True) txs[txid] = tx except Exception: return None return txs def get_wallet_password(self, message=None, parent=None): parent = self.window if not parent else parent password = None if self.wallet.has_keystore_encryption(): password = self.bal_plugin.password_dialog(parent=parent, msg=message) if password is None: return False try: self.wallet.check_password(password) except Exception as e: self.show_error(str(e)) password = self.get_wallet_password(message) return password def on_close(self): # Wallet is closing: run the closing "build will" task and tear down # the plugin's tabs/menu. Each step is isolated so that one failure # does not leave the GUI half-initialised (which previously forced the # user to restart Electrum). Errors are logged instead of silently # swallowed. if self.disable_plugin: return # 1) Business logic: build/save the will on close (unchanged behaviour). try: close_window = BalBuildWillDialog(self) close_window.build_will_task() self.save_willitems() except Exception as e: _logger.error(f"on_close: build/save will failed: {e}") # 2) GUI teardown - each action guarded independently. def _safe(desc, fn): try: fn() except Exception as e: _logger.error(f"on_close: {desc} failed: {e}") _safe("close heirs tab", lambda: self.heirs_tab.close()) _safe("close will tab", lambda: self.will_tab.close()) _safe( "remove willexecutors menu action", lambda: self.tools_menu.removeAction( self.tools_menu.willexecutors_action ), ) _safe("toggle heirs tab off", lambda: self.window.toggle_tab(self.heirs_tab)) _safe("toggle will tab off", lambda: self.window.toggle_tab(self.will_tab)) _safe("refresh tabs", lambda: self.window.tabs.update()) # 3) Reset in-memory state so re-enabling/re-opening starts clean. self.willitems = {} self.will = {} self.heirs = {} self.willexecutors = {} self.disable_plugin = True self.ok = False # The tabs/menu actions were removed above; allow init_menubar_tools to # re-wire them if this same window is reused for another wallet. self._menubar_initialized = False def ask_password_and_sign_transactions(self, callback=None): def on_success(txs): if txs: for txid, tx in txs.items(): self.willitems[txid].tx = copy.deepcopy(tx) self.will[txid] = self.willitems[txid].to_dict() try: self.will_list_widget.update() except Exception: pass if callback: try: callback() except Exception as e: raise e def on_failure(exec_info): log_error(exec_info, self.bal_window) password = self.get_wallet_password() task = partial(self.sign_transactions, password) msg = _("Signing transactions...") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() def broadcast_transactions(self, force=False): def on_success(sulcess): self.will_list_widget.update() if sulcess: _logger.info("error, some transaction was not sent") self.show_warning(_("Some transaction was not broadcasted")) return _logger.debug("OK, sulcess transaction was sent") self.show_message( _("All transactions are broadcasted to respective Will-Executors") ) def on_failure(exec_info): log_error(exec_info, self.bal_window) # a,b,c = err # _logger.error(f"fail to broadcast transactions:{err}") # _logger.error(f"error: {b}") # _logger.error("traceback ") # tb = c # while tb is not None: # frame = tb.tb_frame # _logger.error("file:", frame.f_code.co_filename) # _logger.error("name:", frame.f_code.co_name) # _logger.error("line:", tb.tb_lineno) # _logger.error("lasti:", tb.tb_lasti) # tb = tb.tb_next task = partial(self.push_transactions_to_willexecutors, force) msg = _("Selecting Will-Executors") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() def push_transactions_to_willexecutors(self, force=False): willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems) def getMsg(willexecutors): msg = "Broadcasting Transactions to Will-Executors:\n" for url in willexecutors: msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n" return msg # Initialise statuses + show the list immediately. for url in willexecutors: willexecutors[url].setdefault("broadcast_status", _("waiting...")) try: self.waiting_dialog.update(getMsg(willexecutors)) except Exception: pass error = {"flag": False} already_present = [] def on_each(url, willexecutor, ok, exc): # Runs from a worker thread. We only do book-keeping + a thread-safe # signal-based UI update here; the heavier "already present" check # path (which itself does network I/O) is handled below in the main # task thread to keep the original sequential behaviour for it. if isinstance(exc, Willexecutors.AlreadyPresentException): already_present.append(url) willexecutor["broadcast_status"] = _("checking...") elif ok: for wid in willexecutor.get("txsids", []): self.willitems[wid].set_status("PUSHED", True) willexecutor["broadcast_status"] = _("Success") else: for wid in willexecutor.get("txsids", []): self.willitems[wid].set_status("PUSH_FAIL", True) error["flag"] = True willexecutor["broadcast_status"] = _("Failed") willexecutor.pop("txs", None) try: self.waiting_dialog.update(getMsg(willexecutors)) except Exception: pass if self.waiting_dialog._stopping: return # Push to all servers in parallel (each server keeps its own retry # behaviour, but a slow/dead server no longer blocks the others). Willexecutors.push_transactions_parallel(willexecutors, on_each=on_each) # Handle the "already present" servers: verify each stored tx. This # keeps the exact original check logic, just executed after the parallel # push has identified which servers need it. for url in already_present: willexecutor = willexecutors[url] for wid in willexecutor.get("txsids", []): if self.waiting_dialog._stopping: return self.waiting_dialog.update( "checking {} - {} : {}".format( self.willitems[wid].we["url"], wid, "Waiting" ) ) w = self.willitems[wid] w.set_check_willexecutor( Willexecutors.check_transaction(wid, w.we["url"]) ) self.waiting_dialog.update( "checked {} - {} : {}".format( self.willitems[wid].we["url"], wid, self.willitems[wid].get_status("CHECKED"), ) ) if error["flag"]: return True def export_json_file(self, path): for wid in self.willitems: self.willitems[wid].set_status("EXPORTED", True) self.will[wid] = self.willitems[wid].to_dict() write_json_file(path, self.will) def export_will(self): try: export_meta_gui(self.window, "will.json", self.export_json_file) except Exception as e: self.show_error(str(e)) raise e def import_will(self): def sulcess(): self.will_list_widget.update_will(self.willitems) import_meta_gui(self.window, _("will"), self.import_json_file, sulcess) def import_json_file(self, path): try: data = read_json_file(path) willitems = {} for k, v in data.items(): data[k]["tx"] = tx_from_any(v["tx"]) willitems[k] = WillItem(data[k], _id=k) self.update_will(willitems) except Exception as e: raise e # raise FileImportFailed(_("Invalid will file")) def check_transactions_task(self, will): start = time.time() # Servers are now contacted in parallel (see # Willexecutors.check_transactions_parallel) with a fast-fail timeout and # a global deadline, so a single slow/dead will-executor no longer # freezes the "checking transaction" dialog for minutes. The dialog # shows live progress plus an elapsed-time counter (Xs / DEADLINEs). targets = [(wid, w.we["url"]) for wid, w in will.items() if w.we] total = len(targets) deadline = Willexecutors.CHECK_GLOBAL_DEADLINE done = {"count": 0} def _status_line(): return "{} {}/{} ({}s / {}s)".format( _("Checking transactions"), done["count"], total, min(int(time.time() - start), deadline), deadline, ) def on_each(wid, url, res, exc): # Reuse the original per-item logic: set_check_willexecutor handles # both a real response and a None/failure (-> CHECK_FAIL). try: will[wid].set_check_willexecutor(res) except Exception as e: _logger.error(f"check on_each error for {wid}: {e}") done["count"] += 1 self.waiting_dialog.update(_status_line()) def on_timeout(wid, url): # The global deadline elapsed before this server answered: mark the # item as failed (None response) so the user can retry later. try: will[wid].set_check_willexecutor(None) except Exception as e: _logger.error(f"check on_timeout error for {wid}: {e}") def on_tick(): if getattr(self.waiting_dialog, "_stopping", False): return self.waiting_dialog.update(_status_line()) if total: self.waiting_dialog.update(_status_line()) Willexecutors.check_transactions_parallel( targets, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick ) if time.time() - start < 3: time.sleep(3 - (time.time() - start)) def check_transactions(self, will): def on_success(result): if hasattr(self,"waiting_dialog"): del self.waiting_dialog self.update_all() pass def on_failure(exec_info): log_error(exec_info, self) # _logger.error(f"error checking transactions {e}") # pass task = partial(self.check_transactions_task, will) msg = _("Check Transaction") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() def update_willexecutor_list_widget(self, parent, willexecutors): try: parent.willexecutors_list.update(willexecutors) parent.will_executor_list_widget.update() except Exception as e: _logger.error(f"impossible to update will_executor_list_widget {e}") self.will_executors.update() def fetch_will_executors_list(self, old_willexecutors): """Download the will-executor list (runs inside the TaskThread worker). Tries the configured server first, then the original hardcoded endpoint, so a stale/bad config value cannot break the download. Detailed per-attempt diagnostics are written to the Electrum log only; the user sees a simple message. No business logic in ``bal.core`` is changed. Returns the downloaded dict (empty ``{}`` on failure). """ chainname = BalPlugin.chainname configured = self.bal_plugin.WELIST_SERVER.get() candidates = [] for base in (configured, "https://welist.bitcoin-after.life/"): if not base: continue base = base if base.endswith("/") else base + "/" url = f"{base}data/{chainname}?page=0&limit=100" if url not in candidates: candidates.append(url) result = {} net = Network.get_instance() _logger.info(f"fetch_will_executors_list: network present = {net is not None}") for url in candidates: _logger.info(f"fetch_will_executors_list: trying {url}") try: # Fast-fail with a couple of short retries instead of the # default 10x/3s storm: if the user's connection is flaky we # want to fall back to the next URL (and then show the simple # error message) quickly, not freeze for minutes. resp = Willexecutors.send_request( "get", url, timeout=10, max_retries=1, retry_sleep=1, ) _logger.info( f"fetch_will_executors_list: resp type={type(resp).__name__} " f"len={len(resp) if hasattr(resp, '__len__') else 'n/a'}" ) if resp: result = resp for w in result: if w not in ("status", "url"): Willexecutors.initialize_willexecutor( result[w], w, None, old_willexecutors.get(w, None), ) break _logger.warning(f"fetch_will_executors_list: {url} -> empty response") except Exception as e: _logger.error( f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}" ) return result # Simple, user-facing message shown when the download fails for any reason # (the technical cause is in the Electrum log). DOWNLOAD_FAILED_MESSAGE = ( "Could not download the will-executors list.\n\n" "This is usually caused by your internet connection or a firewall, " "not by the plugin. Please check your connection (a VPN often helps) " "and try again." ) def download_list(self, willexecutors, fn_on_success, fn_on_failure=None): if fn_on_failure is None: fn_on_failure = log_error base_msg = _("Downloading will-executors list...") download_start = time.time() # Upper bound shown to the user. fetch_will_executors_list tries up to # two endpoints, each with timeout=10 and one retry (~21s worst case), # so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how # long they may have to wait instead of an open-ended counter. download_deadline = 45 def task(): # Heartbeat: show an elapsed-seconds counter (with the max wait made # explicit) while the (blocking) download runs, so the user sees time # advancing instead of a seemingly frozen dialog on a slow link. stop_heartbeat = threading.Event() def _heartbeat(): while not stop_heartbeat.wait(1.0): if getattr(self.waiting_dialog, "_stopping", False): return try: self.waiting_dialog.update( "{} ({}s / {}s)".format( base_msg, min(int(time.time() - download_start), download_deadline), download_deadline, ) ) except Exception: return hb = threading.Thread(target=_heartbeat, name="bal-dl-hb", daemon=True) hb.start() try: return self.fetch_will_executors_list(willexecutors) finally: stop_heartbeat.set() def on_success(result): if result: self.willexecutors.update(result) fn_on_success(result) else: self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) def on_failure(exc_info): _logger.error(f"download_list failed: {exc_info}") self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) self.waiting_dialog = BalWaitingDialog( self, base_msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() def ping_willexecutors_task(self, wes): _logger.info("ping willexecutots task") # Track per-url state for the live status text. Servers are contacted # in parallel (see Willexecutors.ping_servers_parallel), so a single # unreachable server no longer blocks all the others: the whole batch # now takes about as long as the slowest server instead of the sum of # every server's (possibly timing-out) request. pinged = set() failed = set() total = len(wes) ping_start = time.time() ping_deadline = Willexecutors.PUSH_GLOBAL_DEADLINE def get_title(): # Header shows progress + an elapsed-seconds counter with the max # wait made explicit (e.g. "3s / 30s"), so the user sees time # advancing and knows how long it may take, instead of a seemingly # frozen dialog. answered = len(pinged) + len(failed) msg = _("Ping Will-Executors:") msg += " {}/{} ({}s / {}s)".format( answered, total, min(int(time.time() - ping_start), ping_deadline), ping_deadline, ) msg += "\n\n" for url in wes: urlstr = "{:<50}: ".format(url[:50]) if url in pinged: urlstr += _("Ok") elif url in failed: urlstr += _("Ko") else: urlstr += _("waiting...") urlstr += "\n" msg += urlstr return msg def on_each(url, we, ok): if ok: pinged.add(url) else: failed.add(url) try: self.waiting_dialog.update(get_title()) except Exception: pass # Show the initial "waiting..." list immediately. try: self.waiting_dialog.update(get_title()) except Exception: pass # Refresh the elapsed-seconds counter while the (blocking) parallel ping # runs. The tick is driven from THIS thread by ping_servers_parallel, # the same thread that drives on_each, so the dialog repaint is reliable. def on_tick(): if getattr(self.waiting_dialog, "_stopping", False): return try: self.waiting_dialog.update(get_title()) except Exception: pass Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick) def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None): def on_success(result): fn_on_success(result) def on_failure(exec_info): fn_on_failure(exec_info) if not fn_on_failure: fn_on_failure = log_error _logger.info("ping willexecutors") task = partial(self.ping_willexecutors_task, wes) msg = _("Ping Will-Executors") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() def preview_modal_dialog(self): self.dw = WillDetailDialog(self) # This dialog is meant to be modal (per its name); show it on top so it # cannot disappear behind the Electrum window. show_on_top(self.dw) def update_all(self): try: # Re-sync the cached "hide invalidated/replaced" flags from the # persisted config before refreshing the list. The Settings dialog # checkboxes write the config directly (without touching the cached # flags), so without this the list would keep filtering with the old # value and the invalidated/replaced rows would not appear/disappear # until Electrum was restarted. self.bal_plugin.sync_hide_filters() Will.add_willtree(self.willitems) all_utxos = self.wallet.get_utxos() utxos_list = Will.utxos_strs(all_utxos) Will.check_invalidated(self.willitems, utxos_list, self.wallet) self.will_list_widget.update_will(self.willitems) self.heirs_tab.update() self.will_tab.update() self.will_list_widget.update() except Exception as e: _logger.error(f"error while updating window: {e}")