qml: separate PI resolving from QEInvoiceParser
What changed, and why it matters
This commit is a code cleanup in Electrum's mobile/QML user interface. It splits the job of interpreting a payment string (like a Lightning invoice or LNURL link) into two parts: one that resolves/fetches remote details, and one that handles the resulting payment or withdrawal request. There is no indication in the commit that it fixes a security bug or vulnerability; it appears to be a refactor to support voucher/LNURL-withdrawal flows more cleanly.
No security action required. Treat as routine refactor. If auditing, verify that the new signal/slot wiring does not introduce race conditions between PI resolution and UI state, and that error paths in QEPIResolver still clear busy state correctly.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change refactors QML GUI handling of PaymentIdentifiers. It introduces QEPIResolver to own the ‘recipient’ property and the resolve_pi() flow, emitting invoiceResolved or requestResolved signals. QEInvoiceParser now receives already-resolved PaymentIdentifiers via fromResolvedPaymentIdentifier and only handles sendable invoices (BOLT11, onchain, LNURL-pay, etc.). LNURL-withdraw (LNURLW) handling moves into a new QERequestDetails path, with LnurlWithdrawRequestDialog now binding to requestDetails instead of invoiceParser. A debug log line is added to lnurl.py. No security-relevant bug fix is visible in the diff.
Changed components
electrum/gui/qml/qeinvoice.pyelectrum/gui/qml/qepiresolver.pyelectrum/gui/qml/qerequestdetails.pyelectrum/gui/qml/components/LnurlWithdrawRequestDialog.qmlelectrum/gui/qml/components/SendDialog.qmlelectrum/gui/qml/components/WalletMainView.qmlelectrum/gui/qml/qeapp.pyelectrum/lnurl.pyInspect captured patch +287 / −160
diff --git a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
index 63eac2f..d923866 100644
--- a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
+++ b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
@@ -13,13 +13,14 @@ ElDialog {
title: qsTr('LNURL Withdraw request')
iconSource: '../../../icons/link.png'
- property InvoiceParser invoiceParser
+ property Wallet wallet: Daemon.currentWallet
+ property RequestDetails requestDetails
padding: 0
- property int walletCanReceive: invoiceParser.wallet.lightningCanReceive.satsInt
- property int providerMinWithdrawable: parseInt(invoiceParser.lnurlData['min_withdrawable_sat'])
- property int providerMaxWithdrawable: parseInt(invoiceParser.lnurlData['max_withdrawable_sat'])
+ property int walletCanReceive: 0
+ property int providerMinWithdrawable: parseInt(requestDetails.lnurlData['min_withdrawable_sat'])
+ property int providerMaxWithdrawable: parseInt(requestDetails.lnurlData['max_withdrawable_sat'])
property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1)
property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive)
property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive
@@ -30,6 +31,12 @@ ElDialog {
amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable
property bool valid: amountValid
+ Component.onCompleted: {
+ // Initialize walletCanReceive (instead of binding wallet.lightningCanReceive.satsInt)
+ // to prevent binding loop if wallet.lightningCanReceive.satsInt changes
+ walletCanReceive = wallet.lightningCanReceive.satsInt
+ }
+
ColumnLayout {
width: parent.width
@@ -89,17 +96,17 @@ ElDialog {
}
Label {
Layout.fillWidth: true
- text: invoiceParser.lnurlData['domain']
+ text: requestDetails.lnurlData['domain']
}
Label {
text: qsTr('Description')
color: Material.accentColor
- visible: invoiceParser.lnurlData['default_description']
+ visible: requestDetails.lnurlData['default_description']
}
Label {
Layout.fillWidth: true
- text: invoiceParser.lnurlData['default_description']
- visible: invoiceParser.lnurlData['default_description']
+ text: requestDetails.lnurlData['default_description']
+ visible: requestDetails.lnurlData['default_description']
wrapMode: Text.Wrap
}
@@ -117,9 +124,6 @@ ElDialog {
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
color: Material.foreground // override gray-out on disabled
fiatfield: amountFiat
- onTextAsSatsChanged: {
- invoiceParser.amountOverride = textAsSats
- }
}
Label {
text: Config.baseUnit
@@ -150,12 +154,12 @@ ElDialog {
Layout.fillWidth: true
text: qsTr('Withdraw...')
icon.source: '../../icons/confirmed.png'
- enabled: valid
+ enabled: valid && !requestDetails.busy
onClicked: {
- invoiceParser.lnurlRequestWithdrawal()
- dialog.close()
+ var satsAmount = amountBtc.textAsSats.satsInt;
+ requestDetails.lnurlRequestWithdrawal(satsAmount);
+ dialog.close();
}
}
}
-
}
diff --git a/electrum/gui/qml/components/SendDialog.qml b/electrum/gui/qml/components/SendDialog.qml
index f747111..e17c45c 100644
--- a/electrum/gui/qml/components/SendDialog.qml
+++ b/electrum/gui/qml/components/SendDialog.qml
@@ -12,6 +12,7 @@ ElDialog {
id: dialog
property InvoiceParser invoiceParser
+ property PIResolver piResolver
signal txFound(data: string)
signal channelBackupFound(data: string)
@@ -36,7 +37,7 @@ ElDialog {
} else if (Daemon.currentWallet.isValidChannelBackup(data)) {
channelBackupFound(data)
} else {
- invoiceParser.recipient = data
+ piResolver.recipient = data
}
}
@@ -71,7 +72,7 @@ ElDialog {
FlatButton {
Layout.fillWidth: true
Layout.preferredWidth: 1
- enabled: !invoiceParser.busy
+ enabled: !invoiceParser.busy && !piResolver.busy
icon.source: '../../icons/copy_bw.png'
text: qsTr('Paste')
onClicked: {
diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml
index fb3387f..d8f44ce 100644
--- a/electrum/gui/qml/components/WalletMainView.qml
+++ b/electrum/gui/qml/components/WalletMainView.qml
@@ -36,7 +36,7 @@ Item {
function openSendDialog() {
// Qt based send dialog if not on android
if (!AppController.isAndroid()) {
- _sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser})
+ _sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser, piResolver: piResolver})
_sendDialog.open()
return
}
@@ -61,7 +61,7 @@ Item {
})
dialog.open()
} else {
- invoiceParser.recipient = data
+ piResolver.recipient = data
}
//scanner.destroy() // TODO
})
@@ -362,7 +362,7 @@ Item {
Layout.preferredWidth: 1
icon.source: '../../icons/tab_send.png'
text: qsTr('Send')
- enabled: !invoiceParser.busy
+ enabled: !invoiceParser.busy && !piResolver.busy && !requestDetails.busy
onClicked: openSendDialog()
onPressAndHold: {
Config.userKnowsPressAndHold = true
@@ -373,6 +373,45 @@ Item {
}
}
+ PIResolver {
+ id: piResolver
+ wallet: Daemon.currentWallet
+
+ onResolveError: (code, message) => {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Error'),
+ iconSource: Qt.resolvedUrl('../../icons/warning.png'),
+ text: message
+ })
+ dialog.open()
+ }
+
+ Component.onCompleted: {
+ piResolver.invoiceResolved.connect(invoiceParser.fromResolvedPaymentIdentifier)
+ piResolver.requestResolved.connect(requestDetails.fromResolvedPaymentIdentifier)
+ }
+ }
+
+ RequestDetails {
+ id: requestDetails
+ wallet: Daemon.currentWallet
+ onNeedsLNURLUserInput: {
+ closeSendDialog()
+ var dialog = lnurlWithdrawDialog.createObject(app, {
+ requestDetails: requestDetails
+ })
+ dialog.open()
+ }
+ onLnurlError: (code, message) => {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Error'),
+ iconSource: Qt.resolvedUrl('../../icons/warning.png'),
+ text: message
+ })
+ dialog.open()
+ }
+ }
+
Invoice {
id: invoice
wallet: Daemon.currentWallet
@@ -420,17 +459,12 @@ Item {
})
dialog.open()
}
-
onLnurlRetrieved: {
closeSendDialog()
if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) {
var dialog = lnurlPayDialog.createObject(app, {
invoiceParser: invoiceParser
})
- } else if (invoiceParser.invoiceType === Invoice.Type.LNURLWithdrawRequest) {
- var dialog = lnurlWithdrawDialog.createObject(app, {
- invoiceParser: invoiceParser
- })
} else {
console.log("Unsupported LNURL type:", invoiceParser.invoiceType)
return
@@ -460,7 +494,7 @@ Item {
_intentUri = uri
return
}
- invoiceParser.recipient = uri
+ piResolver.recipient = uri
}
}
@@ -469,7 +503,7 @@ Item {
function onWalletLoaded() {
infobanner.hide() // start hidden when switching wallets
if (_intentUri) {
- invoiceParser.recipient = _intentUri
+ piResolver.recipient = _intentUri
_intentUri = ''
}
}
diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py
index 45e9fea..dadc5d2 100644
--- a/electrum/gui/qml/qeapp.py
+++ b/electrum/gui/qml/qeapp.py
@@ -33,6 +33,7 @@ from .qebitcoin import QEBitcoin
from .qefx import QEFX
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider
from .qeinvoice import QEInvoice, QEInvoiceParser
+from .qepiresolver import QEPIResolver
from .qerequestdetails import QERequestDetails
from .qetypes import QEAmount, QEBytes
from .qeaddressdetails import QEAddressDetails
@@ -439,6 +440,7 @@ class ElectrumQmlApplication(QGuiApplication):
qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner')
qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX')
qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer')
+ qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver')
qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice')
qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser')
qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails')
diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py
index 5d18dc4..a5f2a77 100644
--- a/electrum/gui/qml/qeinvoice.py
+++ b/electrum/gui/qml/qeinvoice.py
@@ -14,7 +14,7 @@ from electrum.invoices import (
)
from electrum.transaction import PartialTxOutput, TxOutput
from electrum.lnutil import format_short_channel_id
-from electrum.lnurl import LNURLData, LNURL3Data, LNURL6Data, request_lnurl_withdraw_callback, LNURLError
+from electrum.lnurl import LNURL6Data
from electrum.bitcoin import COIN, address_to_script
from electrum.paymentrequest import PaymentRequest
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
@@ -23,7 +23,6 @@ from electrum.network import Network
from .qetypes import QEAmount
from .qewallet import QEWallet
from .util import status_update_timer_interval, QtEventListener, event_listener
-from ...fee_policy import FeePolicy
from ...util import InvoiceError
@@ -34,7 +33,6 @@ class QEInvoice(QObject, QtEventListener):
OnchainInvoice = 0
LightningInvoice = 1
LNURLPayRequest = 2
- LNURLWithdrawRequest = 3
@pyqtEnum
class Status(IntEnum):
@@ -453,26 +451,19 @@ class QEInvoiceParser(QEInvoice):
def __init__(self, parent=None):
super().__init__(parent)
- self._recipient = ''
self._pi = None
self._lnurlData = None
self._busy = False
self.clear()
- recipientChanged = pyqtSignal()
- @pyqtProperty(str, notify=recipientChanged)
- def recipient(self):
- return self._recipient
-
- @recipient.setter
- def recipient(self, recipient: str):
+ @pyqtSlot(object)
+ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
self.canPay = False
- self._recipient = recipient
self.amountOverride = QEAmount()
- if recipient:
- self.validateRecipient(recipient)
- self.recipientChanged.emit()
+ if resolved_pi:
+ assert not resolved_pi.need_resolve()
+ self.validateRecipient(resolved_pi)
@pyqtProperty('QVariantMap', notify=lnurlRetrieved)
def lnurlData(self):
@@ -480,7 +471,7 @@ class QEInvoiceParser(QEInvoice):
@pyqtProperty(bool, notify=lnurlRetrieved)
def isLnurlPay(self):
- return self._lnurlData is not None and self.invoiceType == QEInvoice.Type.LNURLPayRequest
+ return self._lnurlData is not None
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
@@ -488,7 +479,6 @@ class QEInvoiceParser(QEInvoice):
@pyqtSlot()
def clear(self):
- self.recipient = ''
self.setInvoiceType(QEInvoice.Type.Invalid)
self._lnurlData = None
self.canSave = False
@@ -515,12 +505,6 @@ class QEInvoiceParser(QEInvoice):
self._effectiveInvoice = None
self.invoiceChanged.emit()
- def setValidLNURLWithdrawRequest(self):
- self._logger.debug('setValidLNURLWithdrawRequest')
- self.setInvoiceType(QEInvoice.Type.LNURLWithdrawRequest)
- self._effectiveInvoice = None
- self.invoiceChanged.emit()
-
def create_onchain_invoice(self, outputs, message, payment_request, uri):
return self._wallet.wallet.create_invoice(
outputs=outputs,
@@ -543,17 +527,18 @@ class QEInvoiceParser(QEInvoice):
else:
self.validationError.emit('unknown', f'invoice error:\n{pr.error}')
- def validateRecipient(self, recipient):
- if not recipient:
+ def validateRecipient(self, pi: PaymentIdentifier):
+ if not pi:
self.setInvoiceType(QEInvoice.Type.Invalid)
return
- self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
- if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
- PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
- PaymentIdentifierType.LNURL,
- PaymentIdentifierType.EMAILLIKE,
- PaymentIdentifierType.DOMAINLIKE]:
+ self._pi = pi
+ if not self._pi.is_valid() or self._pi.type not in [
+ PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
+ PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
+ PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP,
+ PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE
+ ]:
self.validationError.emit('unknown', _('Unknown invoice'))
return
@@ -566,16 +551,13 @@ class QEInvoiceParser(QEInvoice):
self._update_from_payment_identifier()
def _update_from_payment_identifier(self):
- if self._pi.need_resolve():
- self.resolve_pi()
- return
+ assert not self._pi.need_resolve(), "Should have been resolved by QEPIResolver"
if self._pi.type in [
PaymentIdentifierType.LNURLP,
- PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNADDR,
]:
- self.on_lnurl(self._pi.lnurl_data)
+ self.on_lnurl_pay(self._pi.lnurl_data)
return
if self._pi.type == PaymentIdentifierType.BIP70:
@@ -624,57 +606,20 @@ class QEInvoiceParser(QEInvoice):
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
- def resolve_pi(self):
- assert self._pi.need_resolve()
-
- def on_finished(pi: PaymentIdentifier):
- self._busy = False
- self.busyChanged.emit()
-
- if pi.is_error():
- if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
- msg = _('Could not resolve address')
- elif pi.type == PaymentIdentifierType.LNURL:
- msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
- elif pi.type == PaymentIdentifierType.BIP70:
- msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
- else:
- msg = _('Could not resolve')
- self.validationError.emit('resolve', msg)
- else:
- self._update_from_payment_identifier()
-
- self._busy = True
- self.busyChanged.emit()
-
- self._pi.resolve(on_finished=on_finished)
-
- def on_lnurl(self, lnurldata: LNURLData):
+ def on_lnurl_pay(self, lnurldata: LNURL6Data):
+ assert isinstance(lnurldata, LNURL6Data)
self._logger.debug('on_lnurl')
self._logger.debug(f'{repr(lnurldata)}')
- if isinstance(lnurldata, LNURL6Data):
- self._lnurlData = {
- 'domain': urlparse(lnurldata.callback_url).netloc,
- 'callback_url': lnurldata.callback_url,
- 'min_sendable_sat': lnurldata.min_sendable_sat,
- 'max_sendable_sat': lnurldata.max_sendable_sat,
- 'metadata_plaintext': lnurldata.metadata_plaintext,
- 'comment_allowed': lnurldata.comment_allowed,
- }
- self.setValidLNURLPayRequest()
- elif isinstance(lnurldata, LNURL3Data):
- self._lnurlData = {
- 'domain': urlparse(lnurldata.callback_url).netloc,
- 'callback_url': lnurldata.callback_url,
- 'min_withdrawable_sat': lnurldata.min_withdrawable_sat,
- 'max_withdrawable_sat': lnurldata.max_withdrawable_sat,
- 'default_description': lnurldata.default_description,
- 'k1': lnurldata.k1,
- }
- self.setValidLNURLWithdrawRequest()
- else:
- raise NotImplementedError(f"Invalid lnurl type in on_lnurl {lnurldata=}")
+ self._lnurlData = {
+ 'domain': urlparse(lnurldata.callback_url).netloc,
+ 'callback_url': lnurldata.callback_url,
+ 'min_sendable_sat': lnurldata.min_sendable_sat,
+ 'max_sendable_sat': lnurldata.max_sendable_sat,
+ 'metadata_plaintext': lnurldata.metadata_plaintext,
+ 'comment_allowed': lnurldata.comment_allowed,
+ }
+ self.setValidLNURLPayRequest()
self.lnurlRetrieved.emit()
@pyqtSlot()
@@ -707,54 +652,6 @@ class QEInvoiceParser(QEInvoice):
self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
- @pyqtSlot()
- def lnurlRequestWithdrawal(self):
- assert self._lnurlData
- assert self.invoiceType == QEInvoice.Type.LNURLWithdrawRequest
- self._logger.debug(f'{repr(self._lnurlData)}')
-
- amount_sat = self.amountOverride.satsInt
-
- try:
- key = self.wallet.wallet.create_request(
- amount_sat=amount_sat,
- message=self._lnurlData.get('default_description', ''),
- exp_delay=120,
- address=None,
- )
- req = self.wallet.wallet.get_request(key)
- _lnaddr, b11_invoice = self.wallet.wallet.lnworker.get_bolt11_invoice(
- payment_hash=req.payment_hash,
- amount_msat=req.get_amount_msat(),
- message=req.get_message(),
- expiry=req.exp,
- fallback_address=None
- )
- except Exception as e:
- self._logger.exception('')
- self.lnurlError.emit(
- 'lnurl',
- _("Failed to create payment request for withdrawal: {}").format(str(e))
- )
- return
-
- self._busy = True
- self.busyChanged.emit()
-
- coro = request_lnurl_withdraw_callback(
- callback_url=self._lnurlData['callback_url'],
- k1=self._lnurlData['k1'],
- bolt_11=b11_invoice,
- )
- try:
- Network.run_from_another_thread(coro)
- except LNURLError as e:
- self.lnurlError.emit('lnurl', str(e))
-
- self._busy = False
- self.busyChanged.emit()
-
-
def on_lnurl_invoice(self, orig_amount, invoice):
self._logger.debug('on_lnurl_invoice')
self._logger.debug(f'{repr(invoice)}')
@@ -763,7 +660,9 @@ class QEInvoiceParser(QEInvoice):
if orig_amount * 1000 != invoice.amount_msat: # TODO msat precision can cause trouble here
raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount')
- self.recipient = invoice.lightning_invoice
+ self.fromResolvedPaymentIdentifier(
+ PaymentIdentifier(self._wallet.wallet, invoice.lightning_invoice)
+ )
@pyqtSlot(result=bool)
def saveInvoice(self) -> bool:
diff --git a/electrum/gui/qml/qepiresolver.py b/electrum/gui/qml/qepiresolver.py
new file mode 100644
index 0000000..9699a4b
--- /dev/null
+++ b/electrum/gui/qml/qepiresolver.py
@@ -0,0 +1,100 @@
+from enum import IntEnum
+from typing import Optional
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
+
+from electrum.logging import get_logger
+from electrum.i18n import _
+from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
+
+from .qewallet import QEWallet
+
+
+class QEPIResolver(QObject):
+ """Intended to handle a user input Payment Identifier (PI), resolve it if necessary, then
+ allow to distinguish between a Request/voucher/lnurlw and an Invoice (e.g. b11 or lnurlp)."""
+ _logger = get_logger(__name__)
+
+ busyChanged = pyqtSignal()
+ resolveError = pyqtSignal([str, str], arguments=['code', 'message'])
+ invoiceResolved = pyqtSignal(object)
+ requestResolved = pyqtSignal(object)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._recipient = None
+ self._pi = None
+ self._busy = False
+
+ self.clear()
+
+ recipientChanged = pyqtSignal()
+ @pyqtProperty(str, notify=recipientChanged)
+ def recipient(self) -> Optional[str]:
+ return self._recipient
+
+ @recipient.setter
+ def recipient(self, recipient: str) -> None:
+ self.clear()
+ if not recipient:
+ return
+ self._recipient = recipient
+ self.recipientChanged.emit()
+ self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
+ if self._pi.need_resolve():
+ self.resolve_pi()
+ else:
+ # assuming if the PI is an invoice if it doesn't need resolving
+ # as there are no request types that do not need resolving currently
+ self.invoiceResolved.emit(self._pi)
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QEWallet, notify=walletChanged)
+ def wallet(self) -> Optional[QEWallet]:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet) -> None:
+ self._wallet = wallet
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self):
+ return self._busy
+
+ def resolve_pi(self) -> None:
+ assert self._pi is not None
+ assert self._pi.need_resolve()
+
+ def on_finished(pi: PaymentIdentifier):
+ self._busy = False
+ self.busyChanged.emit()
+
+ if pi.is_error():
+ if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
+ msg = _('Could not resolve address')
+ elif pi.type == PaymentIdentifierType.LNURL:
+ msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
+ elif pi.type == PaymentIdentifierType.BIP70:
+ msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
+ else:
+ msg = _('Could not resolve')
+ self.resolveError.emit('resolve', msg)
+ else:
+ if pi.type == PaymentIdentifierType.LNURLW:
+ self.requestResolved.emit(pi)
+ else:
+ self.invoiceResolved.emit(pi)
+
+ self._busy = True
+ self.busyChanged.emit()
+
+ self._pi.resolve(on_finished=on_finished)
+
+ def clear(self) -> None:
+ self._recipient = None
+ self._pi = None
+ self._busy = False
+ self.busyChanged.emit()
+ self.recipientChanged.emit()
diff --git a/electrum/gui/qml/qerequestdetails.py b/electrum/gui/qml/qerequestdetails.py
index 6f5e35d..e12f463 100644
--- a/electrum/gui/qml/qerequestdetails.py
+++ b/electrum/gui/qml/qerequestdetails.py
@@ -1,5 +1,6 @@
from enum import IntEnum
from typing import Optional
+from urllib.parse import urlparse
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum
@@ -8,6 +9,10 @@ from electrum.invoices import (
PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, LN_EXPIRY_NEVER
)
from electrum.lnutil import MIN_FUNDING_SAT
+from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
+from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierType
+from electrum.i18n import _
+from electrum.network import Network
from .qewallet import QEWallet
from .qetypes import QEAmount
@@ -31,6 +36,9 @@ class QERequestDetails(QObject, QtEventListener):
detailsChanged = pyqtSignal() # generic request properties changed signal
statusChanged = pyqtSignal()
+ needsLNURLUserInput = pyqtSignal()
+ lnurlError = pyqtSignal(str, str) # code, message
+ busyChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
@@ -41,6 +49,9 @@ class QERequestDetails(QObject, QtEventListener):
self._timer = None
self._amount = None
+ self._lnurlData = None # type: Optional[dict]
+ self._busy = False
+
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.updateStatusString)
@@ -150,6 +161,14 @@ class QERequestDetails(QObject, QtEventListener):
def bip21(self):
return self._req.get_bip21_URI() if self._req else ''
+ @pyqtProperty('QVariantMap', notify=detailsChanged)
+ def lnurlData(self) -> Optional[dict]:
+ return self._lnurlData
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self):
+ return self._busy
+
def initRequest(self):
if self._wallet is None or self._key is None:
return
@@ -181,3 +200,70 @@ class QERequestDetails(QObject, QtEventListener):
self.statusChanged.emit()
self.set_status_timer()
+ @pyqtSlot(object)
+ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
+ """
+ Called when a payment identifier is resolved to a request (currently only LNURLW, but
+ could also be used for other "voucher" type input like redeeming ecash tokens or
+ some bolt12 thing).
+ """
+ if not self._wallet:
+ return
+ if resolved_pi.type == PaymentIdentifierType.LNURLW:
+ lnurldata = resolved_pi.lnurl_data
+ assert isinstance(lnurldata, LNURL3Data), "Expected LNURL3Data type"
+ self._lnurlData = {
+ 'domain': urlparse(lnurldata.callback_url).netloc,
+ 'callback_url': lnurldata.callback_url,
+ 'min_withdrawable_sat': lnurldata.min_withdrawable_sat,
+ 'max_withdrawable_sat': lnurldata.max_withdrawable_sat,
+ 'default_description': lnurldata.default_description,
+ 'k1': lnurldata.k1,
+ }
+ self.needsLNURLUserInput.emit()
+ else:
+ raise NotImplementedError("Cannot request withdrawal for this payment identifier type")
+
+ @pyqtSlot(int)
+ def lnurlRequestWithdrawal(self, amount_sat: int) -> None:
+ assert self._lnurlData
+ self._logger.debug(f'requesting lnurlw: {repr(self._lnurlData)}')
+
+ try:
+ key = self.wallet.wallet.create_request(
+ amount_sat=amount_sat,
+ message=self._lnurlData.get('default_description', ''),
+ exp_delay=120,
+ address=None,
+ )
+ req = self.wallet.wallet.get_request(key)
+ _lnaddr, b11_invoice = self.wallet.wallet.lnworker.get_bolt11_invoice(
+ payment_hash=req.payment_hash,
+ amount_msat=req.get_amount_msat(),
+ message=req.get_message(),
+ expiry=req.exp,
+ fallback_address=None
+ )
+ except Exception as e:
+ self._logger.exception('')
+ self.lnurlError.emit(
+ 'lnurl',
+ _("Failed to create payment request for withdrawal: {}").format(str(e))
+ )
+ return
+
+ self._busy = True
+ self.busyChanged.emit()
+
+ coro = request_lnurl_withdraw_callback(
+ callback_url=self._lnurlData['callback_url'],
+ k1=self._lnurlData['k1'],
+ bolt_11=b11_invoice,
+ )
+ try:
+ Network.run_from_another_thread(coro)
+ except LNURLError as e:
+ self.lnurlError.emit('lnurl', str(e))
+ finally:
+ self._busy = False
+ self.busyChanged.emit()
diff --git a/electrum/lnurl.py b/electrum/lnurl.py
index a3676b0..3f035ba 100644
--- a/electrum/lnurl.py
+++ b/electrum/lnurl.py
@@ -230,6 +230,7 @@ async def callback_lnurl(url: str, params: dict) -> dict:
raise LNURLError(f"Client error: {e}") from e
try:
response = json.loads(response_raw)
+ _logger.debug(f"lnurl response: {response}")
except json.JSONDecodeError:
raise LNURLError(f"Invalid response from LNURL server")
Why this scored 10/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.