1
0

plugins: psbt_nostr: implement for qml

This commit is contained in:
Sander van Grieken
2025-04-04 16:44:44 +02:00
parent 13a4076f22
commit 3ff84f08a6
9 changed files with 221 additions and 34 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

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

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

@@ -203,6 +203,9 @@ class CosignerWallet(Logger):
# note that tx could also be unrelated from wallet?... (not ismine inputs)
return True
def mark_event_rcvd(self, event_id):
self.known_events[event_id] = now()
def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]:
messages = []
for xpub, pubkey in self.cosigner_list:

View File

@@ -22,10 +22,19 @@
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from typing import TYPE_CHECKING
import asyncio
import concurrent
from typing import TYPE_CHECKING, List, Tuple, Optional
from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot
from electrum import util
from electrum.plugin import hook
from electrum.transaction import PartialTransaction, tx_from_any
from electrum.wallet import Multisig_Wallet
from electrum.util import EventListener, event_listener
from electrum.gui.qml.qewallet import QEWallet
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet
@@ -34,40 +43,101 @@ if TYPE_CHECKING:
from electrum.gui.qml import ElectrumQmlApplication
class QReceiveSignalObject(QObject):
def __init__(self, plugin: 'Plugin'):
QObject.__init__(self)
self._plugin = plugin
cosignerReceivedPsbt = pyqtSignal(str, str, str)
sendPsbtFailed = pyqtSignal(str, arguments=['reason'])
sendPsbtSuccess = pyqtSignal()
@pyqtProperty(str)
def loader(self):
return 'main.qml'
@pyqtSlot(QEWallet, str)
def sendPsbt(self, wallet: 'QEWallet', tx: str):
cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet]
if not cosigner_wallet:
return
cosigner_wallet.send_psbt(tx_from_any(tx, deserialize=True))
@pyqtSlot(QEWallet, str)
def acceptPsbt(self, wallet: 'QEWallet', event_id: str):
cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet]
if not cosigner_wallet:
return
cosigner_wallet.accept_psbt(event_id)
class Plugin(PsbtNostrPlugin):
def __init__(self, parent, config, name):
super().__init__(parent, config, name)
self.so = QReceiveSignalObject(self)
self._app = None
@hook
def init_qml(self, app: 'ElectrumQmlApplication'):
# if self._init_qt_received: # only need/want the first signal
# return
# self._init_qt_received = True
self._app = app
# plugin enable for already open wallets
for wallet in app.daemon.get_wallets():
self.so.setParent(app) # parent in QObject tree
# plugin enable for already open wallet
wallet = app.daemon.currentWallet.wallet if app.daemon.currentWallet else None
if wallet:
self.load_wallet(wallet)
@hook
def load_wallet(self, wallet: 'Abstract_Wallet'):
# remove existing, only foreground wallet active
if len(self.cosigner_wallets):
self.remove_cosigner_wallet(self.cosigner_wallets[0])
if not isinstance(wallet, Multisig_Wallet):
return
self.add_cosigner_wallet(wallet, CosignerWallet(wallet))
self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
# @hook
# def on_close_window(self, window):
# wallet = window.wallet
# self.remove_cosigner_wallet(wallet)
#
# @hook
# def transaction_dialog(self, d: 'TxDialog'):
# if cw := self.cosigner_wallets.get(d.wallet):
# assert isinstance(cw, QtCosignerWallet)
# cw.hook_transaction_dialog(d)
#
# @hook
# def transaction_dialog_update(self, d: 'TxDialog'):
# if cw := self.cosigner_wallets.get(d.wallet):
# assert isinstance(cw, QtCosignerWallet)
# cw.hook_transaction_dialog_update(d)
class QmlCosignerWallet(EventListener, CosignerWallet):
def __init__(self, wallet: 'Multisig_Wallet', plugin: 'Plugin'):
CosignerWallet.__init__(self, wallet)
self.plugin = plugin
self.register_callbacks()
self.pending = None
@event_listener
def on_event_psbt_nostr_received(self, wallet, pubkey, event, tx: 'PartialTransaction'):
if self.wallet == wallet:
self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event, tx.serialize())
self.on_receive(pubkey, event, tx)
def close(self):
super().close()
self.unregister_callbacks()
def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None):
if not messages:
return
coro = self.send_direct_messages(messages)
loop = util.get_asyncio_loop()
assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
self._result = None
self._future = asyncio.run_coroutine_threadsafe(coro, loop)
try:
self._result = self._future.result()
self.plugin.so.sendPsbtSuccess.emit()
except concurrent.futures.CancelledError:
pass
except Exception as e:
self.plugin.so.sendPsbtFailed.emit(str(e))
def on_receive(self, pubkey, event_id, tx):
self.pending = (pubkey, event_id, tx)
def accept_psbt(self, my_event_id):
pubkey, event_id, tx = self.pending
if event_id == my_event_id:
self.mark_event_rcvd(event_id)
self.pending = None

View File

@@ -0,0 +1,54 @@
import QtQuick
import org.electrum
import "../../../gui/qml/components/controls"
Item {
Connections {
target: AppController ? AppController.plugin('psbt_nostr') : null
function onCosignerReceivedPsbt(pubkey, event, tx) {
var dialog = app.messageDialog.createObject(app, {
text: [
qsTr('A transaction was received from your cosigner.'),
qsTr('Do you want to open it now?')
].join('\n'),
yesno: true
})
dialog.accepted.connect(function () {
app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), {
rawtx: tx
})
target.acceptPsbt(Daemon.currentWallet, event)
})
dialog.open()
}
}
property variant export_tx_button: Component {
FlatButton {
id: psbt_nostr_send_button
property variant dialog
text: qsTr('Nostr')
icon.source: Qt.resolvedUrl('../../../gui/icons/network.png')
visible: Daemon.currentWallet.isMultisig && Daemon.currentWallet.walletType != '2fa'
onClicked: {
console.log('about to psbt nostr send')
psbt_nostr_send_button.enabled = false
AppController.plugin('psbt_nostr').sendPsbt(Daemon.currentWallet, dialog.text)
}
Connections {
target: AppController ? AppController.plugin('psbt_nostr') : null
function onSendPsbtFailed(message) {
psbt_nostr_send_button.enabled = true
var dialog = app.messageDialog.createObject(app, {
text: qsTr('Sending PSBT to co-signer failed:\n%1').arg(message)
})
dialog.open()
}
}
}
}
}

View File

@@ -34,10 +34,9 @@ from electrum.wallet import Multisig_Wallet, Abstract_Wallet
from electrum.util import UserCancelled, event_listener, EventListener
from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet
if TYPE_CHECKING:
from electrum.gui.qt import ElectrumGui
from electrum.gui.qt.main_window import ElectrumWindow
@@ -133,5 +132,5 @@ class QtCosignerWallet(EventListener, CosignerWallet):
_("An transaction was received from your cosigner.") + '\n' +
_("Do you want to open it now?")):
return
self.known_events[event_id] = now()
self.mark_event_rcvd(event_id)
show_transaction(tx, parent=window, prompt_if_unsaved=True)