1
0

Merge pull request #9694 from accumulator/qml_psbt_over_nostr

psbt_nostr: split generic and UI parts, implement for qml
This commit is contained in:
ThomasV
2025-04-15 18:12:17 +02:00
committed by GitHub
12 changed files with 661 additions and 223 deletions

View File

@@ -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)
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -74,6 +74,8 @@ class QEAppController(BaseCrashReporter, QObject):
sendingBugreportFailure = pyqtSignal(str)
secureWindowChanged = pyqtSignal()
wantCloseChanged = pyqtSignal()
pluginLoaded = pyqtSignal(str)
startupFinished = pyqtSignal()
def __init__(self, qeapp: 'ElectrumQmlApplication', plugins: 'Plugins'):
BaseCrashReporter.__init__(self, None, None, None)
@@ -229,8 +231,11 @@ class QEAppController(BaseCrashReporter, QObject):
if scheme == BITCOIN_BIP21_URI_SCHEME or scheme == LIGHTNING_URI_SCHEME:
self.uriReceived.emit(data)
def startupFinished(self):
def startup_finished(self):
self._app_started = True
self.startupFinished.emit()
for plugin_name in self._plugins.plugins.keys():
self.pluginLoaded.emit(plugin_name)
if self._intent:
self.on_new_intent(self._intent)
@@ -304,18 +309,16 @@ class QEAppController(BaseCrashReporter, QObject):
self.logger.debug('None!')
return None
@pyqtProperty('QVariant', notify=_dummy)
@pyqtProperty('QVariantList', notify=_dummy)
def plugins(self):
s = []
for item in self._plugins.descriptions:
self.logger.info(item)
s.append({
'name': item,
'fullname': self._plugins.descriptions[item]['fullname'],
'enabled': bool(self._plugins.get(item))
})
self.logger.debug(f'{str(s)}')
return s
@pyqtSlot(str, bool)
@@ -511,10 +514,11 @@ class ElectrumQmlApplication(QGuiApplication):
# slot is called after loading root QML. If object is None, it has failed.
@pyqtSlot('QObject*', 'QUrl')
def objectCreated(self, object, url):
self.engine.objectCreated.disconnect(self.objectCreated)
if object is None:
self._valid = False
self.engine.objectCreated.disconnect(self.objectCreated)
self.appController.startupFinished()
else:
self.appController.startup_finished()
def message_handler(self, line, funct, file):
# filter out common harmless messages

View File

@@ -28,7 +28,7 @@ import concurrent.futures
import copy
import datetime
import time
from typing import TYPE_CHECKING, Optional, List, Union, Mapping
from typing import TYPE_CHECKING, Optional, List, Union, Mapping, Callable
from functools import partial
from decimal import Decimal
@@ -410,6 +410,7 @@ def show_transaction(
prompt_if_unsaved: bool = False,
external_keypairs: Mapping[bytes, bytes] = None,
payment_identifier: 'PaymentIdentifier' = None,
on_closed: Callable[[], None] = None,
):
try:
d = TxDialog(
@@ -418,6 +419,7 @@ def show_transaction(
prompt_if_unsaved=prompt_if_unsaved,
external_keypairs=external_keypairs,
payment_identifier=payment_identifier,
on_closed=on_closed,
)
except SerializationError as e:
_logger.exception('unable to deserialize the transaction')
@@ -438,6 +440,7 @@ class TxDialog(QDialog, MessageBoxMixin):
prompt_if_unsaved: bool,
external_keypairs: Mapping[bytes, bytes] = None,
payment_identifier: 'PaymentIdentifier' = None,
on_closed: Callable[[], None] = None,
):
'''Transactions in the wallet will show their description.
Pass desc to give a description for txs not yet in the wallet.
@@ -451,6 +454,7 @@ class TxDialog(QDialog, MessageBoxMixin):
self.wallet = parent.wallet
self.payment_identifier = payment_identifier
self.prompt_if_unsaved = prompt_if_unsaved
self.on_closed = on_closed
self.saved = False
self.desc = None
if txid := tx.txid():
@@ -608,6 +612,9 @@ class TxDialog(QDialog, MessageBoxMixin):
self._fetch_txin_data_fut.cancel()
self._fetch_txin_data_fut = None
if self.on_closed:
self.on_closed()
def reject(self):
# Override escape-key to close normally (and invoke closeEvent)
self.close()

View File

@@ -7,7 +7,7 @@ import queue
import os
import webbrowser
from functools import partial, lru_cache, wraps
from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple)
from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple, Union)
from PyQt6 import QtCore
from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage,
@@ -17,7 +17,7 @@ from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBo
QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
QFileDialog, QWidget, QToolButton, QPlainTextEdit, QApplication, QToolTip,
QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem, QLayoutItem, QLayout, QMenu,
QFrame)
QFrame, QAbstractButton)
from electrum.i18n import _
from electrum.util import (FileImportFailed, FileExportFailed, resource_path, EventListener, event_listener,
@@ -262,13 +262,13 @@ class MessageBoxMixin(object):
return self.top_level_window_recurse(test_func)
def question(self, msg, parent=None, title=None, icon=None, **kwargs) -> bool:
Yes, No = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No
return Yes == self.msg_box(icon=icon or QMessageBox.Icon.Question,
yes, no = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No
return yes == self.msg_box(icon=icon or QMessageBox.Icon.Question,
parent=parent,
title=title or '',
text=msg,
buttons=Yes|No,
defaultButton=No,
buttons=yes | no,
defaultButton=no,
**kwargs)
def show_warning(self, msg, parent=None, title=None, **kwargs):
@@ -283,22 +283,27 @@ class MessageBoxMixin(object):
return self.msg_box(QMessageBox.Icon.Critical, parent,
title or _('Critical Error'), msg, **kwargs)
def show_message(self, msg, parent=None, title=None, **kwargs):
return self.msg_box(QMessageBox.Icon.Information, parent,
title or _('Information'), msg, **kwargs)
def show_message(self, msg, parent=None, title=None, icon=QMessageBox.Icon.Information, **kwargs):
return self.msg_box(icon, parent, title or _('Information'), msg, **kwargs)
def msg_box(self, icon, parent, title, text, *, buttons=QMessageBox.StandardButton.Ok,
defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False,
checkbox=None):
def msg_box(
self,
icon: Union[QMessageBox.Icon, QPixmap],
parent: QWidget,
title: str,
text: str,
*,
buttons: Union[QMessageBox.StandardButton,
List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok,
defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
rich_text: bool = False,
checkbox: Optional[bool] = None
):
parent = parent or self.top_level_window()
return custom_message_box(icon=icon,
parent=parent,
title=title,
text=text,
buttons=buttons,
defaultButton=defaultButton,
rich_text=rich_text,
checkbox=checkbox)
return custom_message_box(
icon=icon, parent=parent, title=title, text=text, buttons=buttons, defaultButton=defaultButton,
rich_text=rich_text, checkbox=checkbox
)
def query_choice(self,
msg: Optional[str],
@@ -327,15 +332,35 @@ class MessageBoxMixin(object):
return d.run()
def custom_message_box(*, icon, parent, title, text, buttons=QMessageBox.StandardButton.Ok,
defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False,
checkbox=None):
def custom_message_box(
*,
icon: Union[QMessageBox.Icon, QPixmap],
parent: QWidget,
title: str,
text: str,
buttons: Union[QMessageBox.StandardButton,
List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok,
defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
rich_text: bool = False,
checkbox: Optional[bool] = None
) -> int:
custom_buttons = []
standard_buttons = QMessageBox.StandardButton.NoButton
if buttons:
if not isinstance(buttons, list):
buttons = [buttons]
for button in buttons:
if isinstance(button, QMessageBox.StandardButton):
standard_buttons |= button
else:
custom_buttons.append(button)
if type(icon) is QPixmap:
d = QMessageBox(QMessageBox.Icon.Information, title, str(text), buttons, parent)
d = QMessageBox(QMessageBox.Icon.Information, title, str(text), standard_buttons, parent)
d.setIconPixmap(icon)
else:
d = QMessageBox(icon, title, str(text), buttons, parent)
d = QMessageBox(icon, title, str(text), standard_buttons, parent)
for button, role, _ in custom_buttons:
d.addButton(button, role)
d.setWindowModality(Qt.WindowModality.WindowModal)
d.setDefaultButton(defaultButton)
if rich_text:
@@ -350,7 +375,11 @@ def custom_message_box(*, icon, parent, title, text, buttons=QMessageBox.Standar
d.setTextFormat(Qt.TextFormat.PlainText)
if checkbox is not None:
d.setCheckBox(checkbox)
return d.exec()
result = d.exec()
for button, _, value in custom_buttons:
if button == d.clickedButton():
return value
return result
class WindowModalDialog(QDialog, MessageBoxMixin):

View File

@@ -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"
}

View File

@@ -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()

View File

@@ -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')

View File

@@ -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()
}
}
}
}
}

View File

@@ -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 @@ class CosignerWallet(Logger):
else:
d.cosigner_send_button.setVisible(False)
def cosigner_can_sign(self, tx: Transaction, cosigner_xpub: str) -> bool:
# TODO implement this properly:
# should return True iff cosigner (with given xpub) can sign and has not yet signed.
# note that tx could also be unrelated from wallet?... (not ismine inputs)
return True
def 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 @@ class CosignerWallet(Logger):
self.window.show_error(str(e))
return
self.window.show_message(
_("Your transaction was sent to your cosigners via Nostr.") + '\n\n' + tx.txid())
_("Your transaction was sent to your cosigners via Nostr.") + '\n\n' + txid)
def on_receive(self, pubkey, event_id, tx):
window = self.window
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)