Repository: spesmilo/electrum
Pull request #9694: psbt_nostr: split generic and UI parts, implement for qml
Author: login: accumulator
Created: 2025-04-03T16:25:47Z | Merged: 2025-04-15T16:12:18Z

also some boilerplate for plugins extending QML GUI


Diff:
diff --git a/electrum/gui/qml/components/ExportTxDialog.qml b/electrum/gui/qml/components/ExportTxDialog.qml
index 82850aa6098c..62b4291bf5cf 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/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml
index c2240d54d014..7e8b086ad7bd 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/qml/components/controls/ButtonContainer.qml b/electrum/gui/qml/components/controls/ButtonContainer.qml
index 5a3d18c43820..527897c0b419 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 14df2cf4b38d..203bf20dc02d 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 bb85878248be..3efc496d2fbe 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 @@ def on_new_intent(self, intent):
         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 @@ def plugin(self, plugin_name):
             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 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
     # 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/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py
index 64abfcb28f26..109a90c28d52 100644
--- a/electrum/gui/qt/transaction_dialog.py
+++ b/electrum/gui/qt/transaction_dialog.py
@@ -28,7 +28,7 @@
 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 @@ def __init__(
         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 @@ def __init__(
         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 @@ def closeEvent(self, event):
             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/gui/qt/util.py b/electrum/gui/qt/util.py
index 6ee320b1d82d..1410d5324ea4 100644
--- a/electrum/gui/qt/util.py
+++ b/electrum/gui/qt/util.py
@@ -7,7 +7,7 @@
 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 @@
                              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 @@ def top_level_window(self, test_func=None):
         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 @@ def show_critical(self, msg, parent=None, title=None, **kwargs):
         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 @@ def password_dialog(self, msg=None, parent=None):
         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/manifest.json b/electrum/plugins/psbt_nostr/manifest.json
index 9d0c60f98e06..702d13ca1e24 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
new file mode 100644
index 000000000000..19a843130854
--- /dev/null
+++ b/electrum/plugins/psbt_nostr/psbt_nostr.py
@@ -0,0 +1,249 @@
+#!/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
+
+        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}')
+                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)
+                await self.pending.wait()
+                self.pending.clear()
+
+    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 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 = []
+        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()
+
+    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:
+            self.wallet.save_db()
+            if on_success:
+                on_success()
diff --git a/electrum/plugins/psbt_nostr/qml.py b/electrum/plugins/psbt_nostr/qml.py
new file mode 100644
index 000000000000..1818eb59c2a6
--- /dev/null
+++ b/electrum/plugins/psbt_nostr/qml.py
@@ -0,0 +1,159 @@
+#!/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 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, 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'):
+        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)
+
+    @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):
+        super().__init__(parent, config, name)
+        self.so = QReceiveSignalObject(self)
+        self._app = None
+
+    @hook
+    def init_qml(self, app: 'ElectrumQmlApplication'):
+        self._app = app
+        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, QmlCosignerWallet(wallet, self))
+
+
+class QmlCosignerWallet(EventListener, CosignerWallet):
+
+    def __init__(self, wallet: 'Multisig_Wallet', plugin: 'Plugin'):
+        CosignerWallet.__init__(self, wallet)
+        self.plugin = plugin
+        self.register_callbacks()
+
+        self.tx = None
+        self.user_prompt_cooldown = None
+
+    @event_listener
+    def on_event_psbt_nostr_received(self, wallet, pubkey, event_id, tx: 'PartialTransaction'):
+        if self.wallet == wallet:
+            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()
+        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 accept_psbt(self, event_id):
+        self.mark_pending_event_rcvd(event_id)
+
+    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
new file mode 100644
index 000000000000..b54ceee83c96
--- /dev/null
+++ b/electrum/plugins/psbt_nostr/qml/main.qml
@@ -0,0 +1,59 @@
+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 () {
+                var page = app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), {
+                    rawtx: tx
+                })
+                page.closed.connect(function () {
+                    target.acceptPsbt(Daemon.currentWallet, event)
+                })
+            })
+            dialog.rejected.connect(function () {
+                target.rejectPsbt(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 53926ec8b8da..9b1aa6bd0845 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 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
 
-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
 
 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_to_cosigners(d.tx))
         d.buttons.insert(0, b)
         b.setVisible(False)
 
@@ -245,22 +109,14 @@ def hook_transaction_dialog_update(self, d: 'TxDialog'):
         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 send_to_cosigners(self, tx):
+        self.add_transaction_to_wallet(tx, on_failure=self.on_add_fail)
+        self.send_psbt(tx)
 
-    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,14 +129,27 @@ def do_send(self, tx: Union[Transaction, PartialTransaction]):
             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
-        if not window.question(
-                _("An transaction was received from your cosigner.") + '\n' +
-                _("Do you want to open it now?")):
-            return
-        self.known_events[event_id] = now()
-        show_transaction(tx, parent=window, prompt_if_unsaved=True)
+        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)
+
+    def on_add_fail(self, msg: str):
+        self.window.show_error(msg)


Reviews:
user: login: f321x
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T07:39:19Z
user: login: accumulator
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T09:29:42Z
user: login: accumulator
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T12:20:56Z
user: login: accumulator
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T12:30:15Z
user: login: accumulator
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T12:34:47Z
user: login: f321x
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T15:16:34Z
user: login: accumulator
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T16:00:28Z
user: login: f321x
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T08:59:58Z
user: login: accumulator
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:03:21Z
user: login: ecdsa
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:49:59Z

Review comments:
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: f321x
body: Got the following exception when accepting 2 incoming cosigner requests at the same time (by broadcasting from 2 signers in a 3-of-3, then opening the third signer which will show 2 overlapping popups, clicking yes on both produces this exception):
```
Traceback (most recent call last):
  File "/home/user/code/electrum-fork/electrum/plugins/psbt_nostr/qml.py", line 71, in acceptPsbt
    cosigner_wallet.accept_psbt(event_id)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File "/home/user/code/electrum-fork/electrum/plugins/psbt_nostr/qml.py", line 140, in accept_psbt
    pubkey, event_id, tx = self.pending
    ^^^^^^^^^^^^^^^^^^^^
TypeError: cannot unpack non-iterable NoneType object
```
created_at: 2025-04-09T07:39:19Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: accumulator
body: Yes, good catch, testing this was indeed left on my TODO list :)

The desktop client handles this case by blocking the UI thread, which is not an option in QML. I was thinking it might be better to put the responsibility on the plugin to emit only one PSBT, until the PSBT/event is accepted. This way we don't have to implement this in every GUI.

created_at: 2025-04-09T09:29:41Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: accumulator
body: correction: it seems it is not blocking the entire UI thread, but the signal handler itself is blocking on the `window.question()`
https://github.com/spesmilo/electrum/blob/78c6f7c77f28f28de084298c3499277d8faa3fba/electrum/plugins/psbt_nostr/qt.py#L138-L145

regardless, this is the result of a little spamming on desktop 

![image](https://github.com/user-attachments/assets/f863bf55-b0f0-4daf-8159-e4d661acfbfc)

What to do if the user answers no? Present the next psbt event popup (potentially many)? Or stop asking altogether? (maybe skip all received events? or disable receive for x minutes?)

created_at: 2025-04-09T12:20:55Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: accumulator
body: It looks more like an inbox pattern than an event pattern.. Maybe receive all to wallet and add another tab 'Sign Requests'? 😄
created_at: 2025-04-09T12:30:15Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: accumulator
body: This would also solve supercedes, e.g. remove older psbts that spend the same utxos, due to re-send of RbF-ed or same tx, or supercede PSBTs with more signatures.


created_at: 2025-04-09T12:34:47Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: f321x
body: Cancelling all of them when the user clicks no on one may cause an important one to get cancelled, i think it would be useful to see how many signatures the tx has in the message so the user sees which is the interesting one he actually wants to sign (e.g. in a 3-of-3 with 3 sequential sessions the third signer will get two messages, and could just cancel the one showing 1-of-3 sigs and accept the one with 2-of-3 to sign it). Or maybe just a little pie chart in the message?

But we should probably put the events on the known_events dict when the user clicks no, so they don't show up again on each wallet restart for the next 24h.

A Sign Requests tab would be nice as an optional thing maybe, i think the notification popups are cool because they are self explanatory as in you just get them shoved in the face and don't have to search for the right place to sign. 
Considering that cosigning over nostr probably is done less frequently by users the simplicity of popups outweigh the benefits of an extra tab imo.
created_at: 2025-04-09T15:16:33Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: accumulator
body: > Cancelling all of them when the user clicks no on one may cause an important one to get cancelled, i think it would be useful to see how many signatures the tx has in the message so the user sees which is the interesting one he actually wants to sign (e.g. in a 3-of-3 with 3 sequential sessions the third signer will get two messages, and could just cancel the one showing 1-of-3 sigs and accept the one with 2-of-3 to sign it). Or maybe just a little pie chart in the message?
> 
> But we should probably put the events on the known_events dict when the user clicks no, so they don't show up again on each wallet restart for the next 24h.

Not re-appearing for 24h is not very user friendly..

> A Sign Requests tab would be nice as an optional thing maybe, i think the notification popups are cool because they are self explanatory as in you just get them shoved in the face and don't have to search for the right place to sign. 

Well we could still show the popup of course

>Considering that cosigning over nostr probably is done less frequently by users the simplicity of popups outweigh the benefits of an extra tab imo.

Not sure I agree. There's not many users of multisig, but those that do could use this frequently. It would  basically just be a list. And we can hide the tab unless there's actual unhandled events. 


created_at: 2025-04-09T16:00:28Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: f321x
body: > Not re-appearing for 24h is not very user friendly..

I mean when the popup actually shows some information about the psbt (like the amount of sigs), and the user decides to click no because the tx is not relevant (e.g. already signed on another signer) i don't see a point in showing it again on every restart until it expires, the user will just click no again and it's annoying? In the current state i agree, as there is no information you don't really know which popup to accept or reject.

> Well we could still show the popup of course

I think a nice middle way would be to show a single popup then (even if multiple requests come in), and clicking yes would just redirect to the inbox tab, so the user is aware of the tab and doesn't have to find it.
created_at: 2025-04-10T08:59:58Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: accumulator
body: > I think a nice middle way would be to show a single popup then (even if multiple requests come in), and clicking yes would just redirect to the inbox tab, so the user is aware of the tab and doesn't have to find it.

Hmm, we kinda-sorta already have a list of 'sign requests', if we consider storing transactions as Local (as in, 'Add to History' in transaction dialog), which already handles transaction replacements if they spend the same UTXOs.

So, maybe we could do a 'Add to History' implicitly for all remaining incoming PSBTs when saying No to the popup, so the tx(s) appear at the top of the history as a Local transaction. 

Would be minimal effort to do it like this..

Also, for the initiating side we could 'Add to History' implicitly when sending to cosigner, so the initiator stays aware of the used utxos while waiting for cosigner(s) to complete the tx (and will not use these utxos in other txs). Currently it is possible to send to cosigner without adding to local history.

@ecdsa, @SomberNight what are your thoughts?

created_at: 2025-04-10T11:03:21Z
author_association: MEMBER
diff_hunk: @@ -34,40 +43,101 @@
     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))
-
-    # @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)
+        self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
+
+
+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
path: electrum/plugins/psbt_nostr/qml.py
user: login: ecdsa
body: Maybe if the user says 'no', the event should be added to `self.known_events`. That would reduce the spam.

Also, if a tx has more than 2 cosigners, we should try to show only the most recent version of the tx, or the one that has the most signatures.
created_at: 2025-04-10T11:49:59Z
author_association: MEMBER

PR comments:
user: login: f321x
created_at: 2025-04-09T07:42:28Z
body: Tested QT and QML.
IIUC the plugin is disabled by default and there is no way to enable it from the QML gui (on Android), should there be a config variable set for Android in `run_electrum` or some switch in the settings?

I think it would also be useful to allow the user to configure relays (like in the QT gui), as they maybe want to use their own relay for privacy or the default relays could all be unavailable, rendering this function unusable until the next release.
author_association: MEMBER
user: login: accumulator
created_at: 2025-04-09T09:32:41Z
body: > Tested QT and QML. IIUC the plugin is disabled by default and there is no way to enable it from the QML gui (on Android), should there be a config variable set for Android in `run_electrum` or some switch in the settings?

Correct, I haven't implemented this yet. We can hard-enable this, or create a Preferences option for it. Currently, it can only be tested on desktop after enabling through CLI.

> I think it would also be useful to allow the user to configure relays (like in the QT gui), as they maybe want to use their own relay for privacy or the default relays could all be unavailable, rendering this function unusable until the next release.

Agreed

author_association: MEMBER
user: login: accumulator
created_at: 2025-04-10T18:55:59Z
body: I've added an `Event.wait()` in `check_direct_messages` to pace the incoming PSBTs. 

If the user responds No to 'open psbt now', it will add the transaction to the wallet and set a short cooldown timeout to process existing PSBTs in the nostr-queue without asking the user.

Some extra PSBT handling is still TODO, e.g. check to only add 'newer' txs with more signatures, or auto-combine signatures if PSBTs are coming from multiple cosigners. Also GUI-wise there's some fighting between transaction dialog open and the popup for another PSBT opening at the same time.


author_association: MEMBER
user: login: ecdsa
created_at: 2025-04-15T12:57:30Z
body: > If the user responds No to 'open psbt now', it will add the transaction to the wallet and set a short cooldown timeout to process existing PSBTs in the nostr-queue without asking the user.

On qt, if I click 'No', the history tab is not refreshed after the tx is added to the wallet. Also, the plugin is not informing me that it added the tx to my history. Maybe, instead of a yes/no dialog, we could have 3 buttons: 'Discard', 'Save to wallet' and 'Open'.

author_association: MEMBER
user: login: accumulator
created_at: 2025-04-15T13:02:41Z
body: > Maybe, instead of a yes/no dialog, we could have 3 buttons: 'Discard', 'Save to wallet' and 'Open'.

Ok, that sounds better than an additional popup mentioning it saved to wallet, and also removes the need for a cooldown. It will instead show this popup then for each PSBT in the queue in sequence.



author_association: MEMBER
user: login: accumulator
created_at: 2025-04-15T15:41:28Z
body: It would be nice if we can pass the label/description of the PSBT along with the PSBT itself, this would give the receiver some context instead of just a 10 digit hex code


author_association: MEMBER
user: login: accumulator
created_at: 2025-04-15T15:45:14Z
body: Also, this plugin might also be usable for watch-only wallets to request signing by the seeded wallet.
author_association: MEMBER
user: login: ecdsa
created_at: 2025-04-15T16:04:04Z
body: > Also, this plugin might also be usable for watch-only wallets to request signing by the seeded wallet.

Yeah. I renamed it 'Nostr Multisig' because PSBT is cryptic and too vague. but we could call it 'Nostr Cosigner' ?
author_association: MEMBER
user: login: ecdsa
created_at: 2025-04-15T16:04:21Z
body: > It would be nice if we can pass the label/description of the PSBT along with the PSBT itself, this would give the receiver some context instead of just a 10 digit hex code

indeed
author_association: MEMBER