1
0

qml: separate PI resolving from QEInvoiceParser

separates the resolving step from the QEInvoiceParser so the 'recipient'
can be resolved first and then either an QEInvoiceParser can be used if
it is a sending request that has been resolved (invoice, address,
lnurlp, ...), or RequestDetails can be used if the resolved 'recipient'
turns out to be a voucher/LNURLW string.

# Conflicts:
#	electrum/gui/qml/qeinvoice.py
This commit is contained in:
f321x
2025-07-15 00:17:42 +02:00
parent 72dfc61d9c
commit b90090e2dd
8 changed files with 287 additions and 160 deletions

View File

@@ -13,13 +13,14 @@ ElDialog {
title: qsTr('LNURL Withdraw request') title: qsTr('LNURL Withdraw request')
iconSource: '../../../icons/link.png' iconSource: '../../../icons/link.png'
property InvoiceParser invoiceParser property Wallet wallet: Daemon.currentWallet
property RequestDetails requestDetails
padding: 0 padding: 0
property int walletCanReceive: invoiceParser.wallet.lightningCanReceive.satsInt property int walletCanReceive: 0
property int providerMinWithdrawable: parseInt(invoiceParser.lnurlData['min_withdrawable_sat']) property int providerMinWithdrawable: parseInt(requestDetails.lnurlData['min_withdrawable_sat'])
property int providerMaxWithdrawable: parseInt(invoiceParser.lnurlData['max_withdrawable_sat']) property int providerMaxWithdrawable: parseInt(requestDetails.lnurlData['max_withdrawable_sat'])
property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1) property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1)
property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive) property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive)
property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive
@@ -30,6 +31,12 @@ ElDialog {
amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable
property bool valid: amountValid 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 { ColumnLayout {
width: parent.width width: parent.width
@@ -89,17 +96,17 @@ ElDialog {
} }
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: invoiceParser.lnurlData['domain'] text: requestDetails.lnurlData['domain']
} }
Label { Label {
text: qsTr('Description') text: qsTr('Description')
color: Material.accentColor color: Material.accentColor
visible: invoiceParser.lnurlData['default_description'] visible: requestDetails.lnurlData['default_description']
} }
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: invoiceParser.lnurlData['default_description'] text: requestDetails.lnurlData['default_description']
visible: invoiceParser.lnurlData['default_description'] visible: requestDetails.lnurlData['default_description']
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
@@ -117,9 +124,6 @@ ElDialog {
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable) enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
color: Material.foreground // override gray-out on disabled color: Material.foreground // override gray-out on disabled
fiatfield: amountFiat fiatfield: amountFiat
onTextAsSatsChanged: {
invoiceParser.amountOverride = textAsSats
}
} }
Label { Label {
text: Config.baseUnit text: Config.baseUnit
@@ -150,12 +154,12 @@ ElDialog {
Layout.fillWidth: true Layout.fillWidth: true
text: qsTr('Withdraw...') text: qsTr('Withdraw...')
icon.source: '../../icons/confirmed.png' icon.source: '../../icons/confirmed.png'
enabled: valid enabled: valid && !requestDetails.busy
onClicked: { onClicked: {
invoiceParser.lnurlRequestWithdrawal() var satsAmount = amountBtc.textAsSats.satsInt;
dialog.close() requestDetails.lnurlRequestWithdrawal(satsAmount);
dialog.close();
} }
} }
} }
} }

View File

