From 13a4076f2231fa192657769726848e96b097d9f4 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 3 Apr 2025 17:04:26 +0200 Subject: [PATCH 1/4] plugins: psbt_nostr: split generic and UI parts --- electrum/plugins/psbt_nostr/psbt_nostr.py | 222 ++++++++++++++++++++++ electrum/plugins/psbt_nostr/qml.py | 73 +++++++ electrum/plugins/psbt_nostr/qt.py | 209 +++----------------- 3 files changed, 325 insertions(+), 179 deletions(-) create mode 100644 electrum/plugins/psbt_nostr/psbt_nostr.py create mode 100644 electrum/plugins/psbt_nostr/qml.py diff --git a/electrum/plugins/psbt_nostr/psbt_nostr.py b/electrum/plugins/psbt_nostr/psbt_nostr.py new file mode 100644 index 000000000..e850149c6 --- /dev/null +++ b/electrum/plugins/psbt_nostr/psbt_nostr.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2025 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import asyncio +import ssl +import time +from contextlib import asynccontextmanager + +import electrum_ecc as ecc +import electrum_aionostr as aionostr +from electrum_aionostr.key import PrivateKey +from typing import Dict, TYPE_CHECKING, Union, List, Tuple, Optional + +from electrum import util, Transaction +from electrum.crypto import sha256 +from electrum.i18n import _ +from electrum.logging import Logger +from electrum.plugin import BasePlugin +from electrum.transaction import PartialTransaction, tx_from_any +from electrum.util import log_exceptions, OldTaskGroup, ca_path, trigger_callback +from electrum.wallet import Multisig_Wallet + +if TYPE_CHECKING: + from electrum.wallet import Abstract_Wallet + +# event kind used for nostr messages (with expiration tag) +NOSTR_EVENT_KIND = 4 + +now = lambda: int(time.time()) + + +class PsbtNostrPlugin(BasePlugin): + + def __init__(self, parent, config, name): + BasePlugin.__init__(self, parent, config, name) + self.cosigner_wallets = {} # type: Dict[Abstract_Wallet, CosignerWallet] + + def is_available(self): + return True + + def add_cosigner_wallet(self, wallet: 'Abstract_Wallet', cosigner_wallet: 'CosignerWallet'): + assert isinstance(wallet, Multisig_Wallet) + self.cosigner_wallets[wallet] = cosigner_wallet + + def remove_cosigner_wallet(self, wallet: 'Abstract_Wallet'): + if cw := self.cosigner_wallets.get(wallet): + cw.close() + self.cosigner_wallets.pop(wallet) + + +class CosignerWallet(Logger): + # one for each open window (Qt) / open wallet (QML) + # if user signs a tx, we have the password + # if user receives a dm? needs to enter password first + + KEEP_DELAY = 24*60*60 + + 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.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}') + self.known_events.pop(k) + self.relays = self.config.NOSTR_RELAYS.split(',') + self.ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path) + self.logger.info(f'relays {self.relays}') + + self.cosigner_list = [] # type: List[Tuple[str, str]] + self.nostr_pubkey = None + + for key, keystore in wallet.keystores.items(): + xpub = keystore.get_master_public_key() # type: str + privkey = sha256('nostr_psbt:' + xpub) + pubkey = ecc.ECPrivkey(privkey).get_public_key_bytes()[1:] + if self.nostr_pubkey is None and not keystore.is_watching_only(): + self.nostr_privkey = privkey.hex() + self.nostr_pubkey = pubkey.hex() + self.logger.info(f'nostr pubkey: {self.nostr_pubkey}') + else: + self.cosigner_list.append((xpub, pubkey.hex())) + + self.messages = asyncio.Queue() + self.taskgroup = OldTaskGroup() + if self.network and self.nostr_pubkey: + asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop) + + @log_exceptions + async def main_loop(self): + self.logger.info("starting taskgroup.") + try: + async with self.taskgroup as group: + await group.spawn(self.check_direct_messages()) + except Exception as e: + self.logger.exception("taskgroup died.") + finally: + self.logger.info("taskgroup stopped.") + + async def stop(self): + await self.taskgroup.cancel_remaining() + + @asynccontextmanager + async def nostr_manager(self): + manager_logger = self.logger.getChild('aionostr') + manager_logger.setLevel("INFO") # set to INFO because DEBUG is very spammy + async with aionostr.Manager( + relays=self.relays, + private_key=self.nostr_privkey, + ssl_context=self.ssl_context, + # todo: add proxy support, first needs: + # https://github.com/spesmilo/electrum-aionostr/pull/8 + proxy=None, + log=manager_logger + ) as manager: + yield manager + + @log_exceptions + async def send_direct_messages(self, messages: List[Tuple[str, str]]): + our_private_key: PrivateKey = aionostr.key.PrivateKey(bytes.fromhex(self.nostr_privkey)) + async with self.nostr_manager() as manager: + for pubkey, msg in messages: + encrypted_msg: str = our_private_key.encrypt_message(msg, pubkey) + eid = await aionostr._add_event( + manager, + kind=NOSTR_EVENT_KIND, + content=encrypted_msg, + private_key=self.nostr_privkey, + tags=[['p', pubkey], ['expiration', str(int(now() + self.KEEP_DELAY))]]) + self.logger.info(f'message sent to {pubkey}: {eid}') + + @log_exceptions + async def check_direct_messages(self): + privkey = PrivateKey(bytes.fromhex(self.nostr_privkey)) + async with self.nostr_manager() as manager: + await manager.connect() + query = { + "kinds": [NOSTR_EVENT_KIND], + "limit": 100, + "#p": [self.nostr_pubkey], + "since": int(now() - self.KEEP_DELAY), + } + async for event in manager.get_events(query, single_event=False, only_stored=False): + if event.id in self.known_events: + self.logger.info(f'known event {event.id} {util.age(event.created_at)}') + continue + if event.created_at > now() + self.KEEP_DELAY: + # might be malicious + continue + if event.created_at < now() - self.KEEP_DELAY: + continue + self.logger.info(f'new event {event.id}') + try: + message = privkey.decrypt_message(event.content, event.pubkey) + except Exception as e: + self.logger.info(f'could not decrypt message {event.pubkey}') + self.known_events[event.id] = now() + continue + try: + tx = tx_from_any(message) + except Exception as e: + self.logger.info(_("Unable to deserialize the transaction:") + "\n" + str(e)) + self.known_events[event.id] = now() + return + self.logger.info(f"received PSBT from {event.pubkey}") + trigger_callback('psbt_nostr_received', self.wallet, event.pubkey, event.id, tx) + + def diagnostic_name(self): + return self.wallet.diagnostic_name() + + def close(self): + self.logger.info("shutting down listener") + asyncio.run_coroutine_threadsafe(self.stop(), self.network.asyncio_loop) + + def cosigner_can_sign(self, tx: Transaction, cosigner_xpub: str) -> bool: + # TODO implement this properly: + # should return True iff cosigner (with given xpub) can sign and has not yet signed. + # note that tx could also be unrelated from wallet?... (not ismine inputs) + return True + + def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]: + messages = [] + for xpub, pubkey in self.cosigner_list: + if not self.cosigner_can_sign(tx, xpub): + continue + raw_tx_bytes = tx.serialize_as_bytes() + messages.append((pubkey, raw_tx_bytes.hex())) + return messages + + def send_psbt(self, tx: Union[Transaction, PartialTransaction]): + self.do_send(self.prepare_messages(tx), tx.txid()) + + def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None): + raise NotImplementedError() + + def on_receive(self, pubkey, event_id, tx): + raise NotImplementedError() diff --git a/electrum/plugins/psbt_nostr/qml.py b/electrum/plugins/psbt_nostr/qml.py new file mode 100644 index 000000000..d58ef0d35 --- /dev/null +++ b/electrum/plugins/psbt_nostr/qml.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2025 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from typing import TYPE_CHECKING + +from electrum.plugin import hook +from electrum.wallet import Multisig_Wallet + +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet + +if TYPE_CHECKING: + from electrum.wallet import Abstract_Wallet + from electrum.gui.qml import ElectrumQmlApplication + + +class Plugin(PsbtNostrPlugin): + def __init__(self, parent, config, name): + super().__init__(parent, config, name) + self._app = None + + @hook + def init_qml(self, app: 'ElectrumQmlApplication'): + # if self._init_qt_received: # only need/want the first signal + # return + # self._init_qt_received = True + self._app = app + # plugin enable for already open wallets + for wallet in app.daemon.get_wallets(): + self.load_wallet(wallet) + + @hook + def load_wallet(self, wallet: 'Abstract_Wallet'): + if not isinstance(wallet, Multisig_Wallet): + return + self.add_cosigner_wallet(wallet, CosignerWallet(wallet)) + + # @hook + # def on_close_window(self, window): + # wallet = window.wallet + # self.remove_cosigner_wallet(wallet) + # + # @hook + # def transaction_dialog(self, d: 'TxDialog'): + # if cw := self.cosigner_wallets.get(d.wallet): + # assert isinstance(cw, QtCosignerWallet) + # cw.hook_transaction_dialog(d) + # + # @hook + # def transaction_dialog_update(self, d: 'TxDialog'): + # if cw := self.cosigner_wallets.get(d.wallet): + # assert isinstance(cw, QtCosignerWallet) + # cw.hook_transaction_dialog_update(d) diff --git a/electrum/plugins/psbt_nostr/qt.py b/electrum/plugins/psbt_nostr/qt.py index 53926ec8b..36e91f265 100644 --- a/electrum/plugins/psbt_nostr/qt.py +++ b/electrum/plugins/psbt_nostr/qt.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # Electrum - lightweight Bitcoin client -# Copyright (C) 2014 The Electrum Developers +# Copyright (C) 2025 The Electrum Developers # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -23,213 +23,77 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import asyncio -import time -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Union, List, Tuple, Dict, Optional -import ssl -import json +from typing import TYPE_CHECKING, List, Tuple, Optional from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtWidgets import QPushButton -import electrum_ecc as ecc -import electrum_aionostr as aionostr -from electrum_aionostr.key import PrivateKey - -from electrum.crypto import sha256 -from electrum import util -from electrum.transaction import Transaction, PartialTransaction, tx_from_any, SerializationError -from electrum.bip32 import BIP32Node -from electrum.plugin import BasePlugin, hook +from electrum.plugin import hook from electrum.i18n import _ from electrum.wallet import Multisig_Wallet, Abstract_Wallet -from electrum.logging import Logger -from electrum.network import Network -from electrum.util import log_exceptions, OldTaskGroup, UserCancelled, ca_path +from electrum.util import UserCancelled, event_listener, EventListener from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog -from electrum.gui.qt.util import WaitingDialog + +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now if TYPE_CHECKING: from electrum.gui.qt import ElectrumGui from electrum.gui.qt.main_window import ElectrumWindow -# event kind used for nostr messages (with expiration tag) -NOSTR_EVENT_KIND = 4 - -now = lambda: int(time.time()) class QReceiveSignalObject(QObject): - cosigner_receive_signal = pyqtSignal(object, object, object) + cosignerReceivedPsbt = pyqtSignal(str, str, object) -class Plugin(BasePlugin): - +class Plugin(PsbtNostrPlugin): def __init__(self, parent, config, name): - BasePlugin.__init__(self, parent, config, name) + super().__init__(parent, config, name) self._init_qt_received = False - self.cosigner_wallets = {} # type: Dict[Abstract_Wallet, CosignerWallet] - @hook def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'): - if type(wallet) != Multisig_Wallet: + if not isinstance(wallet, Multisig_Wallet): return - self.cosigner_wallets[wallet] = CosignerWallet(wallet, window) + self.add_cosigner_wallet(wallet, QtCosignerWallet(wallet, window)) @hook def on_close_window(self, window): wallet = window.wallet - if cw := self.cosigner_wallets.get(wallet): - cw.close() - self.cosigner_wallets.pop(wallet) - - def is_available(self): - return True + self.remove_cosigner_wallet(wallet) @hook def transaction_dialog(self, d: 'TxDialog'): if cw := self.cosigner_wallets.get(d.wallet): + assert isinstance(cw, QtCosignerWallet) cw.hook_transaction_dialog(d) @hook def transaction_dialog_update(self, d: 'TxDialog'): if cw := self.cosigner_wallets.get(d.wallet): + assert isinstance(cw, QtCosignerWallet) cw.hook_transaction_dialog_update(d) -class CosignerWallet(Logger): - # one for each open window - # if user signs a tx, we have the password - # if user receives a dm? needs to enter password first - - KEEP_DELAY = 24*60*60 - +class QtCosignerWallet(EventListener, CosignerWallet): def __init__(self, wallet: 'Multisig_Wallet', window: 'ElectrumWindow'): - assert isinstance(wallet, Multisig_Wallet) - self.wallet = wallet - self.network = window.network - self.config = self.wallet.config + CosignerWallet.__init__(self, wallet) self.window = window - - Logger.__init__(self) - 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}') - self.known_events.pop(k) - self.relays = self.config.NOSTR_RELAYS.split(',') - self.ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path) - self.logger.info(f'relays {self.relays}') self.obj = QReceiveSignalObject() - self.obj.cosigner_receive_signal.connect(self.on_receive) - - self.cosigner_list = [] # type: List[Tuple[str, bytes, str]] - self.nostr_pubkey = None - - for key, keystore in wallet.keystores.items(): - xpub = keystore.get_master_public_key() # type: str - privkey = sha256('nostr_psbt:' + xpub) - pubkey = ecc.ECPrivkey(privkey).get_public_key_bytes()[1:] - if self.nostr_pubkey is None and not keystore.is_watching_only(): - self.nostr_privkey = privkey.hex() - self.nostr_pubkey = pubkey.hex() - self.logger.info(f'nostr pubkey: {self.nostr_pubkey}') - else: - self.cosigner_list.append((xpub, pubkey.hex())) - - self.messages = asyncio.Queue() - self.taskgroup = OldTaskGroup() - if self.network and self.nostr_pubkey: - asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop) - - @log_exceptions - async def main_loop(self): - self.logger.info("starting taskgroup.") - try: - async with self.taskgroup as group: - await group.spawn(self.check_direct_messages()) - except Exception as e: - self.logger.exception("taskgroup died.") - finally: - self.logger.info("taskgroup stopped.") - - async def stop(self): - await self.taskgroup.cancel_remaining() - - @asynccontextmanager - async def nostr_manager(self): - manager_logger = self.logger.getChild('aionostr') - manager_logger.setLevel("INFO") # set to INFO because DEBUG is very spammy - async with aionostr.Manager( - relays=self.relays, - private_key=self.nostr_privkey, - ssl_context=self.ssl_context, - # todo: add proxy support, first needs: - # https://github.com/spesmilo/electrum-aionostr/pull/8 - proxy=None, - log=manager_logger - ) as manager: - yield manager - - @log_exceptions - async def send_direct_messages(self, messages: List[Tuple[str, str]]): - our_private_key: PrivateKey = aionostr.key.PrivateKey(bytes.fromhex(self.nostr_privkey)) - async with self.nostr_manager() as manager: - for pubkey, msg in messages: - encrypted_msg: str = our_private_key.encrypt_message(msg, pubkey) - eid = await aionostr._add_event( - manager, - kind=NOSTR_EVENT_KIND, - content=encrypted_msg, - private_key=self.nostr_privkey, - tags=[['p', pubkey], ['expiration', str(int(now() + self.KEEP_DELAY))]]) - self.logger.info(f'message sent to {pubkey}: {eid}') - - @log_exceptions - async def check_direct_messages(self): - privkey = PrivateKey(bytes.fromhex(self.nostr_privkey)) - async with self.nostr_manager() as manager: - query = { - "kinds": [NOSTR_EVENT_KIND], - "limit":100, - "#p": [self.nostr_pubkey], - "since": int(now() - self.KEEP_DELAY) - } - async for event in manager.get_events(query, single_event=False, only_stored=False): - if event.id in self.known_events: - self.logger.info(f'known event {event.id} {util.age(event.created_at)}') - continue - if event.created_at > now() + self.KEEP_DELAY: - # might be malicious - continue - if event.created_at < now() - self.KEEP_DELAY: - continue - self.logger.info(f'new event {event.id}') - try: - message = privkey.decrypt_message(event.content, event.pubkey) - except Exception as e: - self.logger.info(f'could not decrypt message {event.pubkey}') - self.known_events[event.id] = now() - continue - try: - tx = tx_from_any(message) - except Exception as e: - self.logger.info(_("Unable to deserialize the transaction:") + "\n" + str(e)) - self.known_events[event.id] = now() - return - self.logger.info(f"received PSBT from {event.pubkey}") - self.obj.cosigner_receive_signal.emit(event.pubkey, event.id, tx) - - def diagnostic_name(self): - return self.wallet.diagnostic_name() + self.obj.cosignerReceivedPsbt.connect(self.on_receive) + self.register_callbacks() def close(self): - self.logger.info("shutting down listener") - asyncio.run_coroutine_threadsafe(self.stop(), self.network.asyncio_loop) + super().close() + self.unregister_callbacks() + + @event_listener + def on_event_psbt_nostr_received(self, wallet, *args): + if self.wallet == wallet: + self.obj.cosignerReceivedPsbt.emit(*args) # put on UI thread via signal def hook_transaction_dialog(self, d: 'TxDialog'): d.cosigner_send_button = b = QPushButton(_("Send to cosigner")) - b.clicked.connect(lambda: self.do_send(d.tx)) + b.clicked.connect(lambda: self.send_psbt(d.tx)) d.buttons.insert(0, b) b.setVisible(False) @@ -245,22 +109,10 @@ class CosignerWallet(Logger): else: d.cosigner_send_button.setVisible(False) - def cosigner_can_sign(self, tx: Transaction, cosigner_xpub: str) -> bool: - # TODO implement this properly: - # should return True iff cosigner (with given xpub) can sign and has not yet signed. - # note that tx could also be unrelated from wallet?... (not ismine inputs) - return True - - def do_send(self, tx: Union[Transaction, PartialTransaction]): - buffer = [] - for xpub, pubkey in self.cosigner_list: - if not self.cosigner_can_sign(tx, xpub): - continue - raw_tx_bytes = tx.serialize_as_bytes() - buffer.append((pubkey, raw_tx_bytes.hex())) - if not buffer: + def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None): + if not messages: return - coro = self.send_direct_messages(buffer) + coro = self.send_direct_messages(messages) text = _('Sending transaction to your Nostr relays...') try: result = self.window.run_coroutine_dialog(coro, text) @@ -273,8 +125,7 @@ class CosignerWallet(Logger): self.window.show_error(str(e)) return self.window.show_message( - _("Your transaction was sent to your cosigners via Nostr.") + '\n\n' + tx.txid()) - + _("Your transaction was sent to your cosigners via Nostr.") + '\n\n' + txid) def on_receive(self, pubkey, event_id, tx): window = self.window From 3ff84f08a6026fcd4bacde24463b1ba459386fad Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 4 Apr 2025 16:44:44 +0200 Subject: [PATCH 2/4] plugins: psbt_nostr: implement for qml --- .../gui/qml/components/ExportTxDialog.qml | 12 ++ .../components/controls/ButtonContainer.qml | 8 +- electrum/gui/qml/components/main.qml | 39 ++++++ electrum/gui/qml/qeapp.py | 16 ++- electrum/plugins/psbt_nostr/manifest.json | 2 +- electrum/plugins/psbt_nostr/psbt_nostr.py | 3 + electrum/plugins/psbt_nostr/qml.py | 116 ++++++++++++++---- electrum/plugins/psbt_nostr/qml/main.qml | 54 ++++++++ electrum/plugins/psbt_nostr/qt.py | 5 +- 9 files changed, 221 insertions(+), 34 deletions(-) create mode 100644 electrum/plugins/psbt_nostr/qml/main.qml diff --git a/electrum/gui/qml/components/ExportTxDialog.qml b/electrum/gui/qml/components/ExportTxDialog.qml index 82850aa60..62b4291bf 100644 --- a/electrum/gui/qml/components/ExportTxDialog.qml +++ b/electrum/gui/qml/components/ExportTxDialog.qml @@ -76,6 +76,7 @@ ElDialog { } ButtonContainer { + id: buttons Layout.fillWidth: true FlatButton { @@ -97,6 +98,17 @@ ElDialog { AppController.doShare(dialog.text, dialog.title) } } + function beforeLayout() { + var export_tx_buttons = app.pluginsComponentsByName('export_tx_button') + for (var i=0; i < export_tx_buttons.length; i++) { + var b = export_tx_buttons[i].createObject(buttons, { + dialog: dialog + }) + b.Layout.fillWidth = true + b.Layout.preferredWidth = 1 + buttons.addItem(b) + } + } } } diff --git a/electrum/gui/qml/components/controls/ButtonContainer.qml b/electrum/gui/qml/components/controls/ButtonContainer.qml index 5a3d18c43..527897c0b 100644 --- a/electrum/gui/qml/components/controls/ButtonContainer.qml +++ b/electrum/gui/qml/components/controls/ButtonContainer.qml @@ -35,7 +35,13 @@ Container { contentItem = contentRoot } - Component.onCompleted: fillContentItem() + // override this function to dynamically add buttons. + function beforeLayout() {} + + Component.onCompleted: { + beforeLayout() + fillContentItem() + } Component { id: containerLayout diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 14df2cf4b..203bf20dc 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -39,6 +39,8 @@ ApplicationWindow property var _exceptionDialog + property var pluginobjects: ({}) + property QtObject appMenu: Menu { id: menu @@ -640,6 +642,43 @@ ApplicationWindow }) app._exceptionDialog.open() } + function onPluginLoaded(name) { + console.log('plugin ' + name + ' loaded') + var loader = AppController.plugin(name).loader + if (loader == undefined) + return + var url = Qt.resolvedUrl('../../../plugins/' + name + '/qml/' + loader) + var comp = Qt.createComponent(url) + if (comp.status == Component.Error) { + console.log('Could not find/parse PluginLoader for plugin ' + name) + console.log(comp.errorString()) + return + } + var obj = comp.createObject(app) + if (obj != null) + app.pluginobjects[name] = obj + } + } + + function pluginsComponentsByName(comp_name) { + // return named QML components from plugins + var plugins = AppController.plugins + var result = [] + for (var i=0; i < plugins.length; i++) { + if (!plugins[i].enabled) + continue + var pluginobject = app.pluginobjects[plugins[i].name] + if (!pluginobject) + continue + if (!(comp_name in pluginobject)) + continue + var comp = pluginobject[comp_name] + if (!comp) + continue + + result.push(comp) + } + return result } Connections { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index bb8587824..3efc496d2 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -74,6 +74,8 @@ class QEAppController(BaseCrashReporter, QObject): sendingBugreportFailure = pyqtSignal(str) secureWindowChanged = pyqtSignal() wantCloseChanged = pyqtSignal() + pluginLoaded = pyqtSignal(str) + startupFinished = pyqtSignal() def __init__(self, qeapp: 'ElectrumQmlApplication', plugins: 'Plugins'): BaseCrashReporter.__init__(self, None, None, None) @@ -229,8 +231,11 @@ class QEAppController(BaseCrashReporter, QObject): if scheme == BITCOIN_BIP21_URI_SCHEME or scheme == LIGHTNING_URI_SCHEME: self.uriReceived.emit(data) - def startupFinished(self): + def startup_finished(self): self._app_started = True + self.startupFinished.emit() + for plugin_name in self._plugins.plugins.keys(): + self.pluginLoaded.emit(plugin_name) if self._intent: self.on_new_intent(self._intent) @@ -304,18 +309,16 @@ class QEAppController(BaseCrashReporter, QObject): self.logger.debug('None!') return None - @pyqtProperty('QVariant', notify=_dummy) + @pyqtProperty('QVariantList', notify=_dummy) def plugins(self): s = [] for item in self._plugins.descriptions: - self.logger.info(item) s.append({ 'name': item, 'fullname': self._plugins.descriptions[item]['fullname'], 'enabled': bool(self._plugins.get(item)) }) - self.logger.debug(f'{str(s)}') return s @pyqtSlot(str, bool) @@ -511,10 +514,11 @@ class ElectrumQmlApplication(QGuiApplication): # slot is called after loading root QML. If object is None, it has failed. @pyqtSlot('QObject*', 'QUrl') def objectCreated(self, object, url): + self.engine.objectCreated.disconnect(self.objectCreated) if object is None: self._valid = False - self.engine.objectCreated.disconnect(self.objectCreated) - self.appController.startupFinished() + else: + self.appController.startup_finished() def message_handler(self, line, funct, file): # filter out common harmless messages diff --git a/electrum/plugins/psbt_nostr/manifest.json b/electrum/plugins/psbt_nostr/manifest.json index 9d0c60f98..702d13ca1 100644 --- a/electrum/plugins/psbt_nostr/manifest.json +++ b/electrum/plugins/psbt_nostr/manifest.json @@ -3,7 +3,7 @@ "fullname": "Nostr Multisig", "description": "This plugin facilitates the use of multi-signatures wallets. It sends and receives partially signed transactions from/to your cosigner wallet. PSBTs are sent and retrieved from Nostr relays.", "author": "The Electrum Developers", - "available_for": ["qt"], "icon":"nostr_multisig.png", + "available_for": ["qt", "qml"], "version": "0.0.1" } diff --git a/electrum/plugins/psbt_nostr/psbt_nostr.py b/electrum/plugins/psbt_nostr/psbt_nostr.py index e850149c6..2240b06e2 100644 --- a/electrum/plugins/psbt_nostr/psbt_nostr.py +++ b/electrum/plugins/psbt_nostr/psbt_nostr.py @@ -203,6 +203,9 @@ class CosignerWallet(Logger): # note that tx could also be unrelated from wallet?... (not ismine inputs) return True + def mark_event_rcvd(self, event_id): + self.known_events[event_id] = now() + def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]: messages = [] for xpub, pubkey in self.cosigner_list: diff --git a/electrum/plugins/psbt_nostr/qml.py b/electrum/plugins/psbt_nostr/qml.py index d58ef0d35..735843a9c 100644 --- a/electrum/plugins/psbt_nostr/qml.py +++ b/electrum/plugins/psbt_nostr/qml.py @@ -22,10 +22,19 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import TYPE_CHECKING +import asyncio +import concurrent +from typing import TYPE_CHECKING, List, Tuple, Optional +from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot + +from electrum import util from electrum.plugin import hook +from electrum.transaction import PartialTransaction, tx_from_any from electrum.wallet import Multisig_Wallet +from electrum.util import EventListener, event_listener + +from electrum.gui.qml.qewallet import QEWallet from .psbt_nostr import PsbtNostrPlugin, CosignerWallet @@ -34,40 +43,101 @@ if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication +class QReceiveSignalObject(QObject): + def __init__(self, plugin: 'Plugin'): + QObject.__init__(self) + self._plugin = plugin + + cosignerReceivedPsbt = pyqtSignal(str, str, str) + sendPsbtFailed = pyqtSignal(str, arguments=['reason']) + sendPsbtSuccess = pyqtSignal() + + @pyqtProperty(str) + def loader(self): + return 'main.qml' + + @pyqtSlot(QEWallet, str) + def sendPsbt(self, wallet: 'QEWallet', tx: str): + cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet] + if not cosigner_wallet: + return + cosigner_wallet.send_psbt(tx_from_any(tx, deserialize=True)) + + @pyqtSlot(QEWallet, str) + def acceptPsbt(self, wallet: 'QEWallet', event_id: str): + cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet] + if not cosigner_wallet: + return + cosigner_wallet.accept_psbt(event_id) + + class Plugin(PsbtNostrPlugin): def __init__(self, parent, config, name): super().__init__(parent, config, name) + self.so = QReceiveSignalObject(self) self._app = None @hook def init_qml(self, app: 'ElectrumQmlApplication'): - # if self._init_qt_received: # only need/want the first signal - # return - # self._init_qt_received = True self._app = app - # plugin enable for already open wallets - for wallet in app.daemon.get_wallets(): + self.so.setParent(app) # parent in QObject tree + # plugin enable for already open wallet + wallet = app.daemon.currentWallet.wallet if app.daemon.currentWallet else None + if wallet: self.load_wallet(wallet) @hook def load_wallet(self, wallet: 'Abstract_Wallet'): + # remove existing, only foreground wallet active + if len(self.cosigner_wallets): + self.remove_cosigner_wallet(self.cosigner_wallets[0]) if not isinstance(wallet, Multisig_Wallet): return - self.add_cosigner_wallet(wallet, CosignerWallet(wallet)) + self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self)) - # @hook - # def on_close_window(self, window): - # wallet = window.wallet - # self.remove_cosigner_wallet(wallet) - # - # @hook - # def transaction_dialog(self, d: 'TxDialog'): - # if cw := self.cosigner_wallets.get(d.wallet): - # assert isinstance(cw, QtCosignerWallet) - # cw.hook_transaction_dialog(d) - # - # @hook - # def transaction_dialog_update(self, d: 'TxDialog'): - # if cw := self.cosigner_wallets.get(d.wallet): - # assert isinstance(cw, QtCosignerWallet) - # cw.hook_transaction_dialog_update(d) + +class QmlCosignerWallet(EventListener, CosignerWallet): + + def __init__(self, wallet: 'Multisig_Wallet', plugin: 'Plugin'): + CosignerWallet.__init__(self, wallet) + self.plugin = plugin + self.register_callbacks() + + self.pending = None + + @event_listener + def on_event_psbt_nostr_received(self, wallet, pubkey, event, tx: 'PartialTransaction'): + if self.wallet == wallet: + self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event, tx.serialize()) + self.on_receive(pubkey, event, tx) + + def close(self): + super().close() + self.unregister_callbacks() + + def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None): + if not messages: + return + coro = self.send_direct_messages(messages) + + loop = util.get_asyncio_loop() + assert util.get_running_loop() != loop, 'must not be called from asyncio thread' + self._result = None + self._future = asyncio.run_coroutine_threadsafe(coro, loop) + + try: + self._result = self._future.result() + self.plugin.so.sendPsbtSuccess.emit() + except concurrent.futures.CancelledError: + pass + 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, my_event_id): + pubkey, event_id, tx = self.pending + if event_id == my_event_id: + self.mark_event_rcvd(event_id) + self.pending = None diff --git a/electrum/plugins/psbt_nostr/qml/main.qml b/electrum/plugins/psbt_nostr/qml/main.qml new file mode 100644 index 000000000..a50440620 --- /dev/null +++ b/electrum/plugins/psbt_nostr/qml/main.qml @@ -0,0 +1,54 @@ +import QtQuick + +import org.electrum + +import "../../../gui/qml/components/controls" + +Item { + Connections { + target: AppController ? AppController.plugin('psbt_nostr') : null + function onCosignerReceivedPsbt(pubkey, event, tx) { + var dialog = app.messageDialog.createObject(app, { + text: [ + qsTr('A transaction was received from your cosigner.'), + qsTr('Do you want to open it now?') + ].join('\n'), + yesno: true + }) + dialog.accepted.connect(function () { + app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), { + rawtx: tx + }) + target.acceptPsbt(Daemon.currentWallet, event) + }) + dialog.open() + } + } + + property variant export_tx_button: Component { + FlatButton { + id: psbt_nostr_send_button + property variant dialog + text: qsTr('Nostr') + icon.source: Qt.resolvedUrl('../../../gui/icons/network.png') + visible: Daemon.currentWallet.isMultisig && Daemon.currentWallet.walletType != '2fa' + onClicked: { + console.log('about to psbt nostr send') + psbt_nostr_send_button.enabled = false + AppController.plugin('psbt_nostr').sendPsbt(Daemon.currentWallet, dialog.text) + } + Connections { + target: AppController ? AppController.plugin('psbt_nostr') : null + function onSendPsbtFailed(message) { + psbt_nostr_send_button.enabled = true + var dialog = app.messageDialog.createObject(app, { + text: qsTr('Sending PSBT to co-signer failed:\n%1').arg(message) + }) + dialog.open() + } + } + + } + } + +} diff --git a/electrum/plugins/psbt_nostr/qt.py b/electrum/plugins/psbt_nostr/qt.py index 36e91f265..e50ca4980 100644 --- a/electrum/plugins/psbt_nostr/qt.py +++ b/electrum/plugins/psbt_nostr/qt.py @@ -34,10 +34,9 @@ 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, now +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet if TYPE_CHECKING: - from electrum.gui.qt import ElectrumGui from electrum.gui.qt.main_window import ElectrumWindow @@ -133,5 +132,5 @@ class QtCosignerWallet(EventListener, CosignerWallet): _("An transaction was received from your cosigner.") + '\n' + _("Do you want to open it now?")): return - self.known_events[event_id] = now() + self.mark_event_rcvd(event_id) show_transaction(tx, parent=window, prompt_if_unsaved=True) From 60bd6327ce552fd45b8a85ca67542891e13733e9 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 15 Apr 2025 13:58:49 +0200 Subject: [PATCH 3/4] plugins: psbt_nostr: let GUI handle a received PSBTs one by one by pausing receiving additional PSBTs until PSBT dialog is closed. Accepting a PSBT opens the Tx dialog and pauses receiving additional PSBTs until the Tx dialog is closed. Rejecting a PSBT will start a cooldown and accept all pending PSBTs into the history for later inspection. --- electrum/gui/qml/components/TxDetails.qml | 5 +++ electrum/gui/qt/transaction_dialog.py | 9 ++++- electrum/plugins/psbt_nostr/psbt_nostr.py | 29 ++++++++++++++-- electrum/plugins/psbt_nostr/qml.py | 40 +++++++++++++++------- electrum/plugins/psbt_nostr/qml/main.qml | 9 +++-- electrum/plugins/psbt_nostr/qt.py | 41 ++++++++++++++++++----- 6 files changed, 106 insertions(+), 27 deletions(-) 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) From 3b97ab740708154323308405154907ff4edc5c41 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 15 Apr 2025 17:19:52 +0200 Subject: [PATCH 4/4] plugins: psbt_nostr: qt: offer 3 choices for each PSBT; 'Open, Discard, Save to wallet' --- electrum/gui/qt/util.py | 83 +++++++++++++++-------- electrum/plugins/psbt_nostr/psbt_nostr.py | 1 + electrum/plugins/psbt_nostr/qt.py | 32 ++++----- 3 files changed, 71 insertions(+), 45 deletions(-) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 6ee320b1d..1410d5324 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -7,7 +7,7 @@ import queue import os import webbrowser from functools import partial, lru_cache, wraps -from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple) +from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple, Union) from PyQt6 import QtCore from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage, @@ -17,7 +17,7 @@ from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBo QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, QFileDialog, QWidget, QToolButton, QPlainTextEdit, QApplication, QToolTip, QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem, QLayoutItem, QLayout, QMenu, - QFrame) + QFrame, QAbstractButton) from electrum.i18n import _ from electrum.util import (FileImportFailed, FileExportFailed, resource_path, EventListener, event_listener, @@ -262,13 +262,13 @@ class MessageBoxMixin(object): return self.top_level_window_recurse(test_func) def question(self, msg, parent=None, title=None, icon=None, **kwargs) -> bool: - Yes, No = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No - return Yes == self.msg_box(icon=icon or QMessageBox.Icon.Question, + yes, no = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No + return yes == self.msg_box(icon=icon or QMessageBox.Icon.Question, parent=parent, title=title or '', text=msg, - buttons=Yes|No, - defaultButton=No, + buttons=yes | no, + defaultButton=no, **kwargs) def show_warning(self, msg, parent=None, title=None, **kwargs): @@ -283,22 +283,27 @@ class MessageBoxMixin(object): return self.msg_box(QMessageBox.Icon.Critical, parent, title or _('Critical Error'), msg, **kwargs) - def show_message(self, msg, parent=None, title=None, **kwargs): - return self.msg_box(QMessageBox.Icon.Information, parent, - title or _('Information'), msg, **kwargs) + def show_message(self, msg, parent=None, title=None, icon=QMessageBox.Icon.Information, **kwargs): + return self.msg_box(icon, parent, title or _('Information'), msg, **kwargs) - def msg_box(self, icon, parent, title, text, *, buttons=QMessageBox.StandardButton.Ok, - defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False, - checkbox=None): + def msg_box( + self, + icon: Union[QMessageBox.Icon, QPixmap], + parent: QWidget, + title: str, + text: str, + *, + buttons: Union[QMessageBox.StandardButton, + List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok, + defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton, + rich_text: bool = False, + checkbox: Optional[bool] = None + ): parent = parent or self.top_level_window() - return custom_message_box(icon=icon, - parent=parent, - title=title, - text=text, - buttons=buttons, - defaultButton=defaultButton, - rich_text=rich_text, - checkbox=checkbox) + return custom_message_box( + icon=icon, parent=parent, title=title, text=text, buttons=buttons, defaultButton=defaultButton, + rich_text=rich_text, checkbox=checkbox + ) def query_choice(self, msg: Optional[str], @@ -327,15 +332,35 @@ class MessageBoxMixin(object): return d.run() - -def custom_message_box(*, icon, parent, title, text, buttons=QMessageBox.StandardButton.Ok, - defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False, - checkbox=None): +def custom_message_box( + *, + icon: Union[QMessageBox.Icon, QPixmap], + parent: QWidget, + title: str, + text: str, + buttons: Union[QMessageBox.StandardButton, + List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok, + defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton, + rich_text: bool = False, + checkbox: Optional[bool] = None +) -> int: + custom_buttons = [] + standard_buttons = QMessageBox.StandardButton.NoButton + if buttons: + if not isinstance(buttons, list): + buttons = [buttons] + for button in buttons: + if isinstance(button, QMessageBox.StandardButton): + standard_buttons |= button + else: + custom_buttons.append(button) if type(icon) is QPixmap: - d = QMessageBox(QMessageBox.Icon.Information, title, str(text), buttons, parent) + d = QMessageBox(QMessageBox.Icon.Information, title, str(text), standard_buttons, parent) d.setIconPixmap(icon) else: - d = QMessageBox(icon, title, str(text), buttons, parent) + d = QMessageBox(icon, title, str(text), standard_buttons, parent) + for button, role, _ in custom_buttons: + d.addButton(button, role) d.setWindowModality(Qt.WindowModality.WindowModal) d.setDefaultButton(defaultButton) if rich_text: @@ -350,7 +375,11 @@ def custom_message_box(*, icon, parent, title, text, buttons=QMessageBox.Standar d.setTextFormat(Qt.TextFormat.PlainText) if checkbox is not None: d.setCheckBox(checkbox) - return d.exec() + result = d.exec() + for button, _, value in custom_buttons: + if button == d.clickedButton(): + return value + return result class WindowModalDialog(QDialog, MessageBoxMixin): diff --git a/electrum/plugins/psbt_nostr/psbt_nostr.py b/electrum/plugins/psbt_nostr/psbt_nostr.py index d180ef33f..19a843130 100644 --- a/electrum/plugins/psbt_nostr/psbt_nostr.py +++ b/electrum/plugins/psbt_nostr/psbt_nostr.py @@ -244,5 +244,6 @@ class CosignerWallet(Logger): if on_failure: on_failure(str(e)) else: + self.wallet.save_db() if on_success: on_success() diff --git a/electrum/plugins/psbt_nostr/qt.py b/electrum/plugins/psbt_nostr/qt.py index abe696c40..9b1aa6bd0 100644 --- a/electrum/plugins/psbt_nostr/qt.py +++ b/electrum/plugins/psbt_nostr/qt.py @@ -27,7 +27,7 @@ from functools import partial from typing import TYPE_CHECKING, List, Tuple, Optional from PyQt6.QtCore import QObject, pyqtSignal -from PyQt6.QtWidgets import QPushButton +from PyQt6.QtWidgets import QPushButton, QMessageBox from electrum.plugin import hook from electrum.i18n import _ @@ -35,13 +35,11 @@ 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, now +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet if TYPE_CHECKING: from electrum.gui.qt.main_window import ElectrumWindow -USER_PROMPT_COOLDOWN = 10 - class QReceiveSignalObject(QObject): cosignerReceivedPsbt = pyqtSignal(str, str, object) @@ -83,7 +81,6 @@ 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() @@ -113,11 +110,7 @@ class QtCosignerWallet(EventListener, CosignerWallet): 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.add_transaction_to_wallet(tx, on_failure=self.on_add_fail) self.send_psbt(tx) def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None): @@ -139,18 +132,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): - 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: + msg = _("A transaction was received from your cosigner ({}).").format(str(event_id)[0:8]) + '\n' + \ + _("Do you want to open it now?") + result = self.window.show_message(msg, icon=QMessageBox.Icon.Question, buttons=[ + QMessageBox.StandardButton.Open, + (QPushButton('Discard'), QMessageBox.ButtonRole.DestructiveRole, 100), + (QPushButton('Save to wallet'), QMessageBox.ButtonRole.AcceptRole, 101)] + ) + if result == QMessageBox.StandardButton.Open: 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) + if result == 100: # Discard + return self.add_transaction_to_wallet(tx, on_failure=self.on_add_fail) + self.window.update_tabs() def on_tx_dialog_closed(self, event_id): self.mark_pending_event_rcvd(event_id)