1
0

plugins: psbt_nostr: let GUI handle a received PSBTs one by one by pausing receiving additional PSBTs until PSBT dialog is closed.

Accepting a PSBT opens the Tx dialog and pauses receiving additional PSBTs until the Tx dialog is closed.
Rejecting a PSBT will start a cooldown and accept all pending PSBTs into the history for later inspection.
This commit is contained in:
Sander van Grieken
2025-04-15 13:58:49 +02:00
parent 3ff84f08a6
commit 60bd6327ce
6 changed files with 106 additions and 27 deletions

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

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

@@ -79,11 +79,14 @@ class CosignerWallet(Logger):
def __init__(self, wallet: 'Multisig_Wallet'):
assert isinstance(wallet, Multisig_Wallet)
self.wallet = wallet
self.network = wallet.network
self.config = self.wallet.config
Logger.__init__(self)
self.network = wallet.network
self.config = self.wallet.config
self.pending = asyncio.Event()
self.known_events = wallet.db.get_dict('cosigner_events')
for k, v in list(self.known_events.items()):
if v < now() - self.KEEP_DELAY:
self.logger.info(f'deleting old event {k}')
@@ -189,6 +192,8 @@ class CosignerWallet(Logger):
return
self.logger.info(f"received PSBT from {event.pubkey}")
trigger_callback('psbt_nostr_received', self.wallet, event.pubkey, event.id, tx)
await self.pending.wait()
self.pending.clear()
def diagnostic_name(self):
return self.wallet.diagnostic_name()
@@ -203,8 +208,10 @@ class CosignerWallet(Logger):
# note that tx could also be unrelated from wallet?... (not ismine inputs)
return True
def mark_event_rcvd(self, event_id):
def mark_pending_event_rcvd(self, event_id):
self.logger.debug('marking event rcvd')
self.known_events[event_id] = now()
self.pending.set()
def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]:
messages = []
@@ -223,3 +230,19 @@ class CosignerWallet(Logger):
def on_receive(self, pubkey, event_id, tx):
raise NotImplementedError()
def add_transaction_to_wallet(self, tx, *, on_failure=None, on_success=None):
try:
# TODO: adding tx should be handled more gracefully here:
# 1) don't replace tx with same tx with less signatures
# 2) we could combine signatures if tx will become more complete
# 3) ... more heuristics?
if not self.wallet.adb.add_transaction(tx):
# TODO: instead of bool return value, we could use specific fail reason exceptions here
raise Exception('transaction was not added')
except Exception as e:
if on_failure:
on_failure(str(e))
else:
if on_success:
on_success()

View File

@@ -36,12 +36,14 @@ from electrum.util import EventListener, event_listener
from electrum.gui.qml.qewallet import QEWallet
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
from electrum.gui.qml import ElectrumQmlApplication
USER_PROMPT_COOLDOWN = 10
class QReceiveSignalObject(QObject):
def __init__(self, plugin: 'Plugin'):
@@ -70,6 +72,13 @@ class QReceiveSignalObject(QObject):
return
cosigner_wallet.accept_psbt(event_id)
@pyqtSlot(QEWallet, str)
def rejectPsbt(self, wallet: 'QEWallet', event_id: str):
cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet]
if not cosigner_wallet:
return
cosigner_wallet.reject_psbt(event_id)
class Plugin(PsbtNostrPlugin):
def __init__(self, parent, config, name):
@@ -103,13 +112,18 @@ class QmlCosignerWallet(EventListener, CosignerWallet):
self.plugin = plugin
self.register_callbacks()
self.pending = None
self.tx = None
self.user_prompt_cooldown = None
@event_listener
def on_event_psbt_nostr_received(self, wallet, pubkey, event, tx: 'PartialTransaction'):
def on_event_psbt_nostr_received(self, wallet, pubkey, event_id, tx: 'PartialTransaction'):
if self.wallet == wallet:
self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event, tx.serialize())
self.on_receive(pubkey, event, tx)
self.tx = tx
if not (self.user_prompt_cooldown and self.user_prompt_cooldown > now()):
self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event_id, tx.serialize())
else:
self.mark_pending_event_rcvd(event_id)
self.add_transaction_to_wallet(self.tx, on_failure=self.on_add_fail)
def close(self):
super().close()
@@ -133,11 +147,13 @@ class QmlCosignerWallet(EventListener, CosignerWallet):
except Exception as e:
self.plugin.so.sendPsbtFailed.emit(str(e))
def on_receive(self, pubkey, event_id, tx):
self.pending = (pubkey, event_id, tx)
def accept_psbt(self, event_id):
self.mark_pending_event_rcvd(event_id)
def accept_psbt(self, my_event_id):
pubkey, event_id, tx = self.pending
if event_id == my_event_id:
self.mark_event_rcvd(event_id)
self.pending = None
def reject_psbt(self, event_id):
self.user_prompt_cooldown = now() + USER_PROMPT_COOLDOWN
self.mark_pending_event_rcvd(event_id)
self.add_transaction_to_wallet(self.tx, on_failure=self.on_add_fail)
def on_add_fail(self):
self.logger.error('failed to add tx to wallet')

