1
0

Merge pull request #9862 from oren-z0/fix-timelock-recovery-watch-wallets

Fix Timelock Recovery plugin for watch wallets
This commit is contained in:
ghost43
2025-07-04 06:29:13 +00:00
committed by GitHub
5 changed files with 119 additions and 31 deletions

View File

@@ -1181,16 +1181,20 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self, self,
tx: Transaction, tx: Transaction,
*, *,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None, external_keypairs: Mapping[bytes, bytes] = None,
invoice: Invoice = None, invoice: Invoice = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
show_sign_button: bool = True, show_sign_button: bool = True,
show_broadcast_button: bool = True, show_broadcast_button: bool = True,
): ):
show_transaction( show_transaction(
tx, tx,
parent=self, parent=self,
prompt_if_complete_unsaved=prompt_if_complete_unsaved,
external_keypairs=external_keypairs, external_keypairs=external_keypairs,
invoice=invoice, invoice=invoice,
on_closed=on_closed,
show_sign_button=show_sign_button, show_sign_button=show_sign_button,
show_broadcast_button=show_broadcast_button, show_broadcast_button=show_broadcast_button,
) )

View File

@@ -425,9 +425,10 @@ def show_transaction(
*, *,
parent: 'ElectrumWindow', parent: 'ElectrumWindow',
prompt_if_unsaved: bool = False, prompt_if_unsaved: bool = False,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None, external_keypairs: Mapping[bytes, bytes] = None,
invoice: 'Invoice' = None, invoice: 'Invoice' = None,
on_closed: Callable[[], None] = None, on_closed: Callable[[Optional[Transaction]], None] = None,
show_sign_button: bool = True, show_sign_button: bool = True,
show_broadcast_button: bool = True, show_broadcast_button: bool = True,
): ):
@@ -436,6 +437,7 @@ def show_transaction(
tx, tx,
parent=parent, parent=parent,
prompt_if_unsaved=prompt_if_unsaved, prompt_if_unsaved=prompt_if_unsaved,
prompt_if_complete_unsaved=prompt_if_complete_unsaved,
external_keypairs=external_keypairs, external_keypairs=external_keypairs,
invoice=invoice, invoice=invoice,
on_closed=on_closed, on_closed=on_closed,
@@ -461,9 +463,10 @@ class TxDialog(QDialog, MessageBoxMixin):
*, *,
parent: 'ElectrumWindow', parent: 'ElectrumWindow',
prompt_if_unsaved: bool, prompt_if_unsaved: bool,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None, external_keypairs: Mapping[bytes, bytes] = None,
invoice: 'Invoice' = None, invoice: 'Invoice' = None,
on_closed: Callable[[], None] = None, on_closed: Callable[[Optional[Transaction]], None] = None,
): ):
'''Transactions in the wallet will show their description. '''Transactions in the wallet will show their description.
Pass desc to give a description for txs not yet in the wallet. Pass desc to give a description for txs not yet in the wallet.
@@ -477,6 +480,7 @@ class TxDialog(QDialog, MessageBoxMixin):
self.wallet = parent.wallet self.wallet = parent.wallet
self.invoice = invoice self.invoice = invoice
self.prompt_if_unsaved = prompt_if_unsaved self.prompt_if_unsaved = prompt_if_unsaved
self.prompt_if_complete_unsaved = prompt_if_complete_unsaved
self.on_closed = on_closed self.on_closed = on_closed
self.saved = False self.saved = False
self.desc = None self.desc = None
@@ -640,7 +644,7 @@ class TxDialog(QDialog, MessageBoxMixin):
self._fetch_txin_data_fut = None self._fetch_txin_data_fut = None
if self.on_closed: if self.on_closed:
self.on_closed() self.on_closed(self.tx)
def reject(self): def reject(self):
# Override escape-key to close normally (and invoke closeEvent) # Override escape-key to close normally (and invoke closeEvent)
@@ -711,7 +715,7 @@ class TxDialog(QDialog, MessageBoxMixin):
def sign(self): def sign(self):
def sign_done(success): def sign_done(success):
if self.tx.is_complete(): if self.tx.is_complete() and self.prompt_if_complete_unsaved:
self.prompt_if_unsaved = True self.prompt_if_unsaved = True
self.saved = False self.saved = False
self.update() self.update()

View File

@@ -145,7 +145,7 @@ class QtCosignerWallet(EventListener, CosignerWallet):
self.add_transaction_to_wallet(tx, label=label, on_failure=self.on_add_fail) self.add_transaction_to_wallet(tx, label=label, on_failure=self.on_add_fail)
self.window.update_tabs() self.window.update_tabs()
def on_tx_dialog_closed(self, event_id): def on_tx_dialog_closed(self, event_id, _tx: Optional['Transaction']):
self.mark_pending_event_rcvd(event_id) self.mark_pending_event_rcvd(event_id)
def on_add_fail(self, msg: str): def on_add_fail(self, msg: str):

View File

@@ -35,7 +35,7 @@ from electrum.gui.qt.paytoedit import PayToEdit
from electrum.payment_identifier import PaymentIdentifierType from electrum.payment_identifier import PaymentIdentifierType
from electrum.plugin import hook from electrum.plugin import hook
from electrum.i18n import _ from electrum.i18n import _
from electrum.transaction import PartialTxOutput from electrum.transaction import PartialTxOutput, Transaction
from electrum.util import NotEnoughFunds, make_dir from electrum.util import NotEnoughFunds, make_dir
from electrum.gui.qt.util import ColorScheme, WindowModalDialog, Buttons, HelpLabel from electrum.gui.qt.util import ColorScheme, WindowModalDialog, Buttons, HelpLabel
from electrum.gui.qt.util import read_QIcon_from_bytes, read_QPixmap_from_bytes, WaitingDialog from electrum.gui.qt.util import read_QIcon_from_bytes, read_QPixmap_from_bytes, WaitingDialog
@@ -176,12 +176,14 @@ class Plugin(TimelockRecoveryPlugin):
plan_dialog = WindowModalDialog(context.main_window, "Timelock Recovery") plan_dialog = WindowModalDialog(context.main_window, "Timelock Recovery")
plan_dialog.setContentsMargins(11, 11, 1, 1) plan_dialog.setContentsMargins(11, 11, 1, 1)
plan_dialog.resize(800, plan_dialog.height()) plan_dialog.resize(800, plan_dialog.height())
fee_policy = FeePolicy('eta:1')
fee_policy = FeePolicy(context.main_window.config.FEE_POLICY)
create_cancel_cb = QCheckBox('', checked=False) create_cancel_cb = QCheckBox('', checked=False)
alert_tx_label = QLabel('') alert_tx_fee_label = QLabel('')
recovery_tx_label = QLabel('') alert_tx_complete_label = QLabel('')
cancellation_tx_label = QLabel('') recovery_tx_fee_label = QLabel('')
recovery_tx_complete_label = QLabel('')
cancellation_tx_fee_label = QLabel('')
cancellation_tx_complete_label = QLabel('')
if not context.get_alert_address(): if not context.get_alert_address():
plan_dialog.show_error(''.join([ plan_dialog.show_error(''.join([
@@ -227,34 +229,62 @@ class Plugin(TimelockRecoveryPlugin):
view_recovery_tx_button.setEnabled(False) view_recovery_tx_button.setEnabled(False)
view_cancellation_tx_button.setEnabled(False) view_cancellation_tx_button.setEnabled(False)
next_button.setEnabled(False) next_button.setEnabled(False)
next_button.setToolTip("")
return return
try: try:
context.alert_tx = context.make_unsigned_alert_tx(fee_policy) new_alert_tx = context.make_unsigned_alert_tx(fee_policy)
alert_changed = False
if not context.alert_tx or context.alert_tx.txid() != new_alert_tx.txid():
context.alert_tx = new_alert_tx
alert_changed = True
assert all(tx_input.is_segwit() for tx_input in context.alert_tx.inputs()) assert all(tx_input.is_segwit() for tx_input in context.alert_tx.inputs())
alert_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.alert_tx.get_fee()))) alert_tx_complete_label.setText(_("✓ Signed") if context.alert_tx.is_complete() else "")
context.recovery_tx = context.make_unsigned_recovery_tx(fee_policy) alert_tx_fee_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.alert_tx.get_fee())))
new_recovery_tx = context.make_unsigned_recovery_tx(fee_policy)
if alert_changed or not context.recovery_tx or context.recovery_tx.txid() != new_recovery_tx.txid():
context.recovery_tx = new_recovery_tx
context.add_input_info_to_recovery_tx()
assert all(tx_input.is_segwit() for tx_input in context.recovery_tx.inputs()) assert all(tx_input.is_segwit() for tx_input in context.recovery_tx.inputs())
recovery_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.recovery_tx.get_fee()))) recovery_tx_complete_label.setText(_("✓ Signed") if context.recovery_tx.is_complete() else "")
recovery_tx_fee_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.recovery_tx.get_fee())))
if create_cancel_cb.isChecked(): if create_cancel_cb.isChecked():
context.cancellation_tx = context.make_unsigned_cancellation_tx(fee_policy) new_cancellation_tx = context.make_unsigned_cancellation_tx(fee_policy)
if alert_changed or not context.cancellation_tx or context.cancellation_tx.txid() != new_cancellation_tx.txid():
context.cancellation_tx = new_cancellation_tx
context.add_input_info_to_cancellation_tx()
assert all(tx_input.is_segwit() for tx_input in context.cancellation_tx.inputs()) assert all(tx_input.is_segwit() for tx_input in context.cancellation_tx.inputs())
cancellation_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.cancellation_tx.get_fee()))) cancellation_tx_complete_label.setText(_("✓ Signed") if context.cancellation_tx.is_complete() else "")
cancellation_tx_fee_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.cancellation_tx.get_fee())))
else: else:
context.cancellation_tx = None context.cancellation_tx = None
cancellation_tx_complete_label.setText(_("✓ Signed") if context.cancellation_tx is not None and context.cancellation_tx.is_complete() else "")
except NotEnoughFunds: except NotEnoughFunds:
view_alert_tx_button.setEnabled(False) view_alert_tx_button.setEnabled(False)
alert_tx_complete_label.setText("")
alert_tx_fee_label.setText("")
view_recovery_tx_button.setEnabled(False) view_recovery_tx_button.setEnabled(False)
recovery_tx_complete_label.setText("")
recovery_tx_fee_label.setText("")
view_cancellation_tx_button.setEnabled(False) view_cancellation_tx_button.setEnabled(False)
cancellation_tx_complete_label.setText("")
cancellation_tx_fee_label.setText("")
payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True)) payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
payto_e.setToolTip("Not enough funds to create the transactions.") payto_e.setToolTip("Not enough funds to create the transactions.")
next_button.setEnabled(False) next_button.setEnabled(False)
next_button.setToolTip("")
return return
view_alert_tx_button.setEnabled(True) view_alert_tx_button.setEnabled(True)
view_recovery_tx_button.setEnabled(True) view_recovery_tx_button.setEnabled(True)
view_cancellation_tx_button.setEnabled(True) view_cancellation_tx_button.setEnabled(True)
payto_e.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True)) payto_e.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True))
payto_e.setToolTip("") payto_e.setToolTip("")
if context.main_window.wallet.is_watching_only():
if not context.alert_tx.is_complete() or not context.recovery_tx.is_complete() or (context.cancellation_tx is not None and not context.cancellation_tx.is_complete()):
next_button.setEnabled(False)
next_button.setToolTip(_("This is a watching-only wallet. You must sign the transactions externally - use the View button of each transaction."))
return
next_button.setEnabled(True) next_button.setEnabled(True)
next_button.setToolTip("")
payto_e.paymentIdentifierChanged.connect(update_transactions) payto_e.paymentIdentifierChanged.connect(update_transactions)
@@ -315,24 +345,58 @@ class Plugin(TimelockRecoveryPlugin):
grid_row += 1 grid_row += 1
plan_grid.addWidget(QLabel('Alert transaction'), grid_row, 0) plan_grid.addWidget(QLabel('Alert transaction'), grid_row, 0)
plan_grid.addWidget(alert_tx_label, grid_row, 1, 1, 3) plan_grid.addWidget(alert_tx_fee_label, grid_row, 1, 1, 2)
plan_grid.addWidget(alert_tx_complete_label, grid_row, 3)
view_alert_tx_button = QPushButton(_('View')) view_alert_tx_button = QPushButton(_('View'))
view_alert_tx_button.clicked.connect(lambda: context.main_window.show_transaction(context.alert_tx, show_sign_button=False, show_broadcast_button=False)) def on_alert_tx_closed(tx: Optional[Transaction]):
if tx is not None and context.alert_tx is not None and tx.txid() == context.alert_tx.txid() and tx.is_complete():
old_alert_tx_complete = context.alert_tx and context.alert_tx.is_complete()
context.alert_tx = tx
if not old_alert_tx_complete and context.alert_tx.is_complete():
context.add_input_info_to_recovery_tx()
context.add_input_info_to_cancellation_tx()
update_transactions()
view_alert_tx_button.clicked.connect(lambda: context.main_window.show_transaction(
context.alert_tx,
prompt_if_complete_unsaved=False,
show_broadcast_button=False,
on_closed=on_alert_tx_closed
))
plan_grid.addWidget(view_alert_tx_button, grid_row, 4) plan_grid.addWidget(view_alert_tx_button, grid_row, 4)
grid_row += 1 grid_row += 1
plan_grid.addWidget(QLabel('Recovery transaction'), grid_row, 0) plan_grid.addWidget(QLabel('Recovery transaction'), grid_row, 0)
plan_grid.addWidget(recovery_tx_label, grid_row, 1, 1, 3) plan_grid.addWidget(recovery_tx_fee_label, grid_row, 1, 1, 2)
plan_grid.addWidget(recovery_tx_complete_label, grid_row, 3)
view_recovery_tx_button = QPushButton(_('View')) view_recovery_tx_button = QPushButton(_('View'))
view_recovery_tx_button.clicked.connect(lambda: context.main_window.show_transaction(context.recovery_tx, show_sign_button=False, show_broadcast_button=False)) def on_recovery_tx_closed(tx: Optional[Transaction]):
if tx is not None and context.recovery_tx is not None and tx.txid() == context.recovery_tx.txid() and tx.is_complete():
context.recovery_tx = tx
update_transactions()
view_recovery_tx_button.clicked.connect(lambda: context.main_window.show_transaction(
context.recovery_tx,
prompt_if_complete_unsaved=False,
show_broadcast_button=False,
on_closed=on_recovery_tx_closed
))
plan_grid.addWidget(view_recovery_tx_button, grid_row, 4) plan_grid.addWidget(view_recovery_tx_button, grid_row, 4)
grid_row += 1 grid_row += 1
cancellation_label = QLabel('Cancellation transaction') cancellation_label = QLabel('Cancellation transaction')
plan_grid.addWidget(cancellation_label, grid_row, 0) plan_grid.addWidget(cancellation_label, grid_row, 0)
plan_grid.addWidget(cancellation_tx_label, grid_row, 1, 1, 3) plan_grid.addWidget(cancellation_tx_fee_label, grid_row, 1, 1, 2)
plan_grid.addWidget(cancellation_tx_complete_label, grid_row, 3)
view_cancellation_tx_button = QPushButton(_('View')) view_cancellation_tx_button = QPushButton(_('View'))
view_cancellation_tx_button.clicked.connect(lambda: context.main_window.show_transaction(context.cancellation_tx, show_sign_button=False, show_broadcast_button=False)) def on_cancellation_tx_closed(tx: Optional[Transaction]):
if tx is not None and context.cancellation_tx is not None and tx.txid() == context.cancellation_tx.txid() and tx.is_complete():
context.cancellation_tx = tx
update_transactions()
view_cancellation_tx_button.clicked.connect(lambda: context.main_window.show_transaction(
context.cancellation_tx,
prompt_if_complete_unsaved=False,
show_broadcast_button=False,
on_closed=on_cancellation_tx_closed
))
plan_grid.addWidget(view_cancellation_tx_button, grid_row, 4) plan_grid.addWidget(view_cancellation_tx_button, grid_row, 4)
grid_row += 1 grid_row += 1
@@ -342,7 +406,7 @@ class Plugin(TimelockRecoveryPlugin):
def on_cb_change(x): def on_cb_change(x):
cancellation_label.setVisible(x) cancellation_label.setVisible(x)
cancellation_tx_label.setVisible(x) cancellation_tx_fee_label.setVisible(x)
view_cancellation_tx_button.setVisible(x) view_cancellation_tx_button.setVisible(x)
update_transactions() update_transactions()
create_cancel_cb.stateChanged.connect(on_cb_change) create_cancel_cb.stateChanged.connect(on_cb_change)
@@ -415,11 +479,21 @@ class Plugin(TimelockRecoveryPlugin):
password = main_window.get_password() password = main_window.get_password()
def task(): def task():
wallet.sign_transaction(context.alert_tx, password, ignore_warnings=True) if not context.alert_tx.is_complete():
context.add_input_info() wallet.sign_transaction(context.alert_tx, password, ignore_warnings=True)
wallet.sign_transaction(context.recovery_tx, password, ignore_warnings=True) context.add_input_info_to_recovery_tx()
context.add_input_info_to_cancellation_tx()
if not context.alert_tx.is_complete():
raise Exception(_("Alert transaction signing was not completed"))
if not context.recovery_tx.is_complete():
wallet.sign_transaction(context.recovery_tx, password, ignore_warnings=True)
if not context.recovery_tx.is_complete():
raise Exception(_("Recovery transaction signing was not completed"))
if context.cancellation_tx is not None: if context.cancellation_tx is not None:
wallet.sign_transaction(context.cancellation_tx, password, ignore_warnings=True) if not context.cancellation_tx.is_complete():
wallet.sign_transaction(context.cancellation_tx, password, ignore_warnings=True)
if not context.cancellation_tx.is_complete():
raise Exception(_("Cancellation transaction signing was not completed"))
def on_success(result): def on_success(result):
self.create_download_dialog(context) self.create_download_dialog(context)

View File

@@ -89,6 +89,7 @@ class TimelockRecoveryContext:
outputs=alert_tx_outputs, outputs=alert_tx_outputs,
fee_policy=fee_policy, fee_policy=fee_policy,
is_sweep=False, is_sweep=False,
locktime=self.alert_tx.locktime if self.alert_tx else None,
) )
def _alert_tx_output(self) -> Tuple[int, 'TxOutput']: def _alert_tx_output(self) -> Tuple[int, 'TxOutput']:
@@ -122,11 +123,15 @@ class TimelockRecoveryContext:
outputs=[output for output in self.outputs if output.value != 0], outputs=[output for output in self.outputs if output.value != 0],
fee_policy=fee_policy, fee_policy=fee_policy,
is_sweep=False, is_sweep=False,
locktime=self.recovery_tx.locktime if self.recovery_tx else None,
) )
def add_input_info(self): def add_input_info_to_recovery_tx(self):
self.recovery_tx.inputs()[0].utxo = self.alert_tx if self.recovery_tx and self.alert_tx.is_complete():
if self.cancellation_tx: self.recovery_tx.inputs()[0].utxo = self.alert_tx
def add_input_info_to_cancellation_tx(self):
if self.cancellation_tx and self.alert_tx.is_complete():
self.cancellation_tx.inputs()[0].utxo = self.alert_tx self.cancellation_tx.inputs()[0].utxo = self.alert_tx
def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction': def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction':
@@ -143,6 +148,7 @@ class TimelockRecoveryContext:
], ],
fee_policy=fee_policy, fee_policy=fee_policy,
is_sweep=False, is_sweep=False,
locktime=self.cancellation_tx.locktime if self.cancellation_tx else None,
) )
class TimelockRecoveryPlugin(BasePlugin): class TimelockRecoveryPlugin(BasePlugin):