@@ -12,6 +12,7 @@ ElDialog {
id: dialog id: dialog
property InvoiceParser invoiceParser property InvoiceParser invoiceParser
property PIResolver piResolver
signal txFound(data: string) signal txFound(data: string)
signal channelBackupFound(data: string) signal channelBackupFound(data: string)
@@ -36,7 +37,7 @@ ElDialog {
} else if (Daemon.currentWallet.isValidChannelBackup(data)) { } else if (Daemon.currentWallet.isValidChannelBackup(data)) {
channelBackupFound(data) channelBackupFound(data)
} else { } else {
invoiceParser.recipient = data piResolver.recipient = data
} }
} }
@@ -71,7 +72,7 @@ ElDialog {
FlatButton { FlatButton {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredWidth: 1 Layout.preferredWidth: 1
enabled: !invoiceParser.busy enabled: !invoiceParser.busy && !piResolver.busy
icon.source: '../../icons/copy_bw.png' icon.source: '../../icons/copy_bw.png'
text: qsTr('Paste') text: qsTr('Paste')
onClicked: { onClicked: {

View File

@@ -36,7 +36,7 @@ Item {
function openSendDialog() { function openSendDialog() {
// Qt based send dialog if not on android // Qt based send dialog if not on android
if (!AppController.isAndroid()) { if (!AppController.isAndroid()) {
_sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser}) _sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser, piResolver: piResolver})
_sendDialog.open() _sendDialog.open()
return return
} }
@@ -61,7 +61,7 @@ Item {
}) })
dialog.open() dialog.open()
} else { } else {
invoiceParser.recipient = data piResolver.recipient = data
} }
//scanner.destroy() // TODO //scanner.destroy() // TODO
}) })
@@ -362,7 +362,7 @@ Item {
Layout.preferredWidth: 1 Layout.preferredWidth: 1
icon.source: '../../icons/tab_send.png' icon.source: '../../icons/tab_send.png'
text: qsTr('Send') text: qsTr('Send')
enabled: !invoiceParser.busy enabled: !invoiceParser.busy && !piResolver.busy && !requestDetails.busy
onClicked: openSendDialog() onClicked: openSendDialog()
onPressAndHold: { onPressAndHold: {
Config.userKnowsPressAndHold = true 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 { Invoice {
id: invoice id: invoice
wallet: Daemon.currentWallet wallet: Daemon.currentWallet
@@ -420,17 +459,12 @@ Item {
}) })
dialog.open() dialog.open()
} }
onLnurlRetrieved: { onLnurlRetrieved: {
closeSendDialog() closeSendDialog()
if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) { if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) {
var dialog = lnurlPayDialog.createObject(app, { var dialog = lnurlPayDialog.createObject(app, {
invoiceParser: invoiceParser invoiceParser: invoiceParser
}) })
} else if (invoiceParser.invoiceType === Invoice.Type.LNURLWithdrawRequest) {
var dialog = lnurlWithdrawDialog.createObject(app, {
invoiceParser: invoiceParser
})
} else { } else {
console.log("Unsupported LNURL type:", invoiceParser.invoiceType) console.log("Unsupported LNURL type:", invoiceParser.invoiceType)
return return
@@ -460,7 +494,7 @@ Item {
_intentUri = uri _intentUri = uri
return return
} }
invoiceParser.recipient = uri piResolver.recipient = uri
} }
} }
@@ -469,7 +503,7 @@ Item {
function onWalletLoaded() { function onWalletLoaded() {
infobanner.hide() // start hidden when switching wallets infobanner.hide() // start hidden when switching wallets
if (_intentUri) { if (_intentUri) {
invoiceParser.recipient = _intentUri piResolver.recipient = _intentUri
_intentUri = '' _intentUri = ''
} }
} }

View File

@@ -33,6 +33,7 @@ from .qebitcoin import QEBitcoin
from .qefx import QEFX from .qefx import QEFX
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider
from .qeinvoice import QEInvoice, QEInvoiceParser from .qeinvoice import QEInvoice, QEInvoiceParser
from .qepiresolver import QEPIResolver
from .qerequestdetails import QERequestDetails from .qerequestdetails import QERequestDetails
from .qetypes import QEAmount, QEBytes from .qetypes import QEAmount, QEBytes
from .qeaddressdetails import QEAddressDetails from .qeaddressdetails import QEAddressDetails
@@ -439,6 +440,7 @@ class ElectrumQmlApplication(QGuiApplication):
qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner') qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner')
qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX')
qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer')
qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver')
qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice')
qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser')
qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails')

View File