View File

@@ -16,10 +16,15 @@ Item {
yesno: true
})
dialog.accepted.connect(function () {
app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), {
var page = app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), {
rawtx: tx
})
target.acceptPsbt(Daemon.currentWallet, event)
page.closed.connect(function () {
target.acceptPsbt(Daemon.currentWallet, event)
})
})
dialog.rejected.connect(function () {
target.rejectPsbt(Daemon.currentWallet, event)
})
dialog.open()
}

View File

@@ -23,6 +23,7 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import asyncio
from functools import partial
from typing import TYPE_CHECKING, List, Tuple, Optional
from PyQt6.QtCore import QObject, pyqtSignal
@@ -34,11 +35,13 @@ from electrum.wallet import Multisig_Wallet, Abstract_Wallet
from electrum.util import UserCancelled, event_listener, EventListener
from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet
from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now
if TYPE_CHECKING:
from electrum.gui.qt.main_window import ElectrumWindow
USER_PROMPT_COOLDOWN = 10
class QReceiveSignalObject(QObject):
cosignerReceivedPsbt = pyqtSignal(str, str, object)
@@ -80,6 +83,7 @@ class QtCosignerWallet(EventListener, CosignerWallet):
self.obj = QReceiveSignalObject()
self.obj.cosignerReceivedPsbt.connect(self.on_receive)
self.register_callbacks()
self.user_prompt_cooldown = None
def close(self):
super().close()
@@ -92,7 +96,7 @@ class QtCosignerWallet(EventListener, CosignerWallet):
def hook_transaction_dialog(self, d: 'TxDialog'):
d.cosigner_send_button = b = QPushButton(_("Send to cosigner"))
b.clicked.connect(lambda: self.send_psbt(d.tx))
b.clicked.connect(lambda: self.send_to_cosigners(d.tx))
d.buttons.insert(0, b)
b.setVisible(False)
@@ -108,6 +112,14 @@ class QtCosignerWallet(EventListener, CosignerWallet):
else:
d.cosigner_send_button.setVisible(False)
def send_to_cosigners(self, tx):
def ok():
self.logger.debug('ADDED')
def nok(msg: str):
self.logger.debug(f'NOT ADDED: {msg}')
self.add_transaction_to_wallet(tx, on_success=ok, on_failure=nok)
self.send_psbt(tx)
def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None):
if not messages:
return
@@ -127,10 +139,21 @@ class QtCosignerWallet(EventListener, CosignerWallet):
_("Your transaction was sent to your cosigners via Nostr.") + '\n\n' + txid)
def on_receive(self, pubkey, event_id, tx):
window = self.window
if not window.question(
_("An transaction was received from your cosigner.") + '\n' +
_("Do you want to open it now?")):
return
self.mark_event_rcvd(event_id)
show_transaction(tx, parent=window, prompt_if_unsaved=True)
open_now = False
if not (self.user_prompt_cooldown and self.user_prompt_cooldown > now()):
open_now = self.window.question(
_("A transaction was received from your cosigner ({}).").format(str(event_id)[0:8]) + '\n' +
_("Do you want to open it now?"))
if not open_now:
self.user_prompt_cooldown = now() + USER_PROMPT_COOLDOWN
if open_now:
show_transaction(tx, parent=self.window, prompt_if_unsaved=True, on_closed=partial(self.on_tx_dialog_closed, event_id))
else:
self.mark_pending_event_rcvd(event_id)
self.add_transaction_to_wallet(tx, on_failure=self.on_add_fail)
def on_tx_dialog_closed(self, event_id):
self.mark_pending_event_rcvd(event_id)
def on_add_fail(self, msg: str):
self.window.show_error(msg)