Skip to content

Commit 78c6f7c

Browse files
committed
qml: psbt over nostr
1 parent 72011c9 commit 78c6f7c

File tree

9 files changed

+222
-34
lines changed

9 files changed

+222
-34
lines changed

Diff for: electrum/gui/qml/components/ExportTxDialog.qml

+12
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ ElDialog {
7676
}
7777

7878
ButtonContainer {
79+
id: buttons
7980
Layout.fillWidth: true
8081

8182
FlatButton {
@@ -97,6 +98,17 @@ ElDialog {
9798
AppController.doShare(dialog.text, dialog.title)
9899
}
99100
}
101+
function beforeLayout() {
102+
var export_tx_buttons = app.pluginsComponentsByName('export_tx_button')
103+
for (var i=0; i < export_tx_buttons.length; i++) {
104+
var b = export_tx_buttons[i].createObject(buttons, {
105+
dialog: dialog
106+
})
107+
b.Layout.fillWidth = true
108+
b.Layout.preferredWidth = 1
109+
buttons.addItem(b)
110+
}
111+
}
100112
}
101113
}
102114

Diff for: electrum/gui/qml/components/controls/ButtonContainer.qml

+7-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ Container {
3535
contentItem = contentRoot
3636
}
3737

38-
Component.onCompleted: fillContentItem()
38+
// override this function to dynamically add buttons.
39+
function beforeLayout() {}
40+
41+
Component.onCompleted: {
42+
beforeLayout()
43+
fillContentItem()
44+
}
3945

4046
Component {
4147
id: containerLayout

Diff for: electrum/gui/qml/components/main.qml

+39
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ ApplicationWindow
3939

4040
property var _exceptionDialog
4141

42+
property var pluginobjects: ({})
43+
4244
property QtObject appMenu: Menu {
4345
id: menu
4446

@@ -640,6 +642,43 @@ ApplicationWindow
640642
})
641643
app._exceptionDialog.open()
642644
}
645+
function onPluginLoaded(name) {
646+
console.log('plugin ' + name + ' loaded')
647+
var loader = AppController.plugin(name).loader
648+
if (loader == undefined)
649+
return
650+
var url = Qt.resolvedUrl('../../../plugins/' + name + '/qml/' + loader)
651+
var comp = Qt.createComponent(url)
652+
if (comp.status == Component.Error) {
653+
console.log('Could not find/parse PluginLoader for plugin ' + name)
654+
console.log(comp.errorString())
655+
return
656+
}
657+
var obj = comp.createObject(app)
658+
if (obj != null)
659+
app.pluginobjects[name] = obj
660+
}
661+
}
662+
663+
function pluginsComponentsByName(comp_name) {
664+
// return named QML components from plugins
665+
var plugins = AppController.plugins
666+
var result = []
667+
for (var i=0; i < plugins.length; i++) {
668+
if (!plugins[i].enabled)
669+
continue
670+
var pluginobject = app.pluginobjects[plugins[i].name]
671+
if (!pluginobject)
672+
continue
673+
if (!(comp_name in pluginobject))
674+
continue
675+
var comp = pluginobject[comp_name]
676+
if (!comp)
677+
continue
678+
679+
result.push(comp)
680+
}
681+
return result
643682
}
644683

645684
Connections {

Diff for: electrum/gui/qml/qeapp.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class QEAppController(BaseCrashReporter, QObject):
7474
sendingBugreportFailure = pyqtSignal(str)
7575
secureWindowChanged = pyqtSignal()
7676
wantCloseChanged = pyqtSignal()
77+
pluginLoaded = pyqtSignal(str)
78+
startupFinished = pyqtSignal()
7779

7880
def __init__(self, qeapp: 'ElectrumQmlApplication', qedaemon: 'QEDaemon', plugins: 'Plugins'):
7981
BaseCrashReporter.__init__(self, None, None, None)
@@ -230,8 +232,11 @@ def on_new_intent(self, intent):
230232
if scheme == BITCOIN_BIP21_URI_SCHEME or scheme == LIGHTNING_URI_SCHEME:
231233
self.uriReceived.emit(data)
232234

233-
def startupFinished(self):
235+
def startup_finished(self):
234236
self._app_started = True
237+
self.startupFinished.emit()
238+
for plugin_name in self._plugins.plugins.keys():
239+
self.pluginLoaded.emit(plugin_name)
235240
if self._intent:
236241
self.on_new_intent(self._intent)
237242

@@ -305,18 +310,16 @@ def plugin(self, plugin_name):
305310
self.logger.debug('None!')
306311
return None
307312

308-
@pyqtProperty('QVariant', notify=_dummy)
313+
@pyqtProperty('QVariantList', notify=_dummy)
309314
def plugins(self):
310315
s = []
311316
for item in self._plugins.descriptions:
312-
self.logger.info(item)
313317
s.append({
314318
'name': item,
315319
'fullname': self._plugins.descriptions[item]['fullname'],
316320
'enabled': bool(self._plugins.get(item))
317321
})
318322

319-
self.logger.debug(f'{str(s)}')
320323
return s
321324

322325
@pyqtSlot(str, bool)
@@ -514,10 +517,11 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
514517
# slot is called after loading root QML. If object is None, it has failed.
515518
@pyqtSlot('QObject*', 'QUrl')
516519
def objectCreated(self, object, url):
520+
self.engine.objectCreated.disconnect(self.objectCreated)
517521
if object is None:
518522
self._valid = False
519-
self.engine.objectCreated.disconnect(self.objectCreated)
520-
self.appController.startupFinished()
523+
else:
524+
self.appController.startup_finished()
521525

522526
def message_handler(self, line, funct, file):
523527
# filter out common harmless messages

Diff for: electrum/plugins/psbt_nostr/manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"fullname": "PSBT over Nostr",
33
"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.",
44
"author": "The Electrum Developers",
5-
"available_for": ["qt"],
5+
"available_for": ["qt", "qml"],
66
"version": "0.0.1"
77
}

Diff for: electrum/plugins/psbt_nostr/psbt_nostr.py

+3
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ def cosigner_can_sign(self, tx: Transaction, cosigner_xpub: str) -> bool:
203203
# note that tx could also be unrelated from wallet?... (not ismine inputs)
204204
return True
205205

206+
def mark_event_rcvd(self, event_id):
207+
self.known_events[event_id] = now()
208+
206209
def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]:
207210
messages = []
208211
for xpub, pubkey in self.cosigner_list:

Diff for: electrum/plugins/psbt_nostr/qml.py

+94-24
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,19 @@
2222
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
2323
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2424
# SOFTWARE.
25-
from typing import TYPE_CHECKING
25+
import asyncio
26+
import concurrent
27+
from typing import TYPE_CHECKING, List, Tuple, Optional
2628

29+
from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot
30+
31+
from electrum import util
2732
from electrum.plugin import hook
33+
from electrum.transaction import PartialTransaction, tx_from_any
2834
from electrum.wallet import Multisig_Wallet
35+
from electrum.util import EventListener, event_listener
36+
37+
from electrum.gui.qml.qewallet import QEWallet
2938

3039
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet
3140

@@ -34,40 +43,101 @@
3443
from electrum.gui.qml import ElectrumQmlApplication
3544

3645

46+
class QReceiveSignalObject(QObject):
47+
def __init__(self, plugin: 'Plugin'):
48+
QObject.__init__(self)
49+
self._plugin = plugin
50+
51+
cosignerReceivedPsbt = pyqtSignal(str, str, str)
52+
sendPsbtFailed = pyqtSignal(str, arguments=['reason'])
53+
sendPsbtSuccess = pyqtSignal()
54+
55+
@pyqtProperty(str)
56+
def loader(self):
57+
return 'main.qml'
58+
59+
@pyqtSlot(QEWallet, str)
60+
def sendPsbt(self, wallet: 'QEWallet', tx: str):
61+
cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet]
62+
if not cosigner_wallet:
63+
return
64+
cosigner_wallet.send_psbt(tx_from_any(tx, deserialize=True))
65+
66+
@pyqtSlot(QEWallet, str)
67+
def acceptPsbt(self, wallet: 'QEWallet', event_id: str):
68+
cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet]
69+
if not cosigner_wallet:
70+
return
71+
cosigner_wallet.accept_psbt(event_id)
72+
73+
3774
class Plugin(PsbtNostrPlugin):
3875
def __init__(self, parent, config, name):
3976
super().__init__(parent, config, name)
77+
self.so = QReceiveSignalObject(self)
4078
self._app = None
4179

4280
@hook
4381
def init_qml(self, app: 'ElectrumQmlApplication'):
44-
# if self._init_qt_received: # only need/want the first signal
45-
# return
46-
# self._init_qt_received = True
4782
self._app = app
48-
# plugin enable for already open wallets
49-
for wallet in app.daemon.get_wallets():
83+
self.so.setParent(app) # parent in QObject tree
84+
# plugin enable for already open wallet
85+
wallet = app.daemon.currentWallet.wallet if app.daemon.currentWallet else None
86+
if wallet:
5087
self.load_wallet(wallet)
5188

5289
@hook
5390
def load_wallet(self, wallet: 'Abstract_Wallet'):
91+
# remove existing, only foreground wallet active
92+
if len(self.cosigner_wallets):
93+
self.remove_cosigner_wallet(self.cosigner_wallets[0])
5494
if not isinstance(wallet, Multisig_Wallet):
5595
return
56-
self.add_cosigner_wallet(wallet, CosignerWallet(wallet))
57-
58-
# @hook
59-
# def on_close_window(self, window):
60-
# wallet = window.wallet
61-
# self.remove_cosigner_wallet(wallet)
62-
#
63-
# @hook
64-
# def transaction_dialog(self, d: 'TxDialog'):
65-
# if cw := self.cosigner_wallets.get(d.wallet):
66-
# assert isinstance(cw, QtCosignerWallet)
67-
# cw.hook_transaction_dialog(d)
68-
#
69-
# @hook
70-
# def transaction_dialog_update(self, d: 'TxDialog'):
71-
# if cw := self.cosigner_wallets.get(d.wallet):
72-
# assert isinstance(cw, QtCosignerWallet)
73-
# cw.hook_transaction_dialog_update(d)
96+
self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self))
97+
98+
99+
class QmlCosignerWallet(EventListener, CosignerWallet):
100+
101+
def __init__(self, wallet: 'Multisig_Wallet', plugin: 'Plugin'):
102+
CosignerWallet.__init__(self, wallet)
103+
self.plugin = plugin
104+
self.register_callbacks()
105+
106+
self.pending = None
107+
108+
@event_listener
109+
def on_event_psbt_nostr_received(self, wallet, pubkey, event, tx: 'PartialTransaction'):
110+
if self.wallet == wallet:
111+
self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event, tx.serialize())
112+
self.on_receive(pubkey, event, tx)
113+
114+
def close(self):
115+
super().close()
116+
self.unregister_callbacks()
117+
118+
def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None):
119+
if not messages:
120+
return
121+
coro = self.send_direct_messages(messages)
122+
123+
loop = util.get_asyncio_loop()
124+
assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
125+
self._result = None
126+
self._future = asyncio.run_coroutine_threadsafe(coro, loop)
127+
128+
try:
129+
self._result = self._future.result()
130+
self.plugin.so.sendPsbtSuccess.emit()
131+
except concurrent.futures.CancelledError:
132+
pass
133+
except Exception as e:
134+
self.plugin.so.sendPsbtFailed.emit(str(e))
135+
136+
def on_receive(self, pubkey, event_id, tx):
137+
self.pending = (pubkey, event_id, tx)
138+
139+
def accept_psbt(self, my_event_id):
140+
pubkey, event_id, tx = self.pending
141+
if event_id == my_event_id:
142+
self.mark_event_rcvd(event_id)
143+
self.pending = None

Diff for: electrum/plugins/psbt_nostr/qml/main.qml

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import QtQuick
2+
3+
import org.electrum
4+
5+
import "../../../gui/qml/components/controls"
6+
7+
Item {
8+
Connections {
9+
target: AppController ? AppController.plugin('psbt_nostr') : null
10+
function onCosignerReceivedPsbt(pubkey, event, tx) {
11+
var dialog = app.messageDialog.createObject(app, {
12+
text: [
13+
qsTr('A transaction was received from your cosigner.'),
14+
qsTr('Do you want to open it now?')
15+
].join('\n'),
16+
yesno: true
17+
})
18+
dialog.accepted.connect(function () {
19+
app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), {
20+
rawtx: tx
21+
})
22+
target.acceptPsbt(Daemon.currentWallet, event)
23+
})
24+
dialog.open()
25+
}
26+
}
27+
28+
property variant export_tx_button: Component {
29+
FlatButton {
30+
id: psbt_nostr_send_button
31+
property variant dialog
32+
text: qsTr('Nostr')
33+
icon.source: Qt.resolvedUrl('../../../gui/icons/network.png')
34+
visible: Daemon.currentWallet.isMultisig && Daemon.currentWallet.walletType != '2fa'
35+
onClicked: {
36+
console.log('about to psbt nostr send')
37+
psbt_nostr_send_button.enabled = false
38+
AppController.plugin('psbt_nostr').sendPsbt(Daemon.currentWallet, dialog.text)
39+
}
40+
Connections {
41+
target: AppController ? AppController.plugin('psbt_nostr') : null
42+
function onSendPsbtFailed(message) {
43+
psbt_nostr_send_button.enabled = true
44+
var dialog = app.messageDialog.createObject(app, {
45+
text: qsTr('Sending PSBT to co-signer failed:\n%1').arg(message)
46+
})
47+
dialog.open()
48+
}
49+
}
50+
51+
}
52+
}
53+
54+
}

Diff for: electrum/plugins/psbt_nostr/qt.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from electrum.util import UserCancelled, event_listener, EventListener
3535
from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog
3636

37-
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now
37+
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet
3838

3939
if TYPE_CHECKING:
4040
from electrum.gui.qt import ElectrumGui
@@ -141,5 +141,5 @@ def on_receive(self, pubkey, event_id, tx):
141141
_("An transaction was received from your cosigner.") + '\n' +
142142
_("Do you want to open it now?")):
143143
return
144-
self.known_events[event_id] = now()
144+
self.mark_event_rcvd(event_id)
145145
show_transaction(tx, parent=window, prompt_if_unsaved=True)

0 commit comments

Comments
 (0)