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,
tx: Transaction,
*,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None,
invoice: Invoice = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
show_sign_button: bool = True,
show_broadcast_button: bool = True,
):
show_transaction(
tx,
parent=self,
prompt_if_complete_unsaved=prompt_if_complete_unsaved,
external_keypairs=external_keypairs,
invoice=invoice,
on_closed=on_closed,
show_sign_button=show_sign_button,
show_broadcast_button=show_broadcast_button,
)

View File

@@ -425,9 +425,10 @@ def show_transaction(
*,
parent: 'ElectrumWindow',
prompt_if_unsaved: bool = False,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None,
invoice: 'Invoice' = None,
on_closed: Callable[[], None] = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
show_sign_button: bool = True,
show_broadcast_button: bool = True,
):
@@ -436,6 +437,7 @@ def show_transaction(
tx,
parent=parent,
prompt_if_unsaved=prompt_if_unsaved,
prompt_if_complete_unsaved=prompt_if_complete_unsaved,
external_keypairs=external_keypairs,
invoice=invoice,
on_closed=on_closed,
@@ -461,9 +463,10 @@ class TxDialog(QDialog, MessageBoxMixin):
*,
parent: 'ElectrumWindow',
prompt_if_unsaved: bool,
prompt_if_complete_unsaved: bool = True,
external_keypairs: Mapping[bytes, bytes] = None,
invoice: 'Invoice' = None,
on_closed: Callable[[], None] = None,
on_closed: Callable[[Optional[Transaction]], None] = None,
):
'''Transactions in the wallet will show their description.
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.invoice = invoice
self.prompt_if_unsaved = prompt_if_unsaved
self.prompt_if_complete_unsaved = prompt_if_complete_unsaved
self.on_closed = on_closed
self.saved = False
self.desc = None
@@ -640,7 +644,7 @@ class TxDialog(QDialog, MessageBoxMixin):
self._fetch_txin_data_fut = None
if self.on_closed:
self.on_closed()
self.on_closed(self.tx)
def reject(self):
# Override escape-key to close normally (and invoke closeEvent)
@@ -711,7 +715,7 @@ class TxDialog(QDialog, MessageBoxMixin):
def sign(self):
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.saved = False
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.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)
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.plugin import hook
from electrum.i18n import _
from electrum.transaction import PartialTxOutput
from electrum.transaction import PartialTxOutput, Transaction
from electrum.util import NotEnoughFunds, make_dir
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
@@ -176,12 +176,14 @@ class Plugin(TimelockRecoveryPlugin):
plan_dialog = WindowModalDialog(context.main_window, "Timelock Recovery")
plan_dialog.setContentsMargins(11, 11, 1, 1)
plan_dialog.resize(800, plan_dialog.height())
fee_policy = FeePolicy(context.main_window.config.FEE_POLICY)
fee_policy = FeePolicy('eta:1')
create_cancel_cb = QCheckBox('', checked=False)
alert_tx_label = QLabel('')
recovery_tx_label = QLabel('')
cancellation_tx_label = QLabel('')
alert_tx_fee_label = QLabel('')
alert_tx_complete_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():
plan_dialog.show_error(''.join([
@@ -227,34 +229,62 @@ class Plugin(TimelockRecoveryPlugin):
view_recovery_tx_button.setEnabled(False)
view_cancellation_tx_button.setEnabled(False)
next_button.setEnabled(False)
next_button.setToolTip("")
return
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())
alert_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.alert_tx.get_fee())))
context.recovery_tx = context.make_unsigned_recovery_tx(fee_policy)
alert_tx_complete_label.setText(_("✓ Signed") if context.alert_tx.is_complete() else "")
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())
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():
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())
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:
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:
view_alert_tx_button.setEnabled(False)
alert_tx_complete_label.setText("")
alert_tx_fee_label.setText("")
view_recovery_tx_button.setEnabled(False)
recovery_tx_complete_label.setText("")
recovery_tx_fee_label.setText("")
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.setToolTip("Not enough funds to create the transactions.")
next_button.setEnabled(False)
next_button.setToolTip("")
return
view_alert_tx_button.setEnabled(True)
view_recovery_tx_button.setEnabled(True)
view_cancellation_tx_button.setEnabled(True)
payto_e.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True))
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.setToolTip("")
payto_e.paymentIdentifierChanged.connect(update_transactions)
@@ -315,24 +345,58 @@ class Plugin(TimelockRecoveryPlugin):
grid_row += 1
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.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)
grid_row += 1
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.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)
grid_row += 1
cancellation_label = QLabel('Cancellation transaction')
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.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)
grid_row += 1
@@ -342,7 +406,7 @@ class Plugin(TimelockRecoveryPlugin):
def on_cb_change(x):
cancellation_label.setVisible(x)
cancellation_tx_label.setVisible(x)
cancellation_tx_fee_label.setVisible(x)
view_cancellation_tx_button.setVisible(x)
update_transactions()
create_cancel_cb.stateChanged.connect(on_cb_change)
@@ -415,11 +479,21 @@ class Plugin(TimelockRecoveryPlugin):
password = main_window.get_password()
def task():
wallet.sign_transaction(context.alert_tx, password, ignore_warnings=True)
context.add_input_info()
wallet.sign_transaction(context.recovery_tx, password, ignore_warnings=True)
if not context.alert_tx.is_complete():
wallet.sign_transaction(context.alert_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:
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):
self.create_download_dialog(context)

View File

@@ -89,6 +89,7 @@ class TimelockRecoveryContext:
outputs=alert_tx_outputs,
fee_policy=fee_policy,
is_sweep=False,
locktime=self.alert_tx.locktime if self.alert_tx else None,
)
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],
fee_policy=fee_policy,
is_sweep=False,
locktime=self.recovery_tx.locktime if self.recovery_tx else None,
)
def add_input_info(self):
self.recovery_tx.inputs()[0].utxo = self.alert_tx
if self.cancellation_tx:
def add_input_info_to_recovery_tx(self):
if self.recovery_tx and self.alert_tx.is_complete():
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
def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction':
@@ -143,6 +148,7 @@ class TimelockRecoveryContext:
],
fee_policy=fee_policy,
is_sweep=False,
locktime=self.cancellation_tx.locktime if self.cancellation_tx else None,
)
class TimelockRecoveryPlugin(BasePlugin):