diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 15a4fa91a63e..1e97d27ccecb 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1181,16 +1181,20 @@ def show_transaction( 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, ) diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index e3c4e914d5ad..1e0bbe2cc2ca 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -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 @@ def __init__( *, 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 @@ def __init__( 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 @@ def closeEvent(self, event): 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 @@ def show_qr(self, *, tx: Transaction = None): 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() diff --git a/electrum/plugins/psbt_nostr/qt.py b/electrum/plugins/psbt_nostr/qt.py index b86c13e64f57..dc9f86f71b3a 100644 --- a/electrum/plugins/psbt_nostr/qt.py +++ b/electrum/plugins/psbt_nostr/qt.py @@ -145,7 +145,7 @@ def on_receive(self, pubkey, event_id, tx, label): 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): diff --git a/electrum/plugins/timelock_recovery/qt.py b/electrum/plugins/timelock_recovery/qt.py index 7a7dc032cb83..79655c1c851d 100644 --- a/electrum/plugins/timelock_recovery/qt.py +++ b/electrum/plugins/timelock_recovery/qt.py @@ -35,7 +35,7 @@ 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 @@ def create_plan_dialog(self, context: TimelockRecoveryContext) -> bool: 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 @@ def update_transactions(): 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 @@ def update_transactions(): 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 @@ def update_transactions(): 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 @@ def start_plan(self, context: TimelockRecoveryContext): 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) diff --git a/electrum/plugins/timelock_recovery/timelock_recovery.py b/electrum/plugins/timelock_recovery/timelock_recovery.py index 1368e8c0d151..27cb679479fa 100644 --- a/electrum/plugins/timelock_recovery/timelock_recovery.py +++ b/electrum/plugins/timelock_recovery/timelock_recovery.py @@ -89,6 +89,7 @@ def make_unsigned_alert_tx(self, fee_policy) -> 'PartialTransaction': 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 @@ def make_unsigned_recovery_tx(self, fee_policy) -> 'PartialTransaction': 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 @@ def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction': ], fee_policy=fee_policy, is_sweep=False, + locktime=self.cancellation_tx.locktime if self.cancellation_tx else None, ) class TimelockRecoveryPlugin(BasePlugin):