diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index c2240d54d..7e8b086ad 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -18,11 +18,16 @@ Pane { property alias label: txdetails.label signal detailsChanged + signal closed function close() { app.stack.pop() } + StackView.onRemoved: { + closed() + } + ColumnLayout { anchors.fill: parent spacing: 0 diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 64abfcb28..109a90c28 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -28,7 +28,7 @@ import concurrent.futures import copy import datetime import time -from typing import TYPE_CHECKING, Optional, List, Union, Mapping +from typing import TYPE_CHECKING, Optional, List, Union, Mapping, Callable from functools import partial from decimal import Decimal @@ -410,6 +410,7 @@ def show_transaction( prompt_if_unsaved: bool = False, external_keypairs: Mapping[bytes, bytes] = None, payment_identifier: 'PaymentIdentifier' = None, + on_closed: Callable[[], None] = None, ): try: d = TxDialog( @@ -418,6 +419,7 @@ def show_transaction( prompt_if_unsaved=prompt_if_unsaved, external_keypairs=external_keypairs, payment_identifier=payment_identifier, + on_closed=on_closed, ) except SerializationError as e: _logger.exception('unable to deserialize the transaction') @@ -438,6 +440,7 @@ class TxDialog(QDialog, MessageBoxMixin): prompt_if_unsaved: bool, external_keypairs: Mapping[bytes, bytes] = None, payment_identifier: 'PaymentIdentifier' = None, + on_closed: Callable[[], None] = None, ): '''Transactions in the wallet will show their description. Pass desc to give a description for txs not yet in the wallet. @@ -451,6 +454,7 @@ class TxDialog(QDialog, MessageBoxMixin): self.wallet = parent.wallet self.payment_identifier = payment_identifier self.prompt_if_unsaved = prompt_if_unsaved + self.on_closed = on_closed self.saved = False self.desc = None if txid := tx.txid(): @@ -608,6 +612,9 @@ class TxDialog(QDialog, MessageBoxMixin): self._fetch_txin_data_fut.cancel() self._fetch_txin_data_fut = None + if self.on_closed: + self.on_closed() + def reject(self): # Override escape-key to close normally (and invoke closeEvent) self.close() diff --git a/electrum/plugins/psbt_nostr/psbt_nostr.py b/electrum/plugins/psbt_nostr/psbt_nostr.py index 2240b06e2..d180ef33f 100644 --- a/electrum/plugins/psbt_nostr/psbt_nostr.py +++ b/electrum/plugins/psbt_nostr/psbt_nostr.py @@ -79,11 +79,14 @@ class CosignerWallet(Logger): def __init__(self, wallet: 'Multisig_Wallet'): assert isinstance(wallet, Multisig_Wallet) self.wallet = wallet - self.network = wallet.network - self.config = self.wallet.config Logger.__init__(self) + + self.network = wallet.network + self.config = self.wallet.config + self.pending = asyncio.Event() self.known_events = wallet.db.get_dict('cosigner_events') + for k, v in list(self.known_events.items()): if v < now() - self.KEEP_DELAY: self.logger.info(f'deleting old event {k}') @@ -189,6 +192,8 @@ class CosignerWallet(Logger): return self.logger.info(f"received PSBT from {event.pubkey}") trigger_callback('psbt_nostr_received', self.wallet, event.pubkey, event.id, tx) + await self.pending.wait() + self.pending.clear() def diagnostic_name(self): return self.wallet.diagnostic_name() @@ -203,8 +208,10 @@ class CosignerWallet(Logger): # note that tx could also be unrelated from wallet?... (not ismine inputs) return True - def mark_event_rcvd(self, event_id): + def mark_pending_event_rcvd(self, event_id): + self.logger.debug('marking event rcvd') self.known_events[event_id] = now() + self.pending.set() def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]: messages = [] @@ -223,3 +230,19 @@ class CosignerWallet(Logger): def on_receive(self, pubkey, event_id, tx): raise NotImplementedError() + + def add_transaction_to_wallet(self, tx, *, on_failure=None, on_success=None): + try: + # TODO: adding tx should be handled more gracefully here: + # 1) don't replace tx with same tx with less signatures + # 2) we could combine signatures if tx will become more complete + # 3) ... more heuristics? + if not self.wallet.adb.add_transaction(tx): + # TODO: instead of bool return value, we could use specific fail reason exceptions here + raise Exception('transaction was not added') + except Exception as e: + if on_failure: + on_failure(str(e)) + else: + if on_success: + on_success() diff --git a/electrum/plugins/psbt_nostr/qml.py b/electrum/plugins/psbt_nostr/qml.py index 735843a9c..1818eb59c 100644 --- a/electrum/plugins/psbt_nostr/qml.py +++ b/electrum/plugins/psbt_nostr/qml.py @@ -36,12 +36,14 @@ from electrum.util import EventListener, event_listener from electrum.gui.qml.qewallet import QEWallet -from .psbt_nostr import PsbtNostrPlugin, CosignerWallet +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet from electrum.gui.qml import ElectrumQmlApplication +USER_PROMPT_COOLDOWN = 10 + class QReceiveSignalObject(QObject): def __init__(self, plugin: 'Plugin'): @@ -70,6 +72,13 @@ class QReceiveSignalObject(QObject): return cosigner_wallet.accept_psbt(event_id) + @pyqtSlot(QEWallet, str) + def rejectPsbt(self, wallet: 'QEWallet', event_id: str): + cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet] + if not cosigner_wallet: + return + cosigner_wallet.reject_psbt(event_id) + class Plugin(PsbtNostrPlugin): def __init__(self, parent, config, name): @@ -103,13 +112,18 @@ class QmlCosignerWallet(EventListener, CosignerWallet): self.plugin = plugin self.register_callbacks() - self.pending = None + self.tx = None + self.user_prompt_cooldown = None @event_listener - def on_event_psbt_nostr_received(self, wallet, pubkey, event, tx: 'PartialTransaction'): + def on_event_psbt_nostr_received(self, wallet, pubkey, event_id, tx: 'PartialTransaction'): if self.wallet == wallet: - self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event, tx.serialize()) - self.on_receive(pubkey, event, tx) + self.tx = tx + if not (self.user_prompt_cooldown and self.user_prompt_cooldown > now()): + self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event_id, tx.serialize()) + else: + self.mark_pending_event_rcvd(event_id) + self.add_transaction_to_wallet(self.tx, on_failure=self.on_add_fail) def close(self): super().close() @@ -133,11 +147,13 @@ class QmlCosignerWallet(EventListener, CosignerWallet): except Exception as e: self.plugin.so.sendPsbtFailed.emit(str(e)) - def on_receive(self, pubkey, event_id, tx): - self.pending = (pubkey, event_id, tx) + def accept_psbt(self, event_id): + self.mark_pending_event_rcvd(event_id) - def accept_psbt(self, my_event_id): - pubkey, event_id, tx = self.pending - if event_id == my_event_id: - self.mark_event_rcvd(event_id) - self.pending = None + def reject_psbt(self, event_id): + self.user_prompt_cooldown = now() + USER_PROMPT_COOLDOWN + self.mark_pending_event_rcvd(event_id) + self.add_transaction_to_wallet(self.tx, on_failure=self.on_add_fail) + + def on_add_fail(self): + self.logger.error('failed to add tx to wallet') diff --git a/electrum/plugins/psbt_nostr/qml/main.qml b/electrum/plugins/psbt_nostr/qml/main.qml index a50440620..b54ceee83 100644 --- a/electrum/plugins/psbt_nostr/qml/main.qml +++ b/electrum/plugins/psbt_nostr/qml/main.qml @@ -16,10 +16,15 @@ Item { yesno: true }) dialog.accepted.connect(function () { - app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), { + var page = app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), { rawtx: tx }) - target.acceptPsbt(Daemon.currentWallet, event) + page.closed.connect(function () { + target.acceptPsbt(Daemon.currentWallet, event) + }) + }) + dialog.rejected.connect(function () { + target.rejectPsbt(Daemon.currentWallet, event) }) dialog.open() } diff --git a/electrum/plugins/psbt_nostr/qt.py b/electrum/plugins/psbt_nostr/qt.py index e50ca4980..abe696c40 100644 --- a/electrum/plugins/psbt_nostr/qt.py +++ b/electrum/plugins/psbt_nostr/qt.py @@ -23,6 +23,7 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import asyncio +from functools import partial from typing import TYPE_CHECKING, List, Tuple, Optional from PyQt6.QtCore import QObject, pyqtSignal @@ -34,11 +35,13 @@ from electrum.wallet import Multisig_Wallet, Abstract_Wallet from electrum.util import UserCancelled, event_listener, EventListener from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog -from .psbt_nostr import PsbtNostrPlugin, CosignerWallet +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now if TYPE_CHECKING: from electrum.gui.qt.main_window import ElectrumWindow +USER_PROMPT_COOLDOWN = 10 + class QReceiveSignalObject(QObject): cosignerReceivedPsbt = pyqtSignal(str, str, object) @@ -80,6 +83,7 @@ class QtCosignerWallet(EventListener, CosignerWallet): self.obj = QReceiveSignalObject() self.obj.cosignerReceivedPsbt.connect(self.on_receive) self.register_callbacks() + self.user_prompt_cooldown = None def close(self): super().close() @@ -92,7 +96,7 @@ class QtCosignerWallet(EventListener, CosignerWallet): def hook_transaction_dialog(self, d: 'TxDialog'): d.cosigner_send_button = b = QPushButton(_("Send to cosigner")) - b.clicked.connect(lambda: self.send_psbt(d.tx)) + b.clicked.connect(lambda: self.send_to_cosigners(d.tx)) d.buttons.insert(0, b) b.setVisible(False) @@ -108,6 +112,14 @@ class QtCosignerWallet(EventListener, CosignerWallet): else: d.cosigner_send_button.setVisible(False) + def send_to_cosigners(self, tx): + def ok(): + self.logger.debug('ADDED') + def nok(msg: str): + self.logger.debug(f'NOT ADDED: {msg}') + self.add_transaction_to_wallet(tx, on_success=ok, on_failure=nok) + self.send_psbt(tx) + def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None): if not messages: return @@ -127,10 +139,21 @@ class QtCosignerWallet(EventListener, CosignerWallet): _("Your transaction was sent to your cosigners via Nostr.") + '\n\n' + txid) def on_receive(self, pubkey, event_id, tx): - window = self.window - if not window.question( - _("An transaction was received from your cosigner.") + '\n' + - _("Do you want to open it now?")): - return - self.mark_event_rcvd(event_id) - show_transaction(tx, parent=window, prompt_if_unsaved=True) + open_now = False + if not (self.user_prompt_cooldown and self.user_prompt_cooldown > now()): + open_now = self.window.question( + _("A transaction was received from your cosigner ({}).").format(str(event_id)[0:8]) + '\n' + + _("Do you want to open it now?")) + if not open_now: + self.user_prompt_cooldown = now() + USER_PROMPT_COOLDOWN + if open_now: + show_transaction(tx, parent=self.window, prompt_if_unsaved=True, on_closed=partial(self.on_tx_dialog_closed, event_id)) + else: + self.mark_pending_event_rcvd(event_id) + self.add_transaction_to_wallet(tx, on_failure=self.on_add_fail) + + def on_tx_dialog_closed(self, event_id): + self.mark_pending_event_rcvd(event_id) + + def on_add_fail(self, msg: str): + self.window.show_error(msg)