@@ -14,7 +14,7 @@ from electrum.invoices import (
) )
from electrum.transaction import PartialTxOutput, TxOutput from electrum.transaction import PartialTxOutput, TxOutput
from electrum.lnutil import format_short_channel_id 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.bitcoin import COIN, address_to_script
from electrum.paymentrequest import PaymentRequest from electrum.paymentrequest import PaymentRequest
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
@@ -23,7 +23,6 @@ from electrum.network import Network
from .qetypes import QEAmount from .qetypes import QEAmount
from .qewallet import QEWallet from .qewallet import QEWallet
from .util import status_update_timer_interval, QtEventListener, event_listener from .util import status_update_timer_interval, QtEventListener, event_listener
from ...fee_policy import FeePolicy
from ...util import InvoiceError from ...util import InvoiceError
@@ -34,7 +33,6 @@ class QEInvoice(QObject, QtEventListener):
OnchainInvoice = 0 OnchainInvoice = 0
LightningInvoice = 1 LightningInvoice = 1
LNURLPayRequest = 2 LNURLPayRequest = 2
LNURLWithdrawRequest = 3
@pyqtEnum @pyqtEnum
class Status(IntEnum): class Status(IntEnum):
@@ -453,26 +451,19 @@ class QEInvoiceParser(QEInvoice):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._recipient = ''
self._pi = None self._pi = None
self._lnurlData = None self._lnurlData = None
self._busy = False self._busy = False
self.clear() self.clear()
recipientChanged = pyqtSignal() @pyqtSlot(object)
@pyqtProperty(str, notify=recipientChanged) def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
def recipient(self):
return self._recipient
@recipient.setter
def recipient(self, recipient: str):
self.canPay = False self.canPay = False
self._recipient = recipient
self.amountOverride = QEAmount() self.amountOverride = QEAmount()
if recipient: if resolved_pi:
self.validateRecipient(recipient) assert not resolved_pi.need_resolve()
self.recipientChanged.emit() self.validateRecipient(resolved_pi)
@pyqtProperty('QVariantMap', notify=lnurlRetrieved) @pyqtProperty('QVariantMap', notify=lnurlRetrieved)
def lnurlData(self): def lnurlData(self):
@@ -480,7 +471,7 @@ class QEInvoiceParser(QEInvoice):
@pyqtProperty(bool, notify=lnurlRetrieved) @pyqtProperty(bool, notify=lnurlRetrieved)
def isLnurlPay(self): 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) @pyqtProperty(bool, notify=busyChanged)
def busy(self): def busy(self):
@@ -488,7 +479,6 @@ class QEInvoiceParser(QEInvoice):
@pyqtSlot() @pyqtSlot()
def clear(self): def clear(self):
self.recipient = ''
self.setInvoiceType(QEInvoice.Type.Invalid) self.setInvoiceType(QEInvoice.Type.Invalid)
self._lnurlData = None self._lnurlData = None
self.canSave = False self.canSave = False
@@ -515,12 +505,6 @@ class QEInvoiceParser(QEInvoice):
self._effectiveInvoice = None self._effectiveInvoice = None
self.invoiceChanged.emit() 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): def create_onchain_invoice(self, outputs, message, payment_request, uri):
return self._wallet.wallet.create_invoice( return self._wallet.wallet.create_invoice(
outputs=outputs, outputs=outputs,
@@ -543,17 +527,18 @@ class QEInvoiceParser(QEInvoice):
else: else:
self.validationError.emit('unknown', f'invoice error:\n{pr.error}') self.validationError.emit('unknown', f'invoice error:\n{pr.error}')
def validateRecipient(self, recipient): def validateRecipient(self, pi: PaymentIdentifier):
if not recipient: if not pi:
self.setInvoiceType(QEInvoice.Type.Invalid) self.setInvoiceType(QEInvoice.Type.Invalid)
return return
self._pi = PaymentIdentifier(self._wallet.wallet, recipient) self._pi = pi
if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21, if not self._pi.is_valid() or self._pi.type not in [
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
PaymentIdentifierType.LNURL, PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP,
PaymentIdentifierType.DOMAINLIKE]: PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE
]:
self.validationError.emit('unknown', _('Unknown invoice')) self.validationError.emit('unknown', _('Unknown invoice'))
return return
@@ -566,16 +551,13 @@ class QEInvoiceParser(QEInvoice):
self._update_from_payment_identifier() self._update_from_payment_identifier()
def _update_from_payment_identifier(self): def _update_from_payment_identifier(self):
if self._pi.need_resolve(): assert not self._pi.need_resolve(), "Should have been resolved by QEPIResolver"
self.resolve_pi()
return
if self._pi.type in [ if self._pi.type in [
PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNADDR,
]: ]:
self.on_lnurl(self._pi.lnurl_data) self.on_lnurl_pay(self._pi.lnurl_data)
return return
if self._pi.type == PaymentIdentifierType.BIP70: if self._pi.type == PaymentIdentifierType.BIP70:
@@ -624,57 +606,20 @@ class QEInvoiceParser(QEInvoice):
self.setValidOnchainInvoice(invoice) self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit() self.validationSuccess.emit()
def resolve_pi(self): def on_lnurl_pay(self, lnurldata: LNURL6Data):
assert self._pi.need_resolve() assert isinstance(lnurldata, LNURL6Data)
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):
self._logger.debug('on_lnurl') self._logger.debug('on_lnurl')
self._logger.debug(f'{repr(lnurldata)}') self._logger.debug(f'{repr(lnurldata)}')
if isinstance(lnurldata, LNURL6Data): self._lnurlData = {
self._lnurlData = { 'domain': urlparse(lnurldata.callback_url).netloc,
'domain': urlparse(lnurldata.callback_url).netloc, 'callback_url': lnurldata.callback_url,
'callback_url': lnurldata.callback_url, 'min_sendable_sat': lnurldata.min_sendable_sat,
'min_sendable_sat': lnurldata.min_sendable_sat, 'max_sendable_sat': lnurldata.max_sendable_sat,
'max_sendable_sat': lnurldata.max_sendable_sat, 'metadata_plaintext': lnurldata.metadata_plaintext,
'metadata_plaintext': lnurldata.metadata_plaintext, 'comment_allowed': lnurldata.comment_allowed,
'comment_allowed': lnurldata.comment_allowed, }
} self.setValidLNURLPayRequest()
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.lnurlRetrieved.emit() self.lnurlRetrieved.emit()
@pyqtSlot() @pyqtSlot()
@@ -707,54 +652,6 @@ class QEInvoiceParser(QEInvoice):
self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished) 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): def on_lnurl_invoice(self, orig_amount, invoice):
self._logger.debug('on_lnurl_invoice') self._logger.debug('on_lnurl_invoice')
self._logger.debug(f'{repr(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 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') 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) @pyqtSlot(result=bool)
def saveInvoice(self) -> bool: def saveInvoice(self) -> bool:

View File

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

View File

@@ -1,5 +1,6 @@
from enum import IntEnum from enum import IntEnum
from typing import Optional from typing import Optional
from urllib.parse import urlparse
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum 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 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.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 .qewallet import QEWallet
from .qetypes import QEAmount from .qetypes import QEAmount
@@ -31,6 +36,9 @@ class QERequestDetails(QObject, QtEventListener):
detailsChanged = pyqtSignal() # generic request properties changed signal detailsChanged = pyqtSignal() # generic request properties changed signal
statusChanged = pyqtSignal() statusChanged = pyqtSignal()
needsLNURLUserInput = pyqtSignal()
lnurlError = pyqtSignal(str, str) # code, message
busyChanged = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -41,6 +49,9 @@ class QERequestDetails(QObject, QtEventListener):
self._timer = None self._timer = None
self._amount = None self._amount = None
self._lnurlData = None # type: Optional[dict]
self._busy = False
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setSingleShot(True) self._timer.setSingleShot(True)
self._timer.timeout.connect(self.updateStatusString) self._timer.timeout.connect(self.updateStatusString)
@@ -150,6 +161,14 @@ class QERequestDetails(QObject, QtEventListener):
def bip21(self): def bip21(self):
return self._req.get_bip21_URI() if self._req else '' 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): def initRequest(self):
if self._wallet is None or self._key is None: if self._wallet is None or self._key is None:
return return
@@ -181,3 +200,70 @@ class QERequestDetails(QObject, QtEventListener):
self.statusChanged.emit() self.statusChanged.emit()
self.set_status_timer() 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()

View File

@@ -230,6 +230,7 @@ async def callback_lnurl(url: str, params: dict) -> dict:
raise LNURLError(f"Client error: {e}") from e raise LNURLError(f"Client error: {e}") from e
try: try:
response = json.loads(response_raw) response = json.loads(response_raw)
_logger.debug(f"lnurl response: {response}")
except json.JSONDecodeError: except json.JSONDecodeError:
raise LNURLError(f"Invalid response from LNURL server") raise LNURLError(f"Invalid response from LNURL server")