The Building Will report showed a fixed list of three "possible reasons"
whenever a build produced nothing, regardless of what actually happened; in a
case reproduced from the owner log all three were false and the real cause was
not even listed. The "Checking your will" row had the same flaw, showing one
sentence ("Found CHANGES to the DATE or the HEIRS") for five situations,
including one where it is plainly wrong (funds received).
core/heirs.py: record WHY buildTransactions gave up in a new last_build_error
attribute (8 reason codes), set at each path that previously returned empty
with no explanation, plus a processed_willexecutors counter to tell "every
will-executor was skipped" apart from "we tried and failed". Also fix a latent
crash in the prepare_transactions handler, which read a no-longer-existing
e.heirname attribute and re-raised the resulting AttributeError, masking the
real error.
gui/qt/dialogs.py: add msg_alert() (amber warning sign, body text in the theme
colour, readable in both themes), _build_failure_message() and
_check_failure_message() to turn those causes into one precise sentence each,
with an honest "cause could not be determined" fallback. Catch
BalanceTooLowException, which already carried the figures but fell through to
the generic red technical error.
No new exception classes were introduced (owner request): the plain
NotCompleteWillException cases are told apart structurally, not by text.
2542 lines
108 KiB
Python
2542 lines
108 KiB
Python
"""
|
|
bal.gui.qt.dialogs
|
|
==================
|
|
|
|
All modal/non-modal dialogs of the plugin.
|
|
|
|
* BalDialog - common base dialog (icon, close handling).
|
|
* BalWizard* (Dialog/Widget) - the step-by-step "create your will" wizard.
|
|
* BalWaitingDialog /
|
|
BalBlockingWaitingDialog - progress dialogs for background tasks.
|
|
* BalBuildWillDialog - the central build/sign/push/broadcast flow.
|
|
* WillDetailDialog - shows the full will tree for one wallet.
|
|
* WillExecutorDialog - manage the list of will-executor servers.
|
|
|
|
To keep the dialogs verbatim while avoiding import cycles with the list views,
|
|
the few list classes they reference are imported lazily inside the methods that
|
|
use them (see ``lists`` imports below).
|
|
"""
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from ...core.checkalive import CheckAliveError
|
|
from ...core.reminders import build_ics_reminders
|
|
from .calendar import BalCalendarButton
|
|
from .common import (
|
|
_,
|
|
_logger,
|
|
AmountException,
|
|
Any,
|
|
BalTimestamp,
|
|
BalanceTooLowException,
|
|
BestEffortRequestFailed,
|
|
Buttons,
|
|
Callable,
|
|
CancelButton,
|
|
HEIR_DUST_AMOUNT,
|
|
HEIR_REAL_AMOUNT,
|
|
HeirAmountIsDustException,
|
|
HeirChangeException,
|
|
HeirNotFoundException,
|
|
MessageBoxMixin,
|
|
Network,
|
|
NoHeirsException,
|
|
NoWillExecutorNotPresent,
|
|
NotCompleteWillException,
|
|
QComboBox,
|
|
QDialog,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QSizePolicy,
|
|
QTimer,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
Qt,
|
|
TaskThread,
|
|
TxBroadcastError,
|
|
TxFeesChangedException,
|
|
Util,
|
|
Will,
|
|
WillExecutorFeeTooHighException,
|
|
WillExecutorNotPresent,
|
|
WillExpiredException,
|
|
WillPostponedException,
|
|
WillexecutorChangeException,
|
|
Willexecutors,
|
|
bring_to_front,
|
|
decimal_point_to_base_unit_name,
|
|
import_meta_gui,
|
|
partial,
|
|
pyqtSignal,
|
|
read_QIcon_from_bytes,
|
|
read_json_file,
|
|
show_modal,
|
|
show_on_top,
|
|
stop_thread,
|
|
time,
|
|
top_level_of,
|
|
)
|
|
from .widgets import (
|
|
WillSettingsWidget,
|
|
WillWidget,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from .window import BalWindow
|
|
|
|
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
|
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
|
|
|
|
|
class BalDialog(QDialog,MessageBoxMixin):
|
|
_stopping = False
|
|
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
|
|
from PyQt6.QtCore import QMetaObject, Qt
|
|
def handler(signum, frame):
|
|
QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection)
|
|
|
|
#signal.signal(signal.SIGINT, handler)
|
|
# NOTE: do NOT store this as ``self.parent`` - that would shadow
|
|
# QWidget.parent() and can make the dialog disappear behind Electrum.
|
|
self._bal_parent = parent
|
|
self.thread = None
|
|
# Anchor the dialog to the *top-level* Electrum window so it always
|
|
# stays in front of it (instead of falling behind).
|
|
super().__init__(top_level_of(parent))
|
|
if title:
|
|
self.setWindowTitle(title)
|
|
# WindowModalDialog.__init__(self,parent)
|
|
self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon)))
|
|
|
|
def closeEvent(self, event):
|
|
self._stopping = True
|
|
# NOTE: we deliberately do NOT stop ``self.thread`` here.
|
|
#
|
|
# Electrum's ``TaskThread`` delivers results via ``on_done`` which calls
|
|
# ``cb_done`` (often ``self.accept`` -> closes this dialog) *before*
|
|
# ``cb_result`` (``on_success`` -> e.g. updating the will-executor
|
|
# list). If we stop/join the thread inside ``closeEvent`` the close
|
|
# triggered by ``accept`` tears the thread down *before* ``on_success``
|
|
# runs, so the downloaded data is silently dropped. The original plugin
|
|
# left this commented out for exactly this reason; subclasses that own a
|
|
# genuinely long-lived thread stop it explicitly in their own close
|
|
# handler.
|
|
super().closeEvent(event)
|
|
|
|
def hideEvent(self, event):
|
|
self._stopping = True
|
|
super().hideEvent(event)
|
|
|
|
|
|
class BalWizardDialog(BalDialog):
|
|
def __init__(self, bal_window: "BalWindow"):
|
|
assert bal_window
|
|
BalDialog.__init__(
|
|
self, bal_window.window, bal_window.bal_plugin, _("Bal Wizard Setup")
|
|
)
|
|
self.setMinimumSize(800, 400)
|
|
self.bal_window = bal_window
|
|
self._bal_parent = bal_window.window
|
|
self.layout = QVBoxLayout(self)
|
|
self.widget = BalWizardHeirsWidget(
|
|
bal_window, self, self.on_next_heir, None, self.on_cancel_heir
|
|
)
|
|
self.layout.addWidget(self.widget)
|
|
|
|
def next_widget(self, widget):
|
|
self.layout.removeWidget(self.widget)
|
|
self.widget.close()
|
|
self.widget = widget
|
|
self.layout.addWidget(self.widget)
|
|
# self.update()
|
|
# self.repaint()
|
|
|
|
def on_next_heir(self):
|
|
self.next_widget(
|
|
BalWizardLocktimeAndFeeWidget(
|
|
self.bal_window,
|
|
self,
|
|
self.on_next_locktimeandfee,
|
|
self.on_previous_heir,
|
|
self.on_cancel_heir,
|
|
)
|
|
)
|
|
|
|
def on_previous_heir(self):
|
|
self.next_widget(
|
|
BalWizardHeirsWidget(
|
|
self.bal_window, self, self.on_next_heir, None, self.on_cancel_heir
|
|
)
|
|
)
|
|
|
|
def on_cancel_heir(self):
|
|
pass
|
|
|
|
def on_next_wedonwload(self):
|
|
self.next_widget(
|
|
BalWizardWEWidget(
|
|
self.bal_window,
|
|
self,
|
|
self.on_next_we,
|
|
self.on_next_locktimeandfee,
|
|
self.on_cancel_heir,
|
|
)
|
|
)
|
|
|
|
def on_next_we(self):
|
|
close_window = BalBuildWillDialog(self.bal_window)
|
|
close_window.build_will_task()
|
|
|
|
# Run the SAME final server check as the "Check" button (allegato15,
|
|
# case B): previously the wizard only ran build_will_task() and skipped
|
|
# the will-executor verification, so the user always had to press
|
|
# "Check" manually after finishing the wizard. We now replicate exactly
|
|
# the lists.py check() logic: after building, query every will that
|
|
# needs a server check (Will.needs_server_check) and run
|
|
# check_transactions(), which shows the "Checking transactions" dialog.
|
|
will = {}
|
|
for wid, w in self.bal_window.willitems.items():
|
|
if Will.needs_server_check(w):
|
|
will[wid] = w
|
|
if will:
|
|
self.bal_window.check_transactions(will)
|
|
|
|
self.close()
|
|
# self.next_widget(BalWizardLocktimeAndFeeWidget(self.bal_window,self,self.on_next_locktimeandfee,self.on_next_wedonwload,self.on_next_wedonwload.on_cancel_heir))
|
|
|
|
def on_next_locktimeandfee(self):
|
|
self.next_widget(
|
|
BalWizardWEDownloadWidget(
|
|
self.bal_window,
|
|
self,
|
|
self.on_next_wedonwload,
|
|
self.on_next_heir,
|
|
self.on_cancel_heir,
|
|
)
|
|
)
|
|
|
|
def on_accept(self):
|
|
self.bal_window.update_all()
|
|
pass
|
|
|
|
def on_reject(self):
|
|
pass
|
|
|
|
def on_close(self):
|
|
self.bal_window.update_all()
|
|
pass
|
|
|
|
def closeEvent(self, event):
|
|
self._stopping = True
|
|
# self.bal_window.heir_list_widget.will_settings_widget.update_will_settings()
|
|
pass
|
|
|
|
|
|
|
|
class BalWizardWidget(QWidget):
|
|
title = None
|
|
message = None
|
|
|
|
def __init__(
|
|
self, bal_window: "BalWindow", parent, on_next, on_previous, on_cancel
|
|
):
|
|
QWidget.__init__(self, parent)
|
|
self.vbox = QVBoxLayout(self)
|
|
self.bal_window = bal_window
|
|
self._bal_parent = parent
|
|
self.on_next = on_next
|
|
self.on_cancel = on_cancel
|
|
self.titleLabel = QLabel(self.title)
|
|
self.vbox.addWidget(self.titleLabel)
|
|
self.messageLabel = QLabel(_(self.message))
|
|
self.vbox.addWidget(self.messageLabel)
|
|
|
|
self.content = self.get_content()
|
|
self.content_container = QWidget()
|
|
self.containrelayout = QVBoxLayout(self.content_container)
|
|
self.containrelayout.addWidget(self.content)
|
|
|
|
self.vbox.addWidget(self.content_container)
|
|
|
|
spacer_widget = QWidget()
|
|
spacer_widget.setSizePolicy(
|
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
|
)
|
|
self.vbox.addWidget(spacer_widget)
|
|
|
|
self.buttons = []
|
|
if on_previous:
|
|
self.on_previous = on_previous
|
|
self.previous_button = QPushButton(_("Previous"))
|
|
self.previous_button.clicked.connect(self._on_previous)
|
|
self.buttons.append(self.previous_button)
|
|
|
|
self.next_button = QPushButton(_("Next"))
|
|
self.next_button.clicked.connect(self._on_next)
|
|
self.buttons.append(self.next_button)
|
|
|
|
self.abort_button = QPushButton(_("Cancel"))
|
|
self.abort_button.clicked.connect(self._on_cancel)
|
|
self.buttons.append(self.abort_button)
|
|
|
|
self.vbox.addLayout(Buttons(*self.buttons))
|
|
|
|
def _on_cancel(self):
|
|
self.on_cancel()
|
|
self._bal_parent.close()
|
|
|
|
def _on_next(self):
|
|
if self.validate():
|
|
self.on_next()
|
|
|
|
def _on_previous(self):
|
|
self.on_previous()
|
|
|
|
def get_content(self):
|
|
pass
|
|
|
|
def validate(self):
|
|
return True
|
|
|
|
|
|
|
|
class BalWizardHeirsWidget(BalWizardWidget):
|
|
title = "Bitcoin After Life Heirs"
|
|
message = (
|
|
"Please add your heirs\n remember that 100% of wallet balance will be spent"
|
|
)
|
|
|
|
def get_content(self):
|
|
# Lazy import to avoid a dialogs<->lists import cycle (lists imports
|
|
# BalBuildWillDialog from this module at load time).
|
|
from .lists import HeirListWidget
|
|
self.heir_list_widget = HeirListWidget(self.bal_window, self)
|
|
button_add = QPushButton(_("Add"))
|
|
button_add.clicked.connect(self.add_heir)
|
|
button_import = QPushButton(_("Import"))
|
|
button_import.clicked.connect(self.import_from_file)
|
|
button_export = QPushButton(_("Export"))
|
|
button_export.clicked.connect(self.export_to_file)
|
|
widget = QWidget()
|
|
vbox = QVBoxLayout(widget)
|
|
vbox.addWidget(self.heir_list_widget)
|
|
vbox.addLayout(Buttons(button_add, button_import, button_export))
|
|
return widget
|
|
|
|
def import_from_file(self):
|
|
self.bal_window.import_heirs()
|
|
self.heir_list_widget.update()
|
|
|
|
def export_to_file(self):
|
|
self.bal_window.export_heirs()
|
|
|
|
def add_heir(self):
|
|
self.bal_window.new_heir_dialog()
|
|
self.heir_list_widget.update()
|
|
|
|
def validate(self):
|
|
return True
|
|
|
|
|
|
|
|
class BalWizardWEDownloadWidget(BalWizardWidget):
|
|
title = _("Bitcoin After Life Will-Executors")
|
|
message = _("Choose willexecutors download method")
|
|
|
|
def get_content(self):
|
|
# question = QLabel()
|
|
self.combo = QComboBox()
|
|
self.combo.addItems(
|
|
[
|
|
"Automatically download and select willexecutors",
|
|
"Only download willexecutors list",
|
|
"Import willexecutor list from file",
|
|
"Manual",
|
|
]
|
|
)
|
|
# heir_name.setFixedWidth(32 * char_width_in_lineedit())
|
|
return self.combo
|
|
|
|
def validate(self):
|
|
return True
|
|
|
|
def _on_next(self):
|
|
|
|
index = self.combo.currentIndex()
|
|
_logger.debug(f"selected index:{index}")
|
|
if index < 3:
|
|
self.bal_window.willexecutors = Willexecutors.get_willexecutors(
|
|
self.bal_window.bal_plugin
|
|
)
|
|
|
|
if index == 2:
|
|
|
|
def do_nothing():
|
|
self.bal_window.willexecutors.update(self.willexecutors)
|
|
Willexecutors.save(
|
|
self.bal_window.bal_plugin, self.bal_window.willexecutors
|
|
)
|
|
pass
|
|
|
|
import_meta_gui(
|
|
self.bal_window.window,
|
|
_("willexecutors"),
|
|
self.import_json_file,
|
|
do_nothing,
|
|
)
|
|
|
|
if index < 2:
|
|
|
|
def on_success(willexecutors):
|
|
def ping_on_success(result):
|
|
ping_on_done()
|
|
|
|
def ping_on_failure(exec_info):
|
|
ping_on_done()
|
|
|
|
def ping_on_done():
|
|
# Task #02 - "Automatically download and select"
|
|
# (index 0): the green SELECTED tick must follow the
|
|
# green ping dot, i.e. select ONLY servers that actually
|
|
# answered the ping (status == 200) and DESELECT every
|
|
# server that did not (timeout / error / never pinged).
|
|
#
|
|
# Why the explicit deselect matters: previously a server
|
|
# that had been selected on an earlier download but is
|
|
# now unreachable stayed selected, so the plugin kept
|
|
# broadcasting to a dead server and got stuck. Forcing
|
|
# selected=False for non-200 servers discards them at the
|
|
# source. Re-running this (each "Automatically download"
|
|
# action) re-evaluates every server: one that failed
|
|
# before but now answers is selected again.
|
|
#
|
|
# We compare the status as a string ("200") to stay
|
|
# consistent with the will-executor list view
|
|
# (lists.py uses str(status) == "200"), and use .get()
|
|
# so a missing "status" key never raises.
|
|
if index < 1:
|
|
for we in self.bal_window.willexecutors:
|
|
wedict = self.bal_window.willexecutors[we]
|
|
responded = str(wedict.get("status", "")) == "200"
|
|
wedict["selected"] = responded
|
|
Willexecutors.save(
|
|
self.bal_window.bal_plugin, self.bal_window.willexecutors
|
|
)
|
|
|
|
self.bal_window.ping_willexecutors(
|
|
self.bal_window.willexecutors, ping_on_success, ping_on_failure
|
|
)
|
|
|
|
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
|
|
|
elif index == 3:
|
|
# TODO DO NOTHING
|
|
pass
|
|
|
|
self.bal_window.will_list_widget.update()
|
|
if self.validate():
|
|
return self.on_next()
|
|
|
|
def import_json_file(self, path):
|
|
data = read_json_file(path)
|
|
data = self._validate(data)
|
|
self.willexecutors = data
|
|
|
|
def _validate(self, data):
|
|
return data
|
|
|
|
|
|
|
|
class BalWizardWEWidget(BalWizardWidget):
|
|
title = "Bitcoin After Life Will-Executors"
|
|
message = _("Configure and select your willexecutors")
|
|
|
|
def get_content(self):
|
|
# Lazy import to avoid a dialogs<->lists import cycle.
|
|
from .lists import WillExecutorWidget
|
|
widget = QWidget()
|
|
vbox = QVBoxLayout(widget)
|
|
vbox.addWidget(
|
|
WillExecutorWidget(
|
|
self,
|
|
self.bal_window,
|
|
Willexecutors.get_willexecutors(self.bal_window.bal_plugin),
|
|
)
|
|
)
|
|
return widget
|
|
|
|
|
|
|
|
class BalWizardLocktimeAndFeeWidget(BalWizardWidget):
|
|
title = "Bitcoin After Life Will Settings"
|
|
message = _("")
|
|
|
|
def get_content(self):
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
|
|
# The wizard ("Build your will") is the ONLY place the delivery time,
|
|
# check alive and fee can be edited, so it is the only read_only=False.
|
|
layout.addWidget(WillSettingsWidget(self.bal_window, self, "v",
|
|
read_only=False))
|
|
spacer_widget = QWidget()
|
|
spacer_widget.setSizePolicy(
|
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
|
)
|
|
layout.addWidget(spacer_widget)
|
|
return widget
|
|
|
|
|
|
|
|
class BalWaitingDialog(BalDialog):
|
|
updatemessage = pyqtSignal([str], arguments=["message"])
|
|
|
|
def __init__(
|
|
self,
|
|
bal_window: "BalWindow",
|
|
message: str,
|
|
task,
|
|
on_success=None,
|
|
on_error=None,
|
|
on_cancel=None,
|
|
exe=True,
|
|
):
|
|
assert bal_window
|
|
BalDialog.__init__(
|
|
self, bal_window.window, bal_window.bal_plugin, _("Please wait")
|
|
)
|
|
self.message_label = QLabel(message)
|
|
vbox = QVBoxLayout(self)
|
|
vbox.addWidget(self.message_label)
|
|
self.updatemessage.connect(self.update_message)
|
|
if on_cancel:
|
|
self.cancel_button = CancelButton(self)
|
|
self.cancel_button.clicked.connect(on_cancel)
|
|
vbox.addLayout(Buttons(self.cancel_button))
|
|
self.accepted.connect(self.on_accepted)
|
|
self.task = task
|
|
self.on_success = on_success
|
|
self.on_error = on_error
|
|
self.on_cancel = on_cancel
|
|
if exe:
|
|
self.exe()
|
|
|
|
def exe(self):
|
|
self.thread = TaskThread(self)
|
|
self.thread.finished.connect(self.deleteLater) # see #3956
|
|
self.thread.add(self.task, self.on_success, self.accept, self.on_error)
|
|
# IMPORTANT: keep the *application-modal* exec() of the original code.
|
|
# This dialog is driven by a TaskThread whose result (on_success, e.g.
|
|
# populating the will-executor list) is delivered via a queued signal
|
|
# while exec() spins the modal event loop. Switching to window-modal
|
|
# changed how the modal loop interacts with that delivery and could
|
|
# cause the downloaded list to never be applied. We only add the
|
|
# raise/activate so the dialog stays visible, without altering modality.
|
|
bring_to_front(self)
|
|
self.exec()
|
|
|
|
def hello(self):
|
|
pass
|
|
|
|
|
|
def on_accepted(self):
|
|
pass
|
|
|
|
def update_message(self, msg):
|
|
self.message_label.setText(msg)
|
|
|
|
def update(self, msg):
|
|
self.updatemessage.emit(msg)
|
|
|
|
def getText(self):
|
|
return self.message_label.text()
|
|
|
|
|
|
|
|
|
|
class BalBlockingWaitingDialog(BalDialog):
|
|
def __init__(self, bal_window: "BalWindow", message: str, task: Callable[[], Any]):
|
|
BalDialog.__init__(self, bal_window, bal_window.bal_plugin, _("Please wait"))
|
|
self.message_label = QLabel(message)
|
|
vbox = QVBoxLayout(self)
|
|
vbox.addWidget(self.message_label)
|
|
self.finished.connect(self.deleteLater) # see #3956
|
|
# show popup (window-modal + on top so it is actually visible)
|
|
show_on_top(self)
|
|
# Refresh the GUI so the popup is painted (and message_label drawn)
|
|
# BEFORE we block the GUI thread running the task; otherwise the popup
|
|
# appears empty/frozen.
|
|
from PyQt6.QtWidgets import QApplication
|
|
QApplication.processEvents()
|
|
QApplication.processEvents()
|
|
try:
|
|
# block and run given task
|
|
task()
|
|
finally:
|
|
# close popup
|
|
self.accept()
|
|
|
|
|
|
class BalBuildWillDialog(BalDialog):
|
|
updatemessage = pyqtSignal()
|
|
COLOR_WARNING = "#cfa808"
|
|
COLOR_ERROR = "#ff0000"
|
|
COLOR_OK = "#05ad05"
|
|
|
|
def __init__(self, bal_window, parent=None):
|
|
if not parent:
|
|
parent = bal_window.window
|
|
BalDialog.__init__(self, parent, bal_window.bal_plugin, _("Building Will"))
|
|
# (parent already stored as self._bal_parent by BalDialog.__init__)
|
|
self.updatemessage.connect(self.msg_update)
|
|
self.bal_window = bal_window
|
|
self.bal_plugin = bal_window.bal_plugin
|
|
self.message_label = QLabel(_("Building Will:"))
|
|
# Allow the long report text to wrap instead of forcing the dialog ever
|
|
# wider, and let it grow downward inside the scroll area below.
|
|
self.message_label.setWordWrap(True)
|
|
self.message_label.setAlignment(
|
|
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft
|
|
)
|
|
self.vbox = QVBoxLayout(self)
|
|
|
|
# SCROLLABLE message area (allegato14): with many will-executors the
|
|
# report can reach dozens of lines. Previously the dialog kept resizing
|
|
# itself taller for every new line (see msg_update's resize), so with
|
|
# e.g. 50 will-executors the window grew past the screen and the bottom
|
|
# buttons (Close) became unreachable. We now put the message label in a
|
|
# QScrollArea with a capped maximum height: once the text exceeds that
|
|
# height a vertical scrollbar appears and the buttons stay visible.
|
|
self.scroll_area = QScrollArea(self)
|
|
self.scroll_area.setWidgetResizable(True)
|
|
self.scroll_area.setWidget(self.message_label)
|
|
# Open the report area ~450px tall (owner request: 500px left too much
|
|
# empty space below the short report; 450px lines up with the desired
|
|
# window height). The dialog may still grow up to 700px to fit a few more
|
|
# lines; beyond that the vertical scrollbar takes over and the bottom
|
|
# buttons stay reachable.
|
|
self.scroll_area.setMinimumHeight(450)
|
|
self.scroll_area.setMaximumHeight(700)
|
|
self.vbox.addWidget(self.scroll_area, 1)
|
|
|
|
# Kept for backward compatibility (referenced by old/commented code);
|
|
# no longer used to lay out the messages.
|
|
self.qwidget = QWidget(self)
|
|
self.labelsbox = QVBoxLayout(self.qwidget)
|
|
self.setMinimumWidth(600)
|
|
self.setMinimumHeight(100)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
self.labels = []
|
|
self.check_row = None
|
|
self.inval_row = None
|
|
self.build_row = None
|
|
self.sign_row = None
|
|
self.push_row = None
|
|
# Manual next-steps hint (Sign / Broadcast) shown to the user after the
|
|
# dialog finishes; None when nothing is left to do.
|
|
self._next_steps_hint = None
|
|
# Set to True by _sync_locktime_to_built_txs when the delivery date was
|
|
# automatically anticipated during a rebuild. Used to explain to the
|
|
# user WHY signing is being requested (otherwise the sign prompt appears
|
|
# without any reason, as the owner reported).
|
|
self._date_was_anticipated = False
|
|
# Set to True right after we broadcast an automatic invalidation
|
|
# transaction (the "postpone" path). On the very next phase-1 re-check
|
|
# Electrum may not have seen the invalidation tx yet, so it would still
|
|
# report a postpone and the wizard would re-prompt to invalidate over
|
|
# and over (the reported loop). When this flag is set and a postpone is
|
|
# STILL detected, we STOP with a clear message instead of re-prompting.
|
|
self._invalidation_broadcast = False
|
|
self.network = Network.get_instance()
|
|
self._stopping = False
|
|
self.thread = TaskThread(self)
|
|
self.thread.finished.connect(self.task_finished) # see #3956
|
|
|
|
def task_finished(self):
|
|
pass
|
|
|
|
def build_will_task(self):
|
|
_logger.debug("build will task to be started")
|
|
self.thread.add(
|
|
self.task_phase1,
|
|
on_success=self.on_success_phase1,
|
|
on_done=self.on_accept,
|
|
on_error=self.on_error_phase1,
|
|
)
|
|
# exec() already shows the dialog modally; route through the helper so
|
|
# it is window-modal and brought to the front (no separate show()).
|
|
show_modal(self)
|
|
|
|
def task_phase1(self):
|
|
if self._stopping:
|
|
return
|
|
txs = None
|
|
_logger.debug("close plugin phase 1 started")
|
|
varrow = self.msg_set_status("Checking variables")
|
|
try:
|
|
self.bal_window.init_class_variables()
|
|
except CheckAliveError as cae:
|
|
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
|
tx = Will.invalidate_will(
|
|
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
|
|
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
|
|
will_locktime=Will.get_min_locktime(
|
|
self.bal_window.willitems,
|
|
default_value=self.bal_window.date_to_check,
|
|
),
|
|
)
|
|
if tx:
|
|
_logger.debug(
|
|
"during phase1 CAE: {}, Continue to invalidate".format(cae)
|
|
)
|
|
self.msg_set_status(
|
|
"Checking variables", varrow,
|
|
"Check Alive Threshold Passed: you have to Invalidate "
|
|
"your old Will",
|
|
self.COLOR_ERROR,
|
|
)
|
|
else:
|
|
raise cae
|
|
return None, tx
|
|
except NoHeirsException:
|
|
self.msg_set_status(
|
|
"Checking variables", varrow, self.msg_alert("No Heirs")
|
|
)
|
|
return "no_heirs", None
|
|
except Exception as e:
|
|
raise e
|
|
try:
|
|
_logger.debug("checking variables")
|
|
Will.check_amounts(
|
|
self.bal_window.heirs,
|
|
self.bal_window.willexecutors,
|
|
Util.get_available_utxos(
|
|
self.bal_window.window.wallet,
|
|
self.bal_window.bal_plugin.HISTORY_LABEL.get(),
|
|
Will.get_min_locktime(
|
|
self.bal_window.willitems,
|
|
default_value=self.bal_window.date_to_check,
|
|
),
|
|
),
|
|
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)
|
|
except AmountException:
|
|
self.msg_set_checking(
|
|
self.msg_warning(
|
|
"In the inheritance process, "
|
|
+ "the entire wallet will always be fully emptied. \n"
|
|
+ "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
|
|
try:
|
|
self.bal_window.check_will()
|
|
self.msg_set_checking(self.msg_ok())
|
|
except WillExpiredException:
|
|
# UNIFY INVALIDATE PROCEDURE (+ task #03):
|
|
#
|
|
# The will is already expired (e.g. the CHECK button is pressed on an
|
|
# expired will). Previously this returned (None, invalidate_tx),
|
|
# which routed to the automatic invalidate path (password prompt +
|
|
# auto-broadcast) that did NOT set the "BAL Invalidate transaction"
|
|
# history label.
|
|
#
|
|
# We now return the SAME "invalidate_classic" signal used elsewhere,
|
|
# so on_success_phase1 shows the warning popup and auto-opens
|
|
# Electrum's classic transaction window (which sets the label). This
|
|
# makes the CHECK button and the WIZARD behave identically and fixes
|
|
# the missing-label bug (#03).
|
|
_logger.debug("expired")
|
|
self.msg_set_checking("Expired")
|
|
return "invalidate_classic", None
|
|
except WillPostponedException as e:
|
|
# An already signed/sent will is being postponed. Like an expired
|
|
# will, the previously committed coins must be invalidated on-chain
|
|
# FIRST (otherwise a will-executor could broadcast the old,
|
|
# earlier-locktime tx and execute the inheritance too early). We
|
|
# return (None, tx) so phase 2 asks the user to sign and broadcast
|
|
# the invalidation; afterwards the user presses Prepare again to
|
|
# rebuild the new (postponed) inheritance.
|
|
_logger.debug(f"postponed {e}")
|
|
self.msg_set_checking(_("Postponed: invalidating old will"))
|
|
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
|
return None, Will.invalidate_will(
|
|
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
|
|
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
|
|
will_locktime=Will.get_min_locktime(
|
|
self.bal_window.willitems,
|
|
default_value=self.bal_window.date_to_check,
|
|
),
|
|
)
|
|
except NoHeirsException:
|
|
_logger.debug("no heirs")
|
|
self.msg_set_checking("No Heirs")
|
|
except NotCompleteWillException as e:
|
|
_logger.debug(f"not complete {e} true")
|
|
message = False
|
|
have_to_build = True
|
|
# Task #7a: if the will was already executed, the wallet is empty and
|
|
# this exception is expected. Before showing the alarming "Found
|
|
# CHANGES ... a NEW WILL must be prepared" message, add a clear,
|
|
# reassuring note on the "Checking your will" row telling the user
|
|
# the inheritance is already on its way (mempool) or done (on-chain).
|
|
# The original message is still shown afterwards (owner request: the
|
|
# red/"changes" message stays, this is only an extra, more precise
|
|
# informative line).
|
|
# NOTE: we add the informative note as its OWN extra row (not via
|
|
# msg_set_checking, which reuses self.check_row and would be
|
|
# overwritten by the "Found CHANGES" line set below). Passing row=None
|
|
# to msg_set_status appends a new line, so the executed/mempool note
|
|
# and the original message are BOTH visible.
|
|
executed_status = self._executed_inheritance_status()
|
|
if executed_status == "CONFIRMED":
|
|
# Green: the inheritance transaction is confirmed on the
|
|
# blockchain, so it has been executed correctly.
|
|
self.msg_set_status(
|
|
_("Checking your will"),
|
|
None,
|
|
_("An inheritance of this wallet is already executed (on blockchain)"),
|
|
self.COLOR_OK,
|
|
)
|
|
elif executed_status == "MEMPOOL":
|
|
# Orange (warning colour): the transaction is in the mempool,
|
|
# waiting to be confirmed. Not an error, just "in progress".
|
|
self.msg_set_status(
|
|
_("Checking your will"),
|
|
None,
|
|
_("Inheritance in mempool (waiting confirmation)"),
|
|
self.COLOR_WARNING,
|
|
)
|
|
# All of these situations used to collapse into the SAME sentence
|
|
# ("Found CHANGES to the DATE or the HEIRS, a NEW WILL must be
|
|
# prepared"), shown for five genuinely different causes - and
|
|
# plainly WRONG for the most common one, receiving funds, where
|
|
# neither the date nor the heirs changed. _check_failure_message
|
|
# names the real cause using the detail each exception already
|
|
# carries (heir name, will-executor URL, old/new fee rate).
|
|
message = self._check_failure_message(e)
|
|
_logger.debug(f"message: {message}")
|
|
self.msg_set_checking(message)
|
|
|
|
if have_to_build:
|
|
self.msg_set_building()
|
|
try:
|
|
txs = self.bal_window.build_will()
|
|
if not txs:
|
|
# The message now names the ACTUAL reason the build gave
|
|
# up (recorded by Heirs.buildTransactions) instead of
|
|
# listing three fixed guesses that were frequently all
|
|
# wrong. msg_alert keeps the warning sign coloured and the
|
|
# text in the default colour so it stays readable.
|
|
self.msg_set_building(
|
|
self.msg_alert(self._build_failure_message())
|
|
)
|
|
return False, None
|
|
|
|
self.bal_window.check_will()
|
|
self._build_success_report()
|
|
except WillExecutorNotPresent:
|
|
self.msg_set_status(
|
|
_("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
|
|
# above (build_will), and the freshly rebuilt transactions can
|
|
# themselves already be expired.
|
|
#
|
|
# We must NOT trigger the wizard's automatic invalidation loop
|
|
# here (return None, invalidate_tx). That loop re-runs
|
|
# task_phase1 right after broadcasting the invalidation, but the
|
|
# invalidation tx is not yet visible in the mempool, so the will
|
|
# is still detected as expired and the user is asked to
|
|
# invalidate again and again (infinite loop). It also never sets
|
|
# the "BAL Invalidate transaction" history label.
|
|
#
|
|
# Instead we reproduce EXACTLY what the "Tools -> invalidate"
|
|
# menu does (BalWalletWindow.invalidate_will): open Electrum's
|
|
# classic transaction dialog so the user can sign and broadcast
|
|
# the invalidation manually, set the proper history label, and
|
|
# stop. This is robust regardless of mempool confirmation state.
|
|
#
|
|
# The actual call to invalidate_will() (which opens GUI windows)
|
|
# must run in the GUI thread, so we only RETURN a signal here
|
|
# ("invalidate_classic"); on_success_phase1 performs the call.
|
|
# We still show the expired notice as a WARNING (orange).
|
|
self.msg_set_building(self.msg_warning(e))
|
|
return "invalidate_classic", None
|
|
|
|
except NotCompleteWillException as e:
|
|
# IMPORTANT (bugs E/F/K): build_will() above has just REBUILT the
|
|
# whole will because the heirs (or the date) changed. The
|
|
# post-build re-validation (check_will) then legitimately reports
|
|
# that the new will differs from the previous one - e.g. it
|
|
# raises HeirNotFoundException for a heir that is not yet covered
|
|
# by an already-signed transaction. That is NOT an error: it is
|
|
# exactly the signal that the freshly built transactions still
|
|
# need to be SIGNED (and then broadcast).
|
|
#
|
|
# Previously this fell through to the generic "except Exception"
|
|
# below, which (1) printed the heir name in RED and (2) returned
|
|
# have_to_sign=False, so the rebuilt will was never signed
|
|
# ("Nothing to do"). We now treat it as a successful rebuild:
|
|
# show the green "Ok" + the heir list and FALL THROUGH to the
|
|
# have_to_sign detection, so the new (status "New", not COMPLETE)
|
|
# transactions are correctly detected and signed/broadcast.
|
|
_logger.debug(f"will rebuilt, needs signing: {e}")
|
|
self._build_success_report()
|
|
|
|
except HeirAmountIsDustException:
|
|
# ALL-DUST CASE (owner request): every heir's share is below the
|
|
# Bitcoin dust limit, so the inheritance would pay nobody. We do
|
|
# NOT build, sign or check anything - we show a clear message in
|
|
# red and stop, so no "empty" will ends up in the list. This is
|
|
# raised by Heirs.prepare_lists only when ALL real heirs are
|
|
# dust; a mix of dust + valid heirs never reaches here.
|
|
self.msg_set_building(
|
|
self.msg_error(
|
|
_(
|
|
"All heirs' shares are below the dust limit: "
|
|
"the inheritance cannot be created. "
|
|
"Increase the amounts or reduce the number of heirs."
|
|
)
|
|
)
|
|
)
|
|
return False, None
|
|
|
|
except BalanceTooLowException as e:
|
|
# The core DOES detect this precisely and carries the numbers,
|
|
# but the exception was never caught here: it fell through to
|
|
# the generic handler below, which printed the raw technical
|
|
# string in red and re-raised. Show the real figures instead.
|
|
self.msg_set_building(
|
|
self.msg_alert(
|
|
_(
|
|
"Wallet balance is too low: {} satoshi available, "
|
|
"but the miner and will-executor fees need {} "
|
|
"satoshi (the minimum usable amount is {} "
|
|
"satoshi). Add funds, or select fewer "
|
|
"will-executors."
|
|
).format(
|
|
int(e.balance), int(e.fees), int(e.dust_threshold)
|
|
)
|
|
+ "\n\n"
|
|
+ _("Skipped")
|
|
)
|
|
)
|
|
return False, None
|
|
|
|
except Exception as e:
|
|
self.msg_set_building(self.msg_error(e))
|
|
raise e
|
|
return False, None
|
|
|
|
# DUST report (one line PER HEIR, not per will-executor).
|
|
#
|
|
# WHY: the wallet balance can be so small that each heir's share falls
|
|
# below Bitcoin's dust limit, so the inheritance is not feasible. The
|
|
# "is DUST" condition depends ONLY on the heir's amount, NOT on which
|
|
# will-executor transaction we are looking at. The previous code looped
|
|
# over every valid will (one per will-executor) AND every heir, so with
|
|
# e.g. 20 will-executors and 10 heirs it printed 20x10 = 200 identical
|
|
# "is DUST ... Excluded from will <wid>" rows (owner report, allegato13).
|
|
#
|
|
# We now collect the dust heirs in a de-duplicated dict (heir id ->
|
|
# dust amount) across all valid wills and print ONE row per heir,
|
|
# without the will-executor reference. So N heirs => at most N rows,
|
|
# regardless of how many will-executors exist.
|
|
dust_heirs = {}
|
|
for wid in Will.only_valid(self.bal_window.willitems):
|
|
heirs = self.bal_window.willitems[wid].heirs
|
|
for hid, heir in heirs.items():
|
|
if "DUST" in str(heir[HEIR_REAL_AMOUNT]):
|
|
# Keep the first dust amount seen for this heir; it is the
|
|
# same share in every will-executor copy of the will.
|
|
dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT])
|
|
for hid, dust_amount in dust_heirs.items():
|
|
self.msg_set_status(
|
|
f"{_('Heir')} {hid}",
|
|
None,
|
|
f"{dust_amount} is DUST - excluded (amount below dust limit)",
|
|
self.COLOR_WARNING,
|
|
)
|
|
|
|
have_to_sign = False
|
|
for wid in Will.only_valid(self.bal_window.willitems):
|
|
if not self.bal_window.willitems[wid].get_status("COMPLETE"):
|
|
have_to_sign = True
|
|
break
|
|
return have_to_sign, txs
|
|
|
|
def _build_success_report(self):
|
|
"""Mark the build step as done and list every heir in green.
|
|
|
|
Called after a successful (re)build of the will. It:
|
|
1. labels each inheritance transaction in Electrum's history;
|
|
2. shows the green "Ok" result on the "Building your will" row;
|
|
3. lists EVERY heir of the freshly built will, one per line, in green
|
|
(the "Ok" colour), so the user can confirm at a glance who will
|
|
inherit.
|
|
|
|
Previously a heir name could appear in RED (it was the text of a rebuild
|
|
exception) and the heir list was only shown on the "all clean" path,
|
|
which is why after changing/deleting an heir the user saw a single red
|
|
heir name and no green list. This helper is now used both on the clean
|
|
path and on the "will was rebuilt and needs signing" path, so the green
|
|
heir list is always shown.
|
|
|
|
Internal will-executor pseudo-heirs (reserved ``w!ll3x3c"`` prefix) are
|
|
skipped, exactly as the core coherence check does. A set keeps the list
|
|
unique even when an heir appears in several transactions.
|
|
"""
|
|
for wid in Will.only_valid(self.bal_window.willitems):
|
|
# Label shown in Electrum's History tab for inheritance txs.
|
|
self.bal_window.wallet.set_label(wid, "BAL Inheritance transaction")
|
|
# Keep the plugin's stored delivery date in sync with the (possibly
|
|
# auto-anticipated) transactions, otherwise the next Check would wrongly
|
|
# ask to invalidate. See _sync_locktime_to_built_txs for the full why.
|
|
self._sync_locktime_to_built_txs()
|
|
self.msg_set_building(self.msg_ok())
|
|
# List EACH heir on its OWN line, green + bold (owner request: revert the
|
|
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
|
|
# now that the report area scrolls (allegato1) there is no need to cram
|
|
# them onto a single line. We de-duplicate (an heir can appear in several
|
|
# will-executor transactions) and skip the internal will-executor
|
|
# pseudo-heirs (reserved ``w!ll3x3c"`` prefix), exactly as before.
|
|
shown_heirs = set()
|
|
for wid in Will.only_valid(self.bal_window.willitems):
|
|
for hname in self.bal_window.willitems[wid].heirs:
|
|
if str(hname)[:9] == 'w!ll3x3c"':
|
|
continue
|
|
if hname in shown_heirs:
|
|
continue
|
|
shown_heirs.add(hname)
|
|
self.msg_set_status(_("Heir"), None, str(hname), self.COLOR_OK)
|
|
|
|
def _sync_locktime_to_built_txs(self):
|
|
"""Align the plugin's stored delivery date with the built transactions.
|
|
|
|
WHY this is needed (bug reported by the owner):
|
|
When the will is rebuilt while it still spends the same coins as a
|
|
previous one (e.g. after deleting an heir WITHOUT changing the date),
|
|
the core engine AUTOMATICALLY anticipates the transaction locktime by
|
|
one day (see Will.check_anticipate / Util.anticipate_locktime). This is
|
|
correct and required so the new transaction can be mined BEFORE the old
|
|
one it replaces.
|
|
|
|
However the plugin's own stored delivery date
|
|
(WILL_SETTINGS["locktime"]) was NOT updated and stayed at the original
|
|
date. On the next Check the plugin compared the stored date (original)
|
|
with the transaction locktime (original minus one day) and, since
|
|
stored > tx, mistook the automatic anticipation for a user POSTPONE,
|
|
wrongly asking to invalidate the will.
|
|
|
|
Fix: after a (re)build, set the stored delivery date to the MINIMUM
|
|
locktime among the valid built transactions. We only ever move the date
|
|
EARLIER (anticipation): if the minimum is not strictly below the current
|
|
stored date we leave it untouched, so a genuine user-chosen postpone is
|
|
never silently overwritten. The owner confirmed that, when several
|
|
transactions carry different locktimes, taking the minimum is the
|
|
desired behaviour, and that the date shown in the panel/wizard must
|
|
reflect this anticipated date (so the calendar .ics also uses it).
|
|
|
|
RELATIVE dates are additionally normalised here: a relative value
|
|
("30d"/"1y") is re-parsed against "now" on every check, so it drifts
|
|
away from the fixed transaction locktime and the postpone check would
|
|
wrongly ask to invalidate the will every day. The stored locktime is
|
|
therefore frozen to the built transactions' absolute locktime, and a
|
|
relative threshold is frozen to its "N days before the delivery"
|
|
absolute value.
|
|
|
|
We route the update through BalWindow.update_setting_widgets, which is
|
|
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
|
persists it to Electrum's database and (3) refreshes the date widgets in
|
|
every panel/wizard, so the visible date and the .ics export stay
|
|
consistent.
|
|
"""
|
|
# Minimum locktime across the valid (just built) inheritance txs.
|
|
# Will.get_min_locktime returns None when there is no valid tx.
|
|
min_locktime = Will.get_min_locktime(self.bal_window.willitems, None)
|
|
if min_locktime is None:
|
|
return
|
|
min_locktime = int(min_locktime)
|
|
stored_locktime = self.bal_window.will_settings["locktime"]
|
|
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
|
|
# against "now" on every check, so it drifts one day per day away from
|
|
# the fixed tx locktime and the postpone check would ALWAYS see a
|
|
# postpone -> the plugin asks to invalidate the will every day. It must
|
|
# therefore be normalised here to the frozen absolute locktime of the
|
|
# built transactions, even when it happens to parse to the same moment
|
|
# today. (Only an absolute stored value is comparable, see below.)
|
|
is_relative_locktime = (
|
|
isinstance(stored_locktime, str)
|
|
and stored_locktime[-1:].lower() in ("d", "y")
|
|
)
|
|
# Current stored delivery date, as a comparable UNIX timestamp.
|
|
try:
|
|
current = int(Util.parse_locktime_string(stored_locktime))
|
|
except Exception:
|
|
# If the stored value cannot be parsed, fall back to syncing.
|
|
current = None
|
|
# A genuine user-chosen POSTPONE (a later absolute date) is never
|
|
# overwritten; anything else is synced to the built transactions.
|
|
was_anticipation = current is not None and min_locktime < current
|
|
if not is_relative_locktime and current is not None and not was_anticipation:
|
|
pass
|
|
else:
|
|
_logger.debug(
|
|
f"sync delivery date to built tx locktime: "
|
|
f"{current} -> {min_locktime}"
|
|
)
|
|
# Remember that we anticipated the date, so the later sign prompt can
|
|
# explain WHY signing is needed (see on_success_phase1). A pure
|
|
# relative->absolute normalisation is NOT an anticipation.
|
|
if was_anticipation:
|
|
self._date_was_anticipated = True
|
|
# update_setting_widgets stores the value, persists it and refreshes
|
|
# the date widgets in all panels/wizard (the .ics calendar too).
|
|
self.bal_window.update_setting_widgets(
|
|
min_locktime, "locktime", update_all=True
|
|
)
|
|
# Same moving-target problem for a relative "Check Alive" threshold:
|
|
# it means "N days BEFORE the delivery" (the settings widget resolves it
|
|
# as real_threshold = locktime - N days), so it is normalised to that
|
|
# absolute date, referenced against the now-absolute stored locktime.
|
|
threshold_raw = self.bal_window.will_settings.get("threshold")
|
|
if (
|
|
isinstance(threshold_raw, str)
|
|
and threshold_raw[-1:].lower() in ("d", "y")
|
|
):
|
|
try:
|
|
locktime_ts = int(
|
|
Util.parse_locktime_string(
|
|
self.bal_window.will_settings["locktime"]
|
|
)
|
|
)
|
|
real_threshold = int(
|
|
BalTimestamp(threshold_raw)
|
|
.to_date(locktime_ts, reverse=True)
|
|
.timestamp()
|
|
)
|
|
except Exception as e:
|
|
_logger.error(f"sync threshold to absolute failed: {e}")
|
|
else:
|
|
_logger.debug(
|
|
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
|
|
)
|
|
self.bal_window.update_setting_widgets(
|
|
real_threshold, "threshold", update_all=True
|
|
)
|
|
|
|
def on_accept(self):
|
|
try:
|
|
self.bal_window.update_all()
|
|
except Exception as e:
|
|
import traceback
|
|
_logger.error(f"NoneType_catch on_accept: {e}\n{traceback.format_exc()}")
|
|
pass
|
|
|
|
def on_accept_phase2(self):
|
|
try:
|
|
self.bal_window.update_all()
|
|
except Exception as e:
|
|
import traceback
|
|
_logger.error(f"NoneType_catch on_accept_phase2: {e}\n{traceback.format_exc()}")
|
|
pass
|
|
|
|
def on_error_push(self):
|
|
pass
|
|
|
|
def wait(self, secs):
|
|
wait_row = None
|
|
for i in range(secs, 0, -1):
|
|
if self._stopping:
|
|
return
|
|
wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row)
|
|
time.sleep(1)
|
|
self.msg_del_row(wait_row)
|
|
|
|
def loop_broadcast_invalidating(self, tx):
|
|
if self._stopping:
|
|
return
|
|
self.msg_set_invalidating("Broadcasting")
|
|
try:
|
|
tx.add_info_from_wallet(self.bal_window.wallet)
|
|
self.network.run_from_another_thread(tx.add_info_from_network(self.network))
|
|
|
|
# IMPORTANT (task #21 fix): get the txid from the transaction
|
|
# object, NOT from broadcast_transaction()'s return value.
|
|
# Network.broadcast_transaction is declared "-> None" and ALWAYS
|
|
# returns None (it only raises on failure). The previous code stored
|
|
# that None into `txid` and put set_label() in the `else: # txid`
|
|
# branch, which was therefore NEVER reached - that is why the
|
|
# "BAL Invalidate transaction" history label kept missing on this
|
|
# automatic ("postpone") path. The transaction is already signed and
|
|
# complete here, so tx.txid() is the correct, stable id - exactly
|
|
# what the working Tools -> Invalidate path uses
|
|
# (BalWalletWindow.invalidate_will -> result.txid()).
|
|
txid = tx.txid()
|
|
|
|
# Set the history label BEFORE broadcasting. set_label only writes to
|
|
# the local wallet metadata (no network needed), so doing it first
|
|
# guarantees the label exists the moment the transaction shows up in
|
|
# the History tab, regardless of how fast the broadcast/notification
|
|
# arrives.
|
|
if txid:
|
|
self.bal_window.wallet.set_label(txid, "BAL Invalidate transaction")
|
|
else:
|
|
_logger.debug(f"invalidate tx has no txid: {tx}")
|
|
|
|
self.network.run_from_another_thread(
|
|
self.network.broadcast_transaction(tx, timeout=120), timeout=120
|
|
)
|
|
self.msg_set_invalidating(self.msg_ok())
|
|
|
|
except TxBroadcastError as e:
|
|
_logger.error(f"fail to broadcast transaction:{e}")
|
|
msg = e.get_message_for_gui()
|
|
self.msg_set_invalidating(self.msg_error(msg))
|
|
except BestEffortRequestFailed as e:
|
|
self.msg_set_invalidating(self.msg_error(e))
|
|
|
|
def loop_push(self):
|
|
# Broadcast is "one-shot" (Group B / B2 follow-up): each selected
|
|
# will-executor is contacted ONCE. Transactions that are broadcast
|
|
# successfully become PUSHED; transactions whose server fails or times
|
|
# out are left as PUSH_FAIL and simply skipped - they are NOT retried
|
|
# automatically. A dead will-executor could otherwise never answer and
|
|
# make the plugin retry forever. The user can broadcast a failed
|
|
# transaction manually later with the "Broadcast" button. Note that
|
|
# get_willexecutor_transactions already excludes PUSHED transactions, so
|
|
# the successful ones are never re-sent on a subsequent run.
|
|
if self._stopping:
|
|
return
|
|
self.msg_set_pushing(_("Broadcasting"))
|
|
try:
|
|
|
|
willexecutors = Willexecutors.get_willexecutor_transactions(
|
|
self.bal_window.willitems
|
|
)
|
|
|
|
# Only push to the will-executors the user actually selected. We
|
|
# filter the mapping up-front so push_transactions_parallel only
|
|
# talks to the relevant servers.
|
|
selected = {
|
|
url: we
|
|
for url, we in willexecutors.items()
|
|
if Willexecutors.is_selected(
|
|
self.bal_window.willexecutors.get(url),
|
|
) 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
|
|
# verified afterwards (network I/O); collect them here and process
|
|
# them sequentially after the parallel push, keeping the original
|
|
# check logic untouched.
|
|
already_present = []
|
|
total = len(selected)
|
|
done = {"count": 0}
|
|
|
|
deadline = Willexecutors.PUSH_GLOBAL_DEADLINE
|
|
|
|
def _status_line():
|
|
# e.g. "Broadcasting your will to executors: 2/3 (5s / 30s)".
|
|
# The "/ 30s" makes the maximum wait explicit, so the user knows
|
|
# the wizard will proceed by then (the global deadline) instead
|
|
# of wondering how long the counter will keep climbing.
|
|
return "{} {}/{} ({}s / {}s)".format(
|
|
_("Broadcasting"), done["count"], total,
|
|
min(int(time.time() - push_start), deadline), deadline,
|
|
)
|
|
|
|
def on_each(url, willexecutor, ok, exc):
|
|
# Runs from a worker thread. Do only thread-safe book-keeping
|
|
# plus a signal-based UI update (msg_edit_row emits a pyqtSignal,
|
|
# which is marshalled to the GUI thread).
|
|
if isinstance(exc, Willexecutors.AlreadyPresentException):
|
|
already_present.append(url)
|
|
elif ok:
|
|
for wid in willexecutor["txsids"]:
|
|
self.bal_window.willitems[wid].set_status("PUSHED", True)
|
|
else:
|
|
# One-shot: mark the failed transactions and move on. They
|
|
# are left as PUSH_FAIL (no automatic retry).
|
|
for wid in willexecutor["txsids"]:
|
|
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
|
|
done["count"] += 1
|
|
# Show the per-server result (Ok/Ko) in bold + color so the
|
|
# outcome stands out, keeping the server URL in normal weight.
|
|
result = self.msg_ok("Ok") if ok else self.msg_error("Ko")
|
|
self.msg_edit_row("{} : {}".format(url, result))
|
|
self.msg_set_pushing(_status_line())
|
|
|
|
def on_timeout(url, willexecutor):
|
|
# The global deadline elapsed before this server answered. Mark
|
|
# its txs as failed and move on (one-shot: no automatic retry).
|
|
# The user can broadcast them manually later if desired.
|
|
for wid in willexecutor.get("txsids", []):
|
|
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
|
|
self.msg_edit_row(
|
|
"{} : {}".format(url, self.msg_error(_("Timeout - no answer")))
|
|
)
|
|
|
|
if self._stopping:
|
|
return
|
|
# Push to all selected will-executors in parallel: a slow/dead
|
|
# server no longer blocks the others, so the wizard's "Broadcasting"
|
|
# step is no longer sequential. Each server keeps a short retry
|
|
# behaviour, and a global deadline guarantees the wizard always
|
|
# proceeds even if a server never answers.
|
|
push_start = time.time()
|
|
self.msg_set_pushing(_status_line())
|
|
|
|
# Refresh the elapsed-seconds counter while the (blocking) parallel
|
|
# push runs, so the user sees time advancing instead of a frozen
|
|
# "Trasmissione". The tick is driven from THIS (Task) thread by
|
|
# push_transactions_parallel, the same thread that drives on_each, so
|
|
# the pyqtSignal repaint is reliable (a separate heartbeat thread's
|
|
# signal emissions were not being marshalled and never repainted).
|
|
def on_tick():
|
|
if self._stopping:
|
|
return
|
|
self.msg_set_pushing(_status_line())
|
|
|
|
Willexecutors.push_transactions_parallel(
|
|
selected, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick
|
|
)
|
|
|
|
# Final summary line with the total elapsed time.
|
|
self.msg_set_pushing(
|
|
"{}/{} ({}s)".format(done["count"], total,
|
|
int(time.time() - push_start))
|
|
)
|
|
|
|
# Verify the "already present" servers (sequential, original logic).
|
|
self.bal_plugin = self.bal_window.bal_plugin
|
|
for url in already_present:
|
|
for wid in willexecutors[url]["txsids"]:
|
|
if self._stopping:
|
|
return
|
|
row = self.msg_edit_row(
|
|
"checking {} - {} : <b>{}</b>".format(
|
|
self.bal_window.willitems[wid].we["url"], wid, "Waiting"
|
|
)
|
|
)
|
|
w = self.bal_window.willitems[wid]
|
|
w.set_check_willexecutor(
|
|
Willexecutors.check_transaction(wid, w.we["url"])
|
|
)
|
|
# Show the CHECKED result in bold + color (green True /
|
|
# red False) so the outcome stands out, keeping the server
|
|
# URL and tx id in normal weight.
|
|
checked = self.bal_window.willitems[wid].get_status("CHECKED")
|
|
result = self.msg_ok(checked) if checked else self.msg_error(checked)
|
|
row = self.msg_edit_row(
|
|
"checked {} - {} : {}".format(
|
|
self.bal_window.willitems[wid].we["url"],
|
|
wid,
|
|
result,
|
|
),
|
|
row,
|
|
)
|
|
|
|
# One-shot broadcast: we deliberately do NOT raise/retry when some
|
|
# will-executors failed. Their transactions stay PUSH_FAIL and are
|
|
# left for the user to broadcast manually. This prevents an endless
|
|
# retry loop against a will-executor that may never answer.
|
|
|
|
except Exception as e:
|
|
# Only genuine, unexpected errors reach here now (not the old
|
|
# "retry" signal). Report the error; do not loop.
|
|
self.msg_set_pushing(self.msg_error(e))
|
|
self.wait(10)
|
|
if not self._stopping:
|
|
pass
|
|
|
|
def invalidate_task(self, password, bal_window, tx):
|
|
if self._stopping:
|
|
return
|
|
_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)
|
|
try:
|
|
if tx:
|
|
if tx.is_complete():
|
|
self.loop_broadcast_invalidating(tx)
|
|
# Wait 10 seconds (was 5) AFTER broadcasting the
|
|
# invalidation so Electrum's wallet/network has time to see
|
|
# the new transaction before we re-run phase 1. Without this
|
|
# pause the immediate re-check still detected the old
|
|
# (not-yet-invalidated) will and re-prompted to invalidate,
|
|
# producing the reported loop. This runs in the worker
|
|
# thread, so the GUI is not frozen by the sleep.
|
|
self.wait(10)
|
|
# Remember that we just broadcast an invalidation. If the
|
|
# next phase-1 re-check STILL reports a postpone (because
|
|
# Electrum has not registered the tx yet), we stop with a
|
|
# clear message instead of looping (see on_success_phase1).
|
|
self._invalidation_broadcast = True
|
|
else:
|
|
raise Exception("tx not complete")
|
|
else:
|
|
raise Exception("not tx")
|
|
except Exception as e:
|
|
(f"exception:{e}")
|
|
self.msg_set_invalidating(f"Error: {e}")
|
|
raise Exception("Impossible to sign") from e
|
|
|
|
def on_success_invalidate(self, success):
|
|
self.thread.add(
|
|
self.task_phase1,
|
|
on_success=self.on_success_phase1,
|
|
on_done=self.on_accept,
|
|
on_error=self.on_error_phase1,
|
|
)
|
|
|
|
def on_success_phase1(self, result):
|
|
try:
|
|
self._on_success_phase1_body(result)
|
|
except Exception as e:
|
|
import traceback
|
|
_logger.error(f"NoneType_catch on_success_phase1: {e}\n{traceback.format_exc()}")
|
|
|
|
def _on_success_phase1_body(self, result):
|
|
if self._stopping:
|
|
return
|
|
self.have_to_sign, tx = list(result)
|
|
# if not tx:
|
|
# self.msg_edit_row(self.msg_error("Error, no tx was built"))
|
|
# return
|
|
|
|
# Special signal raised by task_phase1 when the freshly rebuilt will is
|
|
# already expired (e.g. an heir was added to an expired will). Instead of
|
|
# running the wizard's automatic invalidation loop (which would re-check
|
|
# before the invalidation tx reaches the mempool and loop forever), we
|
|
# behave exactly like the "Tools -> invalidate" menu: open Electrum's
|
|
# classic transaction dialog so the user signs and broadcasts the
|
|
# invalidation manually, with the "BAL Invalidate transaction" label.
|
|
# This runs in the GUI thread (on_success callback), so opening windows
|
|
# is safe. We then stop and close the wizard.
|
|
if self.have_to_sign == "invalidate_classic":
|
|
self.thread.stop()
|
|
# UNIFY INVALIDATE PROCEDURE (+ task #03):
|
|
#
|
|
# When an heir is added to an already-expired will, the rebuilt will
|
|
# is itself expired and the old will must be invalidated on-chain
|
|
# before the new one can be used.
|
|
#
|
|
# We make the CHECK button and the WIZARD behave IDENTICALLY:
|
|
# 1. show a WARNING popup (no "Tools -> Invalidate" wording);
|
|
# 2. AUTOMATICALLY open Electrum's classic transaction dialog via
|
|
# BalWalletWindow.invalidate_will() (the same code used by the
|
|
# Tools -> Invalidate menu). That path already sets the
|
|
# "BAL Invalidate transaction" history label - which fixes
|
|
# task #03 (the label was previously missing on the automatic
|
|
# path).
|
|
#
|
|
# Window-stacking note (history): opening the transaction window
|
|
# straight from within the closing wizard used to leave it BEHIND
|
|
# the main window on some window managers. The robust fix is to
|
|
# close the wizard FIRST and defer the call with QTimer.singleShot
|
|
# so it runs on the next event-loop iteration, when the wizard is
|
|
# already gone and the transaction window becomes the front-most,
|
|
# focused window.
|
|
self.close()
|
|
self.bal_window.show_message(
|
|
_(
|
|
"Your will has expired and must be invalidated before it "
|
|
"can be rebuilt.\n"
|
|
"A transaction window will now open:\n"
|
|
"please SIGN and then BROADCAST it to invalidate your old "
|
|
"will.\n"
|
|
"After the invalidation is confirmed, press the Check "
|
|
"button to finish the will."
|
|
)
|
|
)
|
|
# Deferred so the wizard is fully closed before the classic
|
|
# invalidate window opens (keeps it in front, fixes the old
|
|
# "window behind" problem).
|
|
QTimer.singleShot(0, self.bal_window.invalidate_will)
|
|
return
|
|
|
|
if self.have_to_sign == "no_willexecutor":
|
|
self._add_no_willexecutor_buttons()
|
|
return
|
|
|
|
if self.have_to_sign == "no_heirs":
|
|
self._add_no_heirs_buttons()
|
|
return
|
|
|
|
_logger.debug("have to sign {}".format(self.have_to_sign))
|
|
password = None
|
|
if self.have_to_sign is None:
|
|
_logger.debug("have to invalidate")
|
|
|
|
# LOOP GUARD (task #21): if we already broadcast an invalidation on
|
|
# the previous pass and phase 1 STILL reports a postpone, Electrum
|
|
# has simply not seen the invalidation transaction yet. Re-prompting
|
|
# to invalidate here is exactly what produced the reported endless
|
|
# loop ("Invalidate your old will" reappearing right after signing).
|
|
# Instead of re-prompting, STOP cleanly with a clear message and
|
|
# tell the user to retry the Check once the invalidation confirms.
|
|
if self._invalidation_broadcast:
|
|
self.thread.stop()
|
|
self.msg_set_invalidating(self.msg_ok())
|
|
self.bal_window.show_message(
|
|
_(
|
|
"Your old will has been invalidated and the "
|
|
"transaction was broadcast.\n"
|
|
"Electrum may need a little time to register it.\n"
|
|
"Please wait until the invalidation transaction is "
|
|
"confirmed, then press the Check button again to "
|
|
"finish updating your will."
|
|
)
|
|
)
|
|
self._add_close_button()
|
|
return
|
|
|
|
self.msg_set_invalidating()
|
|
# need to sign invalidate and restart phase 1
|
|
|
|
password = self.bal_window.get_wallet_password(
|
|
_("Invalidate your old will"), parent=self
|
|
)
|
|
if password is False:
|
|
# The user cancelled the password prompt for the invalidation.
|
|
# We must NOT call self.wait(3) here: on_success_phase1 runs in
|
|
# the GUI thread, so wait()'s time.sleep() would freeze the UI
|
|
# for several seconds. While frozen the dialog cannot repaint
|
|
# the area it just resized, leaving a black, undrawn rectangle
|
|
# at the bottom of the "Building Will" window (the reported bug).
|
|
#
|
|
# Instead, mark the invalidation as "Aborted" and offer a
|
|
# non-blocking "Close" button (the same helper used by the
|
|
# normal finish path), so the user can read the outcome and
|
|
# dismiss the dialog when they want, without blocking the GUI.
|
|
self.msg_set_invalidating(_("Aborted"))
|
|
self._add_close_button()
|
|
return
|
|
self.thread.add(
|
|
partial(self.invalidate_task, password, self.bal_window, tx),
|
|
on_success=self.on_success_invalidate,
|
|
on_done=self.on_accept,
|
|
on_error=self.on_error,
|
|
)
|
|
|
|
return
|
|
|
|
elif self.have_to_sign:
|
|
auto_sign = (
|
|
self.bal_window.bal_plugin.is_basic_mode()
|
|
or self.bal_window.bal_plugin.AUTO_SIGN.get()
|
|
)
|
|
if not auto_sign:
|
|
self.msg_set_signing(
|
|
_("Auto-sign disabled — sign manually")
|
|
)
|
|
self.have_to_sign = False
|
|
else:
|
|
# If the will was rebuilt with an automatically anticipated
|
|
# delivery date, explain WHY we are now asking to sign:
|
|
# otherwise the sign prompt appears with no reason (owner
|
|
# feedback). The note is shown on the "Building your will"
|
|
# row (orange warning) before the modal password prompt
|
|
# opens, so the user can read it.
|
|
if self._date_was_anticipated:
|
|
self.msg_set_building(
|
|
"<b>{}</b>".format(
|
|
_(
|
|
"The delivery date was automatically moved "
|
|
"one day earlier so the updated will can "
|
|
"correctly replace the previous one.\n"
|
|
"Please sign (and broadcast) to confirm "
|
|
"the change."
|
|
)
|
|
)
|
|
)
|
|
password = self.bal_window.get_wallet_password(
|
|
_("Sign your will"), parent=self
|
|
)
|
|
if password is False:
|
|
self.msg_set_signing(_("Aborted"))
|
|
else:
|
|
self.msg_set_signing(_("Nothing to do"))
|
|
self.thread.add(
|
|
partial(self.task_phase2, password),
|
|
on_success=self.on_success_phase2,
|
|
on_done=self.on_accept_phase2,
|
|
on_error=self.on_error_phase2,
|
|
)
|
|
return
|
|
|
|
def on_success_phase2(self, arg=False):
|
|
self.thread.stop()
|
|
self.bal_window.save_willitems()
|
|
# After the whole check/sign/broadcast cycle, keep the wallet's local
|
|
# history in sync with the current will state (save the still "New"
|
|
# incomplete txs, remove the now-complete ones).
|
|
try:
|
|
self.bal_window._save_will_to_history()
|
|
except Exception as e:
|
|
_logger.error(f"save_will_to_history after phase2 failed: {e}")
|
|
self.msg_edit_row(_("Finished"))
|
|
# Instead of auto-closing after a countdown, let the user decide when to
|
|
# dismiss the dialog: they can read the full "Building Will" report at
|
|
# their own pace and then press "Close". This runs in the GUI thread
|
|
# (on_success callback) so building the button here is safe.
|
|
self._add_close_button()
|
|
|
|
def _add_close_button(self):
|
|
"""Add a right-aligned "Close" button and a "Calendar" dropdown button.
|
|
|
|
Replaces the old automatic countdown (self.wait(5) + self.close()).
|
|
Guarded so it is only built once even if called again.
|
|
"""
|
|
if getattr(self, "_close_button", None) is not None:
|
|
return
|
|
|
|
calendar_button = BalCalendarButton(self.bal_window, self._ics_provider)
|
|
calendar_button.setText(_("Calendar"))
|
|
|
|
self._close_button = QPushButton(_("Close"))
|
|
self._close_button.clicked.connect(self._on_close_clicked)
|
|
button_row = QHBoxLayout()
|
|
button_row.addStretch(1)
|
|
button_row.addWidget(calendar_button)
|
|
button_row.addWidget(self._close_button)
|
|
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,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# No-heirs error handling (mirrors the no-willexecutor pattern above)
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _add_no_heirs_buttons(self):
|
|
"""Add "Heirs", "Wizard" and "Close" buttons when no heirs are
|
|
configured."""
|
|
if getattr(self, "_no_heirs_buttons_added", False):
|
|
return
|
|
self._no_heirs_buttons_added = True
|
|
btn_row = QHBoxLayout()
|
|
btn_row.addStretch(1)
|
|
|
|
heirs_btn = QPushButton(_("Heirs"))
|
|
heirs_btn.clicked.connect(self._open_heir_dialog)
|
|
btn_row.addWidget(heirs_btn)
|
|
|
|
wizard_btn = QPushButton(_("\U0001f52e Wizard"))
|
|
wizard_btn.clicked.connect(self._open_heirs_wizard)
|
|
btn_row.addWidget(wizard_btn)
|
|
|
|
close_btn = QPushButton(_("Close"))
|
|
close_btn.clicked.connect(self.close)
|
|
btn_row.addWidget(close_btn)
|
|
|
|
self._no_heirs_layout = btn_row
|
|
self.vbox.addLayout(btn_row)
|
|
self.resize(self.vbox.sizeHint())
|
|
|
|
def _open_heir_dialog(self):
|
|
"""Open the heirs management dialog, then retry the build."""
|
|
d = HeirsDialog(self.bal_window, parent=self)
|
|
d.exec()
|
|
self._retry_build_after_heirs()
|
|
|
|
def _open_heirs_wizard(self):
|
|
"""Close the build-will dialog and open the wizard at the heirs
|
|
step so the user can add heirs."""
|
|
self.close()
|
|
wizard = BalWizardDialog(self.bal_window)
|
|
wizard.exec()
|
|
|
|
def _retry_build_after_heirs(self):
|
|
"""Remove the no-heirs buttons, reset the message panel,
|
|
and re-run ``task_phase1`` on the same thread."""
|
|
self._no_heirs_buttons_added = False
|
|
if self._no_heirs_layout:
|
|
while self._no_heirs_layout.count():
|
|
item = self._no_heirs_layout.takeAt(0)
|
|
w = item.widget()
|
|
if w:
|
|
w.setParent(None)
|
|
w.deleteLater()
|
|
self.vbox.removeItem(self._no_heirs_layout)
|
|
self._no_heirs_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
|
|
|
|
try:
|
|
locktime_ts = Util.parse_locktime_string(
|
|
self.bal_window.will_settings["locktime"]
|
|
)
|
|
locktime = datetime.fromtimestamp(locktime_ts)
|
|
|
|
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
|
if basic_mode:
|
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
|
threshold = None
|
|
num_reminders = 3
|
|
else:
|
|
threshold_ts = BalTimestamp(
|
|
self.bal_window.will_settings["threshold"]
|
|
).to_timestamp()
|
|
threshold = datetime.fromtimestamp(threshold_ts)
|
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
|
try:
|
|
num_reminders = int(
|
|
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
|
)
|
|
except Exception:
|
|
num_reminders = 3
|
|
|
|
heirs_details = "\r\n".join(
|
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
|
f"{self.bal_window.heirs[heir][1]}"
|
|
for heir in self.bal_window.heirs
|
|
)
|
|
|
|
# ToDo #2: when no reminder falls in the future (the delivery date is
|
|
# too close or already passed), build_ics_reminders returns None so
|
|
# the caller shows a clear warning instead of producing an empty,
|
|
# seemingly-broken .ics file.
|
|
return build_ics_reminders(
|
|
locktime=locktime,
|
|
basic_mode=basic_mode,
|
|
description=raw_description,
|
|
summary=raw_summary,
|
|
wallet_name=str(self.bal_window.wallet),
|
|
heirs_details=heirs_details,
|
|
version=self.bal_window.bal_plugin.version,
|
|
num_reminders=num_reminders,
|
|
threshold=threshold,
|
|
)
|
|
except Exception as e:
|
|
_logger.error(f"failed to generate .ics: {e}")
|
|
return None
|
|
|
|
def _on_close_clicked(self):
|
|
# Close the dialog first, then show the persistent popup guiding the
|
|
# user through any remaining MANUAL steps (Sign / Broadcast). Showing
|
|
# the (modal) hint after close() mirrors the previous behaviour where
|
|
# the hint appeared once the auto-closing dialog was gone.
|
|
self.close()
|
|
if self._next_steps_hint:
|
|
self.bal_window.show_message(self._next_steps_hint)
|
|
|
|
def closeEvent(self, event):
|
|
self._stopping = True
|
|
# Stop AND join the thread, then propagate the close event (previously
|
|
# it neither waited nor called super().closeEvent()).
|
|
stop_thread(getattr(self, "thread", None))
|
|
super().closeEvent(event)
|
|
|
|
def task_phase2(self, password):
|
|
if self._stopping:
|
|
return
|
|
if self.have_to_sign:
|
|
try:
|
|
if txs := self.bal_window.sign_transactions(password):
|
|
for txid, tx in txs.items():
|
|
# Re-parse instead of deepcopy (the signed tx can carry
|
|
# wallet-derived input info holding a threading.RLock,
|
|
# which copy.deepcopy cannot pickle).
|
|
self.bal_window.willitems[txid].tx = Will.get_tx_from_any(
|
|
str(tx)
|
|
)
|
|
self.bal_window.save_willitems()
|
|
self.msg_set_signing(self.msg_ok())
|
|
except Exception as e:
|
|
self.msg_set_signing(self.msg_error(e))
|
|
|
|
self.msg_set_pushing()
|
|
have_to_push = False
|
|
for wid in Will.only_valid(self.bal_window.willitems):
|
|
w = self.bal_window.willitems[wid]
|
|
if w.we and w.get_status("COMPLETE") and not w.get_status("PUSHED"):
|
|
have_to_push = True
|
|
if not have_to_push:
|
|
self.msg_set_pushing(_("Nothing to do"))
|
|
else:
|
|
try:
|
|
self.loop_push()
|
|
self.msg_set_pushing(self.msg_ok())
|
|
|
|
except Exception as e:
|
|
# td = traceback.format_exc()
|
|
self.msg_set_pushing(self.msg_error(e))
|
|
# Blank separator row: visually detach the final "All done" summary
|
|
# from the per-step result rows above it, so the closing line stands
|
|
# out as the overall outcome rather than just another step.
|
|
self.msg_edit_row("")
|
|
# Final summary row: the whole "Building Will" sequence above (check /
|
|
# sign / broadcast) finished without errors. Give it an explicit
|
|
# left-side label ("All done") so this closing Ok is not an orphan
|
|
# result like the other rows have.
|
|
self.msg_edit_row("{}:\t{}".format(_("All done"), self.msg_ok()))
|
|
|
|
# Guide the user through any remaining MANUAL steps. After the will is
|
|
# (re)built -- e.g. because an heir was removed/added from the Wizard --
|
|
# the new transactions may still need to be SIGNED and/or BROADCAST by
|
|
# the user. This dialog only signs/pushes automatically when it already
|
|
# has the password and the will is in the right state; in every other
|
|
# case the user is otherwise left without any indication of what to do
|
|
# next. We inspect the real status of the valid wills and tell the user
|
|
# exactly which buttons to press.
|
|
self._show_next_steps_hint()
|
|
|
|
def _show_next_steps_hint(self):
|
|
"""Append a clear "what to do next" line to the Building Will dialog.
|
|
|
|
Pure UX guidance (no logic change): looks at the valid wills and, if any
|
|
still needs signing or broadcasting, tells the user to press 'Sign'
|
|
and/or 'Broadcast' manually. The computed hint is also stored in
|
|
``self._next_steps_hint`` so a persistent popup can be shown after the
|
|
dialog closes (this dialog auto-closes after a few seconds, which is too
|
|
short to be sure the user noticed the in-dialog line).
|
|
"""
|
|
self._next_steps_hint = None
|
|
# Group B / B2: when AUTO_SIGN is ON the dialog has already signed and
|
|
# broadcast the will automatically, so the manual "press Sign/Broadcast"
|
|
# hints (and the follow-up popup) would be wrong/confusing. Suppress
|
|
# them in that case. When AUTO_SIGN is OFF, keep the previous behaviour
|
|
# and guide the user through the remaining manual steps.
|
|
try:
|
|
if (
|
|
self.bal_window.bal_plugin.is_basic_mode()
|
|
or self.bal_window.bal_plugin.AUTO_SIGN.get()
|
|
):
|
|
return
|
|
except Exception:
|
|
# If the setting cannot be read for any reason, fall back to the
|
|
# original behaviour (show the manual hints).
|
|
pass
|
|
try:
|
|
need_sign = False
|
|
need_push = False
|
|
for wid in Will.only_valid(self.bal_window.willitems):
|
|
w = self.bal_window.willitems[wid]
|
|
if not w.get_status("COMPLETE"):
|
|
# Not signed yet.
|
|
need_sign = True
|
|
elif w.we and not w.get_status("PUSHED"):
|
|
# Signed but not yet sent to its will-executor.
|
|
need_push = True
|
|
|
|
if need_sign and need_push:
|
|
hint = _(
|
|
"Next steps (manual): press 'Sign' to sign your will, "
|
|
"then 'Broadcast' to send it to the will-executors."
|
|
)
|
|
elif need_sign:
|
|
hint = _(
|
|
"Next step (manual): press 'Sign' to sign your will."
|
|
)
|
|
elif need_push:
|
|
hint = _(
|
|
"Next step (manual): press 'Broadcast' to send your will "
|
|
"to the will-executors."
|
|
)
|
|
else:
|
|
# Nothing left to do (already signed and, if needed, sent).
|
|
return
|
|
|
|
self._next_steps_hint = hint
|
|
self.msg_edit_row("<b>{}</b>".format(hint))
|
|
except Exception as hint_err:
|
|
_logger.debug(f"next-steps hint error: {hint_err}")
|
|
|
|
def on_error(self, error):
|
|
_logger.error(error)
|
|
pass
|
|
|
|
def on_error_phase1(self, error):
|
|
self.bal_window.update_all()
|
|
a, b, c = error
|
|
self.msg_edit_row(self.msg_error(f"Error: {b}"))
|
|
import traceback
|
|
_logger.error(f"error phase1: {b}\n{''.join(traceback.format_exception(a, b, c))}")
|
|
button=QPushButton(_("Close"))
|
|
button.clicked.connect(self.close)
|
|
self.vbox.addWidget(button)
|
|
self.resize(self.vbox.sizeHint()+button.sizeHint()*2)
|
|
self.repaint()
|
|
def on_error_phase2(self, error):
|
|
self.bal_window.upade_all()
|
|
a, b, c = error
|
|
self.msg_edit_row(self.msg_error(f"Error: {b}"))
|
|
_logger.error(f"error phase2: {b}")
|
|
|
|
def _executed_inheritance_status(self):
|
|
"""Return the on-chain status of an already-executed inheritance.
|
|
|
|
Task #7a (owner request). After an inheritance is executed the wallet is
|
|
fully emptied (the plugin always empties the wallet). Pressing CHECK on
|
|
such an empty wallet makes ``check_will`` raise NotCompleteWillException
|
|
(the heirs no longer match the empty wallet), which previously showed the
|
|
alarming "Found CHANGES ... a NEW WILL must be prepared" message even
|
|
though the inheritance was, in fact, correctly executed.
|
|
|
|
To reassure the user we look at the will items, whose status is already
|
|
set to CONFIRMED / MEMPOOL by ``Will.check_will`` (see core/will.py), and
|
|
return a short code describing the real situation:
|
|
|
|
Returns:
|
|
"CONFIRMED" if at least one will transaction is confirmed on-chain
|
|
(the inheritance is already executed);
|
|
"MEMPOOL" if none is confirmed but at least one is in the mempool
|
|
(waiting for confirmation);
|
|
None if neither (the change really has to be rebuilt).
|
|
|
|
CONFIRMED takes precedence over MEMPOOL, since a confirmed transaction is
|
|
the strongest evidence that the inheritance has gone through.
|
|
"""
|
|
has_mempool = False
|
|
try:
|
|
for _wid, witem in self.bal_window.willitems.items():
|
|
if witem.get_status("CONFIRMED"):
|
|
return "CONFIRMED"
|
|
if witem.get_status("MEMPOOL"):
|
|
has_mempool = True
|
|
except Exception as _err:
|
|
# Never let this purely informational check break the flow.
|
|
_logger.debug(f"_executed_inheritance_status error: {_err}")
|
|
return "MEMPOOL" if has_mempool else None
|
|
|
|
def _check_failure_message(self, e):
|
|
"""Return a precise explanation of why the will is no longer coherent.
|
|
|
|
``Will.is_will_valid`` raises NotCompleteWillException (or one of its
|
|
subclasses) when the stored will stops matching the wallet, the heirs
|
|
or the will-executors. Those exceptions ALREADY carry the useful
|
|
detail - the heir name, the will-executor URL, the old and new fee
|
|
rate - but this dialog used to discard all of it and print the same
|
|
sentence, "Found CHANGES to the DATE or the HEIRS", for every case.
|
|
|
|
That was not merely vague, it was WRONG for the most common situation:
|
|
when the wallet simply receives new funds, neither the date nor the
|
|
heirs changed, yet the user was sent hunting for edits never made.
|
|
|
|
NOTE: no new exception classes were added for this (owner request).
|
|
Every case below is told apart using only what the core already
|
|
raises today.
|
|
"""
|
|
# Subclasses first - they are all NotCompleteWillException.
|
|
if isinstance(e, TxFeesChangedException):
|
|
# The core raises TxFeesChangedException(f"{tx_fees}: {w.tx_fees}"),
|
|
# i.e. "current: stored". Show both rates when they can be read,
|
|
# and fall back to a plain sentence if that format ever changes.
|
|
try:
|
|
now_fee, old_fee = [p.strip() for p in str(e).split(":", 1)]
|
|
return _(
|
|
"Miner fee rate changed (the will was built with {} "
|
|
"sat/byte, now it is {}): a new will must be prepared."
|
|
).format(old_fee, now_fee)
|
|
except Exception:
|
|
return _(
|
|
"The miner fee rate changed: a new will must be prepared."
|
|
)
|
|
if isinstance(e, WillExecutorNotPresent):
|
|
return _(
|
|
'Will-executor "{}" is not covered by the current will: '
|
|
"a new will must be prepared."
|
|
).format(str(e))
|
|
if isinstance(e, NoWillExecutorNotPresent):
|
|
return _(
|
|
"Backup mode is enabled but the will has no backup "
|
|
"transaction: a new will must be prepared."
|
|
)
|
|
if isinstance(e, HeirNotFoundException):
|
|
# Raised when an heir was added, removed, or had its delivery date
|
|
# changed. We deliberately do NOT try to tell those three apart
|
|
# (owner request: too fine-grained); naming the heir is what makes
|
|
# the message actionable.
|
|
return _(
|
|
'Heir "{}" is not covered by the current will (it was added '
|
|
"or removed, or its delivery date changed): a new will must "
|
|
"be prepared."
|
|
).format(str(e))
|
|
# Kept for completeness: nothing in the plugin raises these two today,
|
|
# but they ARE NotCompleteWillException subclasses, so should future
|
|
# code raise them they get a sensible message rather than the fallback.
|
|
if isinstance(e, HeirChangeException):
|
|
return _("The heirs changed: a new will must be prepared.")
|
|
if isinstance(e, WillexecutorChangeException):
|
|
return _("A will-executor changed: a new will must be prepared.")
|
|
|
|
# A plain NotCompleteWillException. The core raises it in exactly two
|
|
# places, told apart STRUCTURALLY (not by matching message text, which
|
|
# would be fragile): with no argument when the will holds no valid
|
|
# transaction, and with one argument when a wallet utxo is not
|
|
# included in the will.
|
|
if type(e) is NotCompleteWillException:
|
|
if e.args:
|
|
return _(
|
|
"The wallet contains funds that the current will does not "
|
|
"cover yet: a new will must be prepared."
|
|
)
|
|
return _(
|
|
"The will contains no valid transaction: a new will must be "
|
|
"prepared."
|
|
)
|
|
|
|
# An unrecognised subclass: say so honestly instead of guessing.
|
|
return _(
|
|
"The will is no longer coherent and must be rebuilt; the exact "
|
|
"reason could not be determined."
|
|
)
|
|
|
|
def _build_failure_message(self):
|
|
"""Return a plain-language explanation of why the will was not built.
|
|
|
|
``Heirs.buildTransactions`` records a reason code in
|
|
``last_build_error`` every time it gives up (the codes are listed in
|
|
that method). Here we turn that code into ONE specific sentence
|
|
telling the user what to fix.
|
|
|
|
WHY: this dialog used to print the same three "possible reasons"
|
|
(low balance / dust shares / check-alive after the delivery date)
|
|
whenever the build returned nothing. The owner hit a real case where
|
|
all three were false - the actual cause was that no will-executor was
|
|
usable, which the list did not even mention - so the message actively
|
|
misled. When the reason is unknown we now SAY that it is unknown and
|
|
list what to check, instead of asserting three guesses as if they were
|
|
the only possibilities.
|
|
"""
|
|
reason = None
|
|
try:
|
|
reason = getattr(self.bal_window.heirs, "last_build_error", None)
|
|
except Exception as _err:
|
|
# A diagnostic must never break the report it is explaining.
|
|
_logger.debug(f"_build_failure_message: {_err}")
|
|
|
|
messages = {
|
|
"NO_HEIRS": _(
|
|
"No heirs: add at least one heir before building the will."
|
|
),
|
|
"NO_UTXO": _(
|
|
"The wallet has no spendable funds, so no inheritance "
|
|
"transaction can be created."
|
|
),
|
|
"NO_WILLEXECUTOR_USABLE": _(
|
|
"No usable will-executor: none of the servers in the list is "
|
|
"both selected and valid. Open the will-executor settings, "
|
|
"select at least one server and check that it is reachable."
|
|
),
|
|
"NO_FUTURE_DATE": _(
|
|
"No delivery date left to build: every heir's date is already "
|
|
"covered by the existing will. Choose a later delivery date, "
|
|
"or change an heir's date."
|
|
),
|
|
"WILLEXECUTOR_FEE": _(
|
|
"The amount to send must cover the miner fees plus this "
|
|
"will-executor's fee, and the wallet balance is not enough: "
|
|
"select cheaper will-executors, or add funds to the wallet."
|
|
),
|
|
"WILLEXECUTOR_FEE_TOO_HIGH": _(
|
|
"A will-executor asks for more than the maximum fee you "
|
|
"allowed: raise the maximum fee in the settings, or select a "
|
|
"cheaper will-executor."
|
|
),
|
|
"TX_BUILD_FAILED": _(
|
|
"The inheritance transactions could not be assembled from the "
|
|
"available funds (the balance may not cover the miner fees)."
|
|
),
|
|
"WILLEXECUTOR_TX_ERROR": _(
|
|
"An unexpected error stopped the transactions being prepared "
|
|
"for a will-executor, so it was skipped. Try again, or select "
|
|
"a different will-executor."
|
|
),
|
|
}
|
|
|
|
if reason in messages:
|
|
return messages[reason] + "\n\n" + _("Skipped")
|
|
|
|
return (
|
|
_(
|
|
"Could not build the will, and the exact cause could not be "
|
|
"determined. Please check that:\n"
|
|
"- the wallet balance covers the miner and will-executor fees,\n"
|
|
"- each heir's share is above the minimum (dust limit),\n"
|
|
"- the Check Alive date is EARLIER than the delivery date,\n"
|
|
"- at least one will-executor is selected and reachable."
|
|
)
|
|
+ "\n\n"
|
|
+ _("Skipped")
|
|
)
|
|
|
|
def msg_set_checking(self, status="Waiting", row=None):
|
|
row = self.check_row if row is None else row
|
|
self.check_row = self.msg_set_status(_("Checking your will"), row, status)
|
|
|
|
def msg_set_invalidating(self, status=None, row=None):
|
|
row = self.inval_row if row is None else row
|
|
self.inval_row = self.msg_set_status(
|
|
_("Invalidating old will"), self.inval_row, status
|
|
)
|
|
|
|
def msg_set_building(self, status=None, row=None,color=None):
|
|
row = self.build_row if row is None else row
|
|
self.build_row = self.msg_set_status(
|
|
"Building your will", self.build_row, status, color
|
|
)
|
|
|
|
def msg_set_signing(self, status=None, row=None):
|
|
row = self.sign_row if row is None else row
|
|
self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status)
|
|
|
|
def msg_set_pushing(self, status=None, row=None):
|
|
row = self.push_row if row is None else row
|
|
self.push_row = self.msg_set_status(
|
|
"Broadcasting your will to executors", self.push_row, status
|
|
)
|
|
|
|
def msg_set_waiting(self, status=None, row=None):
|
|
row = self.wait_row if row is None else row
|
|
self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row)
|
|
|
|
def msg_error(self, e):
|
|
# Results are shown in bold so the outcome stands out from the
|
|
# left-side state label (which stays in normal weight).
|
|
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
|
|
|
|
def msg_ok(self, e="Ok"):
|
|
# Results are shown in bold (see msg_error).
|
|
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_OK, e)
|
|
|
|
def msg_warning(self, e):
|
|
# Results are shown in bold (see msg_error).
|
|
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_WARNING, e)
|
|
|
|
def msg_alert(self, e):
|
|
"""Amber warning sign followed by text in the theme's default colour.
|
|
|
|
WHY: long warnings printed entirely in amber (COLOR_WARNING) are hard
|
|
to read - the owner reported the multi-line "could not build the will"
|
|
block as barely legible. Colour is only needed to ATTRACT attention,
|
|
not to be read, so we keep it on the "warning sign" character alone and
|
|
let the message body inherit Electrum's normal text colour. That also
|
|
keeps it readable under the dark theme, where a hard-coded black would
|
|
disappear. U+26A0 is written as a numeric HTML entity so the source
|
|
file stays pure ASCII; QLabel renders it as rich text.
|
|
"""
|
|
return "<font color='{}'>⚠</font> <b>{}</b>".format(
|
|
self.COLOR_WARNING, e
|
|
)
|
|
|
|
def msg_set_status(self, msg, row=None, status=None, color=None):
|
|
# The left "state" label keeps its normal weight; only the right-side
|
|
# result (``status``) is rendered in bold so it is easy to read at a
|
|
# glance. ``status`` may already contain rich-text emitted by
|
|
# msg_ok/msg_error/msg_warning (which add their own <b>...</b>); wrapping
|
|
# it again in <b> is harmless for those cases.
|
|
status = "Wait" if status is None else status
|
|
if color is None:
|
|
line = "{}:\t<b>{}</b>".format(_(msg), status)
|
|
else:
|
|
line = "{}:\t<font color={}><b>{}</b></font>".format(
|
|
_(msg), color, status
|
|
)
|
|
return self.msg_edit_row(line, row)
|
|
|
|
def ask_password(self, msg=None):
|
|
self.password = self.bal_window.get_wallet_password(msg, parent=self)
|
|
|
|
def msg_edit_row(self, line, row=None):
|
|
try:
|
|
self.labels[row] = line
|
|
except Exception:
|
|
self.labels.append(line)
|
|
row = len(self.labels) - 1
|
|
|
|
self.updatemessage.emit()
|
|
|
|
return row
|
|
|
|
def msg_del_row(self, row):
|
|
try:
|
|
del self.labels[row]
|
|
except Exception:
|
|
pass
|
|
self.updatemessage.emit()
|
|
|
|
# def clear_layout(self,layout):
|
|
# while layout.count():
|
|
# item = layout.takeAt(0)
|
|
# w = item.widget()
|
|
# if w:
|
|
# w.setParent(None)
|
|
# w.deleteLater()
|
|
|
|
# def msg_update(self):
|
|
# self.clear_layout(self.labelsbox)
|
|
# for label in self.labels:
|
|
# label=label.replace("\n","<br>")
|
|
# qlabel=QLabel(label)
|
|
# qlabel.setWordWrap(True)
|
|
# self.labelsbox.addWidget(qlabel)
|
|
|
|
# self.labelsbox.activate()
|
|
# self.qwidget.setMinimumSize(self.labelsbox.sizeHint())
|
|
# self.qwidget.adjustSize()
|
|
# from PyQt6.QtWidgets import QApplication
|
|
# QApplication.processEvents()
|
|
#
|
|
# self.adjustSize()
|
|
def msg_update(self):
|
|
full_text = "<br><br>".join(self.labels).replace("\n", "<br>")
|
|
self.message_label.setText(full_text)
|
|
self.message_label.adjustSize()
|
|
# Auto-scroll the report to the BOTTOM so the newest line is always
|
|
# visible (allegato14). The QScrollArea caps the height, so instead of
|
|
# resizing the whole dialog taller we move its vertical scrollbar to the
|
|
# maximum. ensureWidgetVisible is deferred via the scrollbar range so it
|
|
# reflects the just-added text.
|
|
scrollbar = self.scroll_area.verticalScrollBar()
|
|
scrollbar.setValue(scrollbar.maximum())
|
|
# Let the dialog grow only up to the scroll area's capped height; beyond
|
|
# that the scrollbar handles overflow and the buttons stay reachable.
|
|
self.resize(self.sizeHint())
|
|
# Re-assert the bottom position after the resize/relayout settled.
|
|
scrollbar.setValue(scrollbar.maximum())
|
|
|
|
def get_text(self):
|
|
return self.message_label.text()
|
|
|
|
pass
|
|
|
|
|
|
|
|
class WillDetailDialog(BalDialog):
|
|
def __init__(self, bal_window, will=None, threshold=None):
|
|
# ``will``/``threshold`` are passed when showing an IMPORTED (read-only)
|
|
# will. In that case every action button below operates on the imported
|
|
# will, never on the live wallet state.
|
|
self._external_will = will is not None
|
|
self.will = will if self._external_will else bal_window.willitems
|
|
if threshold is not None:
|
|
self.threshold = threshold
|
|
elif self._external_will:
|
|
self.threshold = max(wi.tx.locktime for wi in self.will.values())
|
|
else:
|
|
self.threshold = bal_window.will_settings["real_threshold"]
|
|
|
|
self.bal_window = bal_window
|
|
Will.add_willtree(self.will)
|
|
super().__init__(bal_window.window, bal_window.bal_plugin)
|
|
self.config = bal_window.window.config
|
|
self.wallet = bal_window.wallet
|
|
self.format_amount = bal_window.window.format_amount
|
|
self.base_unit = bal_window.window.base_unit
|
|
self.format_fiat_and_units = bal_window.window.format_fiat_and_units
|
|
self.fx = bal_window.window.fx
|
|
self.format_fee_rate = bal_window.window.format_fee_rate
|
|
self.decimal_point = bal_window.window.get_decimal_point()
|
|
self.base_unit_name = decimal_point_to_base_unit_name(self.decimal_point)
|
|
self.setWindowTitle(_("Will Details"))
|
|
self.setMinimumSize(670, 700)
|
|
self.vlayout = QVBoxLayout()
|
|
w = QWidget()
|
|
hlayout = QHBoxLayout(w)
|
|
|
|
b = QPushButton(_("Sign"))
|
|
b.clicked.connect(self.ask_password_and_sign_transactions)
|
|
hlayout.addWidget(b)
|
|
|
|
b = QPushButton(_("Broadcast"))
|
|
b.clicked.connect(self.broadcast_transactions)
|
|
hlayout.addWidget(b)
|
|
|
|
b = QPushButton(_("Export"))
|
|
b.clicked.connect(self.export_will)
|
|
hlayout.addWidget(b)
|
|
b = QPushButton(_("Invalidate"))
|
|
b.clicked.connect(self.invalidate_will)
|
|
hlayout.addWidget(b)
|
|
self.merge_button = None
|
|
if self._external_will:
|
|
b = QPushButton(_("Merge"))
|
|
b.clicked.connect(self.merge_will)
|
|
hlayout.addWidget(b)
|
|
self.merge_button = b
|
|
self.vlayout.addWidget(w)
|
|
|
|
self.paint_scroll_area()
|
|
self.vlayout.addWidget(
|
|
QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold)))
|
|
)
|
|
self.vlayout.addWidget(self.scrollbox)
|
|
w = QWidget()
|
|
hlayout = QHBoxLayout(w)
|
|
hlayout.addWidget(
|
|
QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will))))
|
|
)
|
|
hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will))))
|
|
self.vlayout.addWidget(w)
|
|
self.setLayout(self.vlayout)
|
|
|
|
def paint_scroll_area(self):
|
|
self.scrollbox = QScrollArea()
|
|
viewport = QWidget(self.scrollbox)
|
|
self.willlayout = QVBoxLayout(viewport)
|
|
self.detailsWidget = WillWidget(parent=self, will=self.will)
|
|
self.willlayout.addWidget(self.detailsWidget)
|
|
|
|
self.scrollbox.setWidget(viewport)
|
|
viewport.setLayout(self.willlayout)
|
|
|
|
def ask_password_and_sign_transactions(self):
|
|
self.bal_window.ask_password_and_sign_transactions(
|
|
callback=self.update, will=self.will if self._external_will else None
|
|
)
|
|
self.update()
|
|
|
|
def broadcast_transactions(self):
|
|
self.bal_window.broadcast_transactions(
|
|
will=self.will if self._external_will else None
|
|
)
|
|
self.update()
|
|
|
|
def export_will(self):
|
|
self.bal_window.export_will(will=self.will if self._external_will else None)
|
|
|
|
def invalidate_will(self):
|
|
self.bal_window.invalidate_will(
|
|
will=self.will if self._external_will else None
|
|
)
|
|
|
|
def merge_will(self):
|
|
"""Merge the imported will into the live will and switch to it.
|
|
|
|
The merge is performed by :meth:`BalWindow.merge_will` (the same
|
|
common method used by the tools-menu "Merge" action). Afterwards the
|
|
dialog stops showing the read-only imported will and operates on the
|
|
live wallet willitems directly, so any further Sign/Broadcast/Export/
|
|
Invalidate action targets the saved will items.
|
|
"""
|
|
self.bal_window.merge_will(self.will)
|
|
self._external_will = False
|
|
self.will = self.bal_window.willitems
|
|
self.threshold = self.bal_window.will_settings["real_threshold"]
|
|
if self.merge_button:
|
|
self.merge_button.hide()
|
|
self.update()
|
|
|
|
def toggle_replaced(self):
|
|
self.bal_window.bal_plugin.hide_replaced()
|
|
toggle = _("Hide")
|
|
if self.bal_window.bal_plugin._hide_replaced:
|
|
toggle = _("Unhide")
|
|
self.toggle_replace_button.setText(f"{toggle} {_('replaced')}")
|
|
self.update()
|
|
|
|
def toggle_invalidated(self):
|
|
self.bal_window.bal_plugin.hide_invalidated()
|
|
toggle = _("Hide")
|
|
if self.bal_window.bal_plugin._hide_invalidated:
|
|
toggle = _("Unhide")
|
|
self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}"))
|
|
self.update()
|
|
|
|
def update(self):
|
|
if not self._external_will:
|
|
self.will = self.bal_window.willitems
|
|
pos = self.vlayout.indexOf(self.scrollbox)
|
|
self.vlayout.removeWidget(self.scrollbox)
|
|
self.paint_scroll_area()
|
|
self.vlayout.insertWidget(pos, self.scrollbox)
|
|
super().update()
|
|
|
|
|
|
|
|
class WillExecutorDialog(BalDialog, MessageBoxMixin):
|
|
def __init__(self, bal_window, parent=None):
|
|
if not parent:
|
|
parent = bal_window.window
|
|
BalDialog.__init__(self, parent, bal_window.bal_plugin)
|
|
self.bal_plugin = bal_window.bal_plugin
|
|
self.config = self.bal_plugin.config
|
|
self.bal_window = bal_window
|
|
self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin)
|
|
|
|
self.setWindowTitle(_("Will-Executor Service List"))
|
|
self.setMinimumSize(1000, 200)
|
|
|
|
# Lazy import to avoid a dialogs<->lists import cycle.
|
|
from .lists import WillExecutorWidget
|
|
vbox = QVBoxLayout(self)
|
|
self.will_executor_list_widget = WillExecutorWidget(
|
|
self, self.bal_window, self.willexecutors_list
|
|
)
|
|
vbox.addWidget(self.will_executor_list_widget)
|
|
|
|
def is_hidden(self):
|
|
return self.isMinimized() or self.isHidden()
|
|
|
|
def show_or_hide(self):
|
|
if self.is_hidden():
|
|
self.bring_to_top()
|
|
else:
|
|
self.hide()
|
|
|
|
def bring_to_top(self):
|
|
self.show()
|
|
# raise_() alone does not grab focus on some window managers (Windows);
|
|
# activateWindow() ensures the dialog actually comes to the front.
|
|
bring_to_front(self)
|
|
|
|
def closeEvent(self, event):
|
|
event.accept()
|
|
|
|
|
|
class HeirsDialog(BalDialog, MessageBoxMixin):
|
|
def __init__(self, bal_window, parent=None):
|
|
if not parent:
|
|
parent = bal_window.window
|
|
BalDialog.__init__(self, parent, bal_window.bal_plugin)
|
|
self.bal_plugin = bal_window.bal_plugin
|
|
self.bal_window = bal_window
|
|
|
|
self.setWindowTitle(_("Heirs"))
|
|
self.setMinimumSize(800, 300)
|
|
|
|
from .lists import HeirListWidget
|
|
vbox = QVBoxLayout(self)
|
|
self.heir_list_widget = HeirListWidget(bal_window, self)
|
|
vbox.addWidget(self.heir_list_widget)
|
|
|
|
btn_row = QHBoxLayout()
|
|
new_heir_btn = QPushButton(_("New Heir"))
|
|
new_heir_btn.clicked.connect(self._add_heir)
|
|
btn_row.addWidget(new_heir_btn)
|
|
|
|
import_btn = QPushButton(_("Import"))
|
|
import_btn.clicked.connect(self._import_heirs)
|
|
btn_row.addWidget(import_btn)
|
|
|
|
export_btn = QPushButton(_("Export"))
|
|
export_btn.clicked.connect(self._export_heirs)
|
|
btn_row.addWidget(export_btn)
|
|
|
|
btn_row.addStretch(1)
|
|
vbox.addLayout(btn_row)
|
|
|
|
def _add_heir(self):
|
|
self.bal_window.new_heir_dialog()
|
|
self.heir_list_widget.update()
|
|
|
|
def _import_heirs(self):
|
|
self.bal_window.import_heirs()
|
|
self.heir_list_widget.update()
|
|
|
|
def _export_heirs(self):
|
|
self.bal_window.export_heirs()
|
|
|
|
def closeEvent(self, event):
|
|
event.accept()
|
|
|