Merge remote-tracking branch 'spesmilo/pr/9993': lnurl-withdraw

ref https://github.com/spesmilo/electrum/pull/9993
This commit is contained in:
SomberNight
2025-10-02 23:34:40 +00:00
13 changed files with 942 additions and 122 deletions
@@ -0,0 +1,175 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
title: qsTr('LNURL Withdraw request')
iconSource: '../../../icons/link.png'
property Wallet wallet: Daemon.currentWallet
property RequestDetails requestDetails
padding: 0
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
property bool liquidityWarning: providerMaxWithdrawable > walletCanReceive
property bool amountValid: !dialog.insufficientLiquidity &&
amountBtc.textAsSats.satsInt >= dialog.effectiveMinWithdrawable &&
amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable
property bool valid: amountValid
Component.onCompleted: {
dialog.walletCanReceive = wallet.lightningCanReceive.satsInt
}
Connections {
// assign walletCanReceive directly to prevent a binding loop
target: wallet
function onLightningCanReceiveChanged() {
if (!requestDetails.busy) {
// don't assign while busy to prevent the view from changing while receiving
// the incoming payment
dialog.walletCanReceive = wallet.lightningCanReceive.satsInt
}
}
}
ColumnLayout {
width: parent.width
GridLayout {
id: rootLayout
columns: 2
Layout.fillWidth: true
Layout.leftMargin: constants.paddingLarge
Layout.rightMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
compact: true
visible: dialog.insufficientLiquidity
text: qsTr('Too little incoming liquidity to satisfy this withdrawal request.')
+ '\n\n'
+ qsTr('Can receive: %1')
.arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit)
+ '\n'
+ qsTr('Minimum withdrawal amount: %1')
.arg(Config.formatSats(dialog.providerMinWithdrawable) + ' ' + Config.baseUnit)
+ '\n\n'
+ qsTr('Do a submarine swap in the \'Channels\' tab to get more incoming liquidity.')
iconStyle: InfoTextArea.IconStyle.Error
}
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
compact: true
visible: !dialog.insufficientLiquidity && dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable
text: qsTr('Amount must be between %1 and %2 %3')
.arg(Config.formatSats(dialog.effectiveMinWithdrawable))
.arg(Config.formatSats(dialog.effectiveMaxWithdrawable))
.arg(Config.baseUnit)
}
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
compact: true
visible: dialog.liquidityWarning && !dialog.insufficientLiquidity
text: qsTr('The maximum withdrawable amount (%1) is larger than what your channels can receive (%2).')
.arg(Config.formatSats(dialog.providerMaxWithdrawable) + ' ' + Config.baseUnit)
.arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit)
+ ' '
+ qsTr('You may need to do a submarine swap to increase your incoming liquidity.')
iconStyle: InfoTextArea.IconStyle.Warn
}
Label {
text: qsTr('Provider')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
text: requestDetails.lnurlData['domain']
}
Label {
text: qsTr('Description')
color: Material.accentColor
visible: requestDetails.lnurlData['default_description']
}
Label {
Layout.fillWidth: true
text: requestDetails.lnurlData['default_description']
visible: requestDetails.lnurlData['default_description']
wrapMode: Text.Wrap
}
Label {
text: qsTr('Amount')
color: Material.accentColor
}
RowLayout {
Layout.fillWidth: true
BtcField {
id: amountBtc
Layout.preferredWidth: rootLayout.width / 3
text: Config.formatSatsForEditing(dialog.effectiveMaxWithdrawable)
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
color: Material.foreground // override gray-out on disabled
fiatfield: amountFiat
}
Label {
text: Config.baseUnit
color: Material.accentColor
}
}
Item { visible: Daemon.fx.enabled; Layout.preferredWidth: 1; Layout.preferredHeight: 1 }
RowLayout {
visible: Daemon.fx.enabled
FiatField {
id: amountFiat
Layout.preferredWidth: rootLayout.width / 3
btcfield: amountBtc
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
color: Material.foreground
}
Label {
text: Daemon.fx.fiatCurrency
color: Material.accentColor
}
}
}
FlatButton {
Layout.topMargin: constants.paddingLarge
Layout.fillWidth: true
text: qsTr('Withdraw...')
icon.source: '../../icons/confirmed.png'
enabled: valid && !requestDetails.busy
onClicked: {
var satsAmount = amountBtc.textAsSats.satsInt;
requestDetails.lnurlRequestWithdrawal(satsAmount);
dialog.close();
}
}
}
}
+5 -4
View File
@@ -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
}
}
@@ -57,8 +58,8 @@ ElDialog {
Layout.fillHeight: true
hint: Daemon.currentWallet.isLightning
? qsTr('Scan an Invoice, an Address, an LNURL-pay, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL-pay or a PSBT')
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
onFoundText: (data) => {
dialog.dispatch(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: {
+67 -11
View File
@@ -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
}
@@ -44,8 +44,8 @@ Item {
// Android based send dialog if on android
var scanner = app.scanDialog.createObject(mainView, {
hint: Daemon.currentWallet.isLightning
? qsTr('Scan an Invoice, an Address, an LNURL-pay, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL-pay or a PSBT')
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
})
scanner.onFoundText.connect(function(data) {
data = data.trim()
@@ -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,48 @@ 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()
}
onInvoiceResolved: (pi) => {
invoiceParser.fromResolvedPaymentIdentifier(pi)
}
onRequestResolved: (pi) => {
requestDetails.fromResolvedPaymentIdentifier(pi)
}
}
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,12 +462,16 @@ Item {
})
dialog.open()
}
onLnurlRetrieved: {
closeSendDialog()
var dialog = lnurlPayDialog.createObject(app, {
invoiceParser: invoiceParser
})
if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) {
var dialog = lnurlPayDialog.createObject(app, {
invoiceParser: invoiceParser
})
} else {
console.log("Unsupported LNURL type:", invoiceParser.invoiceType)
return
}
dialog.open()
}
onLnurlError: (code, message) => {
@@ -451,7 +497,7 @@ Item {
_intentUri = uri
return
}
invoiceParser.recipient = uri
piResolver.recipient = uri
}
}
@@ -460,7 +506,7 @@ Item {
function onWalletLoaded() {
infobanner.hide() // start hidden when switching wallets
if (_intentUri) {
invoiceParser.recipient = _intentUri
piResolver.recipient = _intentUri
_intentUri = ''
}
}
@@ -739,6 +785,16 @@ Item {
}
}
Component {
id: lnurlWithdrawDialog
LnurlWithdrawRequestDialog {
width: parent.width * 0.9
anchors.centerIn: parent
onClosed: destroy()
}
}
Component {
id: otpDialog
OtpDialog {
+2
View File
@@ -34,6 +34,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
@@ -489,6 +490,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')
+28 -55
View File
@@ -14,6 +14,7 @@ from electrum.invoices import (
)
from electrum.transaction import PartialTxOutput, TxOutput
from electrum.lnutil import format_short_channel_id
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
@@ -22,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
@@ -451,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):
@@ -486,7 +479,6 @@ class QEInvoiceParser(QEInvoice):
@pyqtSlot()
def clear(self):
self.recipient = ''
self.setInvoiceType(QEInvoice.Type.Invalid)
self._lnurlData = None
self.canSave = False
@@ -535,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.LNURLP,
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
@@ -558,12 +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.LNADDR]:
self.on_lnurl(self._pi.lnurl_data)
if self._pi.type in [
PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNADDR,
]:
self.on_lnurl_pay(self._pi.lnurl_data)
return
if self._pi.type == PaymentIdentifierType.BIP70:
@@ -612,32 +606,8 @@ 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.LNURLP:
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):
def on_lnurl_pay(self, lnurldata: LNURL6Data):
assert isinstance(lnurldata, LNURL6Data)
self._logger.debug('on_lnurl')
self._logger.debug(f'{repr(lnurldata)}')
@@ -647,7 +617,7 @@ class QEInvoiceParser(QEInvoice):
'min_sendable_sat': lnurldata.min_sendable_sat,
'max_sendable_sat': lnurldata.max_sendable_sat,
'metadata_plaintext': lnurldata.metadata_plaintext,
'comment_allowed': lnurldata.comment_allowed
'comment_allowed': lnurldata.comment_allowed,
}
self.setValidLNURLPayRequest()
self.lnurlRetrieved.emit()
@@ -657,6 +627,7 @@ class QEInvoiceParser(QEInvoice):
def lnurlGetInvoice(self, comment=None):
assert self._lnurlData
assert self._pi.need_finalize()
assert self.invoiceType == QEInvoice.Type.LNURLPayRequest
self._logger.debug(f'{repr(self._lnurlData)}')
amount = self.amountOverride.satsInt
@@ -689,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:
+100
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], arguments=['pi'])
requestResolved = pyqtSignal([object], arguments=['pi'])
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()
+86
View File
@@ -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()
+158 -4
View File
@@ -4,6 +4,7 @@
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
import urllib.parse
from PyQt6.QtCore import pyqtSignal, QPoint, Qt
from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
@@ -20,15 +21,18 @@ from electrum.util import (
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier, invoice_from_payment_identifier,
payment_identifier_from_invoice)
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier,
invoice_from_payment_identifier,
payment_identifier_from_invoice, PaymentIdentifierState)
from electrum.submarine_swaps import SwapServerError
from electrum.fee_policy import FeePolicy, FixedFeePolicy
from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, add_input_actions_to_context_menu)
get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, Buttons, WWLabel,
add_input_actions_to_context_menu, WindowModalDialog, OkButton, CancelButton)
from .invoice_list import InvoiceList
if TYPE_CHECKING:
@@ -442,7 +446,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.send_button.setEnabled(False)
return
lock_recipient = pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70,
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
lock_amount = pi.is_amount_locked()
@@ -511,6 +516,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.show_error(pi.error)
self.do_clear()
return
if pi.type == PaymentIdentifierType.LNURLW:
assert pi.state == PaymentIdentifierState.LNURLW_FINALIZE, \
f"Detected LNURLW but not ready to finalize? {pi=}"
self.do_clear()
self.request_lnurl_withdraw_dialog(pi.lnurl_data)
return
# if openalias add openalias to contacts
if pi.type == PaymentIdentifierType.OPENALIAS:
key = pi.emaillike if pi.emaillike else pi.domainlike
@@ -837,3 +849,145 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
else:
total += output.value
self.amount_e.setAmount(total if outputs else None)
def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data):
if not self.wallet.has_lightning():
self.show_error(
_("Cannot request lightning withdrawal, wallet has no lightning channels.")
)
return
dialog = WindowModalDialog(self, _("Lightning Withdrawal"))
dialog.setMinimumWidth(400)
vbox = QVBoxLayout()
dialog.setLayout(vbox)
grid = QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1) # Make the last column stretch
row = 0
# provider url
domain_label = QLabel(_("Provider") + ":")
domain_text = WWLabel(urllib.parse.urlparse(lnurl_data.callback_url).netloc)
grid.addWidget(domain_label, row, 0)
grid.addWidget(domain_text, row, 1, 1, 3)
row += 1
if lnurl_data.default_description:
desc_label = QLabel(_("Description") + ":")
desc_text = WWLabel(lnurl_data.default_description)
grid.addWidget(desc_label, row, 0)
grid.addWidget(desc_text, row, 1, 1, 3)
row += 1
min_amount = max(lnurl_data.min_withdrawable_sat, 1)
max_amount = min(
lnurl_data.max_withdrawable_sat,
int(self.wallet.lnworker.num_sats_can_receive())
)
min_text = self.format_amount_and_units(lnurl_data.min_withdrawable_sat)
if min_amount > int(self.wallet.lnworker.num_sats_can_receive()):
self.show_error("".join([
_("Too little incoming liquidity to satisfy this withdrawal request."), "\n\n",
_("Can receive: {}").format(
self.format_amount_and_units(self.wallet.lnworker.num_sats_can_receive()),
), "\n",
_("Minimum withdrawal amount: {}").format(min_text), "\n\n",
_("Do a submarine swap in the 'Channels' tab to get more incoming liquidity.")
]))
return
is_fixed_amount = lnurl_data.min_withdrawable_sat == lnurl_data.max_withdrawable_sat
# Range information (only for non-fixed amounts)
if not is_fixed_amount:
range_label_text = QLabel(_("Range") + ":")
range_value = QLabel("{} - {}".format(
min_text,
self.format_amount_and_units(lnurl_data.max_withdrawable_sat)
))
grid.addWidget(range_label_text, row, 0)
grid.addWidget(range_value, row, 1, 1, 2)
row += 1
# Amount section
amount_label = QLabel(_("Amount") + ":")
amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount)
amount_edit.setAmount(max_amount)
grid.addWidget(amount_label, row, 0)
grid.addWidget(amount_edit, row, 1)
if is_fixed_amount:
# Fixed amount, just show the amount
amount_edit.setDisabled(True)
else:
# Range, show max button
max_button = EnterButton(_("Max"), lambda: amount_edit.setAmount(max_amount))
btn_width = 10 * char_width_in_lineedit()
max_button.setFixedWidth(btn_width)
grid.addWidget(max_button, row, 2)
row += 1
# Warning for insufficient liquidity
if lnurl_data.max_withdrawable_sat > int(self.wallet.lnworker.num_sats_can_receive()):
warning_text = WWLabel(
_("The maximum withdrawable amount is larger than what your channels can receive. "
"You may need to do a submarine swap to increase your incoming liquidity.")
)
warning_text.setStyleSheet("color: orange;")
grid.addWidget(warning_text, row, 0, 1, 4)
row += 1
vbox.addLayout(grid)
# Buttons
request_button = OkButton(dialog, _("Request Withdrawal"))
cancel_button = CancelButton(dialog)
vbox.addLayout(Buttons(cancel_button, request_button))
# Show dialog and handle result
if dialog.exec():
if is_fixed_amount:
amount_sat = lnurl_data.max_withdrawable_sat
else:
amount_sat = amount_edit.get_amount()
if not amount_sat or not (min_amount <= int(amount_sat) <= max_amount):
self.show_error(_("Enter a valid amount. You entered: {}").format(amount_sat))
return
else:
return
try:
key = self.wallet.create_request(
amount_sat=amount_sat,
message=lnurl_data.default_description,
exp_delay=120,
address=None,
)
req = self.wallet.get_request(key)
_lnaddr, b11_invoice = self.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.show_error(
f"{_('Failed to create payment request for withdrawal')}: {str(e)}"
)
return
coro = request_lnurl_withdraw_callback(
callback_url=lnurl_data.callback_url,
k1=lnurl_data.k1,
bolt_11=b11_invoice
)
try:
self.window.run_coroutine_dialog(coro, _("Requesting lightning withdrawal..."))
except LNURLError as e:
self.show_error(f"{_('Failed to request withdrawal')}:\n{str(e)}")
+105 -28
View File
@@ -9,23 +9,31 @@ import re
import urllib.parse
import aiohttp.client_exceptions
from aiohttp import ClientResponse
from electrum import segwit_addr
from electrum import segwit_addr, util
from electrum.segwit_addr import bech32_decode, Encoding, convertbits, bech32_encode
from electrum.lnaddr import LnDecodeException, LnEncodeException
from electrum.network import Network
from electrum.logging import get_logger
if TYPE_CHECKING:
from collections.abc import Coroutine
from electrum.i18n import _
_logger = get_logger(__name__)
class LNURLError(Exception):
pass
class LNURLError(Exception): pass
class UntrustedLNURLError(LNURLError):
def __init__(self, message=""):
# use if error messages are returned by the LNURL server,
# some services could try to trick users into doing something
# by sending a malicious error message
if message:
message = (
f"{_('[DO NOT TRUST THIS MESSAGE]:')}\n"
f"{util.error_text_str_to_safe_str(message)}"
)
super().__init__(message)
def decode_lnurl(lnurl: str) -> str:
@@ -68,6 +76,19 @@ def _is_url_safe_enough_for_lnurl(url: str) -> bool:
return False
def _parse_lnurl_response_callback_url(lnurl_response: dict) -> str:
try:
callback_url = lnurl_response['callback']
except KeyError as e:
raise LNURLError(f"Missing 'callback' field in lnurl response.") from e
if not _is_url_safe_enough_for_lnurl(callback_url):
raise LNURLError(
f"This lnurl callback_url looks unsafe. It must use 'https://' or '.onion' (found: {callback_url[:10]}...)")
return callback_url
# payRequest
# https://github.com/lnurl/luds/blob/227f850b701e9ba893c080103c683273e2feb521/06.md
class LNURL6Data(NamedTuple):
callback_url: str
max_sendable_sat: int
@@ -76,6 +97,23 @@ class LNURL6Data(NamedTuple):
comment_allowed: int
#tag: str = "payRequest"
# withdrawRequest
# https://github.com/lnurl/luds/blob/227f850b701e9ba893c080103c683273e2feb521/03.md
class LNURL3Data(NamedTuple):
# The URL which LN SERVICE would accept a withdrawal Lightning invoice as query parameter
callback_url: str
# Random or non-random string to identify the user's LN WALLET when using the callback URL
k1: str
# A default withdrawal invoice description
default_description: str
# Min amount the user can withdraw from LN SERVICE, or 0
min_withdrawable_sat: int
# Max amount the user can withdraw from LN SERVICE,
# or equal to minWithdrawable if the user has no choice over the amounts
max_withdrawable_sat: int
LNURLData = LNURL6Data | LNURL3Data
async def _request_lnurl(url: str) -> dict:
"""Requests payment data from a lnurl."""
@@ -94,41 +132,34 @@ async def _request_lnurl(url: str) -> dict:
status = response.get("status")
if status and status == "ERROR":
raise LNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
return response
async def request_lnurl(url: str) -> LNURL6Data:
lnurl_dict = await _request_lnurl(url)
tag = lnurl_dict.get('tag')
if tag != 'payRequest': # only LNURL6 is handled atm
raise LNURLError(f"Unknown subtype of lnurl. tag={tag}")
def _parse_lnurl6_response(lnurl_response: dict) -> LNURL6Data:
# parse lnurl6 "metadata"
metadata_plaintext = ""
try:
metadata_raw = lnurl_dict["metadata"]
metadata_raw = lnurl_response["metadata"]
metadata = json.loads(metadata_raw)
for m in metadata:
if m[0] == 'text/plain':
metadata_plaintext = str(m[1])
except Exception as e:
raise LNURLError(f"Missing or malformed 'metadata' field in lnurl6 response. exc: {e!r}") from e
raise LNURLError(
f"Missing or malformed 'metadata' field in lnurl6 response. exc: {e!r}") from e
# parse lnurl6 "callback"
try:
callback_url = lnurl_dict['callback']
except KeyError as e:
raise LNURLError(f"Missing 'callback' field in lnurl6 response.") from e
if not _is_url_safe_enough_for_lnurl(callback_url):
raise LNURLError(f"This lnurl callback_url looks unsafe. It must use 'https://' or '.onion' (found: {callback_url[:10]}...)")
callback_url = _parse_lnurl_response_callback_url(lnurl_response)
# parse lnurl6 "minSendable"/"maxSendable"
try:
max_sendable_sat = int(lnurl_dict['maxSendable']) // 1000
min_sendable_sat = int(lnurl_dict['minSendable']) // 1000
max_sendable_sat = int(lnurl_response['maxSendable']) // 1000
min_sendable_sat = int(lnurl_response['minSendable']) // 1000
except Exception as e:
raise LNURLError(f"Missing or malformed 'minSendable'/'maxSendable' field in lnurl6 response. {e=!r}") from e
raise LNURLError(
f"Missing or malformed 'minSendable'/'maxSendable' field in lnurl6 response. {e=!r}") from e
# parse lnurl6 "commentAllowed" (optional, described in lnurl-12)
try:
comment_allowed = int(lnurl_dict['commentAllowed']) if 'commentAllowed' in lnurl_dict else 0
comment_allowed = int(lnurl_response['commentAllowed']) if 'commentAllowed' in lnurl_response else 0
except Exception as e:
raise LNURLError(f"Malformed 'commentAllowed' field in lnurl6 response. {e=!r}") from e
data = LNURL6Data(
@@ -141,14 +172,59 @@ async def request_lnurl(url: str) -> LNURL6Data:
return data
async def try_resolve_lnurl(lnurl: Optional[str]) -> Optional[LNURL6Data]:
def _parse_lnurl3_response(lnurl_response: dict) -> LNURL3Data:
"""Parses the server response received when requesting a LNURL-withdraw (lud3) request"""
callback_url = _parse_lnurl_response_callback_url(lnurl_response)
if not (k1 := lnurl_response.get('k1')):
raise UntrustedLNURLError(f"Missing k1 value in LNURL3 response: {lnurl_response=}")
default_description = lnurl_response.get('defaultDescription', '')
try:
min_withdrawable_sat = int(lnurl_response['minWithdrawable']) // 1000
max_withdrawable_sat = int(lnurl_response['maxWithdrawable']) // 1000
assert max_withdrawable_sat >= min_withdrawable_sat, f"Invalid amounts: max < min amount"
assert max_withdrawable_sat > 0, f"Invalid max amount: {max_withdrawable_sat} sat"
except Exception as e:
raise LNURLError(
f"Missing or malformed 'minWithdrawable'/'minWithdrawable' field in lnurl3 response. {e=!r}") from e
return LNURL3Data(
callback_url=callback_url,
k1=k1,
default_description=default_description,
min_withdrawable_sat=min_withdrawable_sat,
max_withdrawable_sat=max_withdrawable_sat,
)
async def request_lnurl(url: str) -> LNURLData:
lnurl_dict = await _request_lnurl(url)
tag = lnurl_dict.get('tag')
if tag == 'payRequest': # only LNURL6 is handled atm
return _parse_lnurl6_response(lnurl_dict)
elif tag == 'withdrawRequest':
return _parse_lnurl3_response(lnurl_dict)
raise UntrustedLNURLError(f"Unknown subtype of lnurl. tag={tag}")
async def try_resolve_lnurlpay(lnurl: Optional[str]) -> Optional[LNURL6Data]:
if lnurl:
try:
return await request_lnurl(lnurl)
result = await request_lnurl(lnurl)
assert isinstance(result, LNURL6Data), f"lnurl result is not LNURL-pay response: {result=}"
return result
except Exception as request_error:
_logger.debug(f"Error resolving lnurl: {request_error!r}")
return None
async def request_lnurl_withdraw_callback(callback_url: str, k1: str, bolt_11: str) -> None:
assert bolt_11
params = {
"k1": k1,
"pr": bolt_11,
}
await callback_lnurl(
url=callback_url,
params=params
)
async def callback_lnurl(url: str, params: dict) -> dict:
"""Requests an invoice from a lnurl supporting server."""
@@ -162,12 +238,13 @@ 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")
status = response.get("status")
if status and status == "ERROR":
raise LNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
# TODO: handling of specific errors (validate fields, e.g. for lnurl6)
return response
+1 -1
View File
@@ -2267,7 +2267,7 @@ class LNWallet(LNWorker):
timestamp = int(time.time())
needs_jit: bool = self.receive_requires_jit_channel(amount_msat)
routing_hints = self.calc_routing_hints_for_invoice(amount_msat, channels=channels, needs_jit=needs_jit)
self.logger.info(f"creating bolt11 invoice with routing_hints: {routing_hints}, jit: {needs_jit}, sat: {amount_msat or 0 // 1000}")
self.logger.info(f"creating bolt11 invoice with routing_hints: {routing_hints}, jit: {needs_jit}, sat: {(amount_msat or 0) // 1000}")
invoice_features = self.features.for_invoice()
if not self.uses_trampoline():
invoice_features &= ~ LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM
+26 -14
View File
@@ -14,8 +14,9 @@ from .logging import Logger
from .util import parse_max_spend, InvoiceError
from .util import get_asyncio_loop, log_exceptions
from .transaction import PartialTxOutput
from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url,
try_resolve_lnurl)
from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError,
lightning_address_to_url, try_resolve_lnurlpay, LNURL6Data,
LNURL3Data, LNURLData)
from .bitcoin import opcodes, construct_script
from .lnaddr import LnInvoiceException
from .lnutil import IncompatibleOrInsaneFeatures
@@ -60,10 +61,11 @@ class PaymentIdentifierState(IntEnum):
# of the channels Electrum supports (on-chain, lightning)
NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step
LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11
MERCHANT_NOTIFY = 5 # PI contains a valid payment request and on-chain destination. It should notify
LNURLW_FINALIZE = 5 # PI contains resolved LNURLw, user needs to enter amount and initiate withdraw
MERCHANT_NOTIFY = 6 # PI contains a valid payment request and on-chain destination. It should notify
# the merchant payment processor of the tx after on-chain broadcast,
# and supply a refund address (bip70)
MERCHANT_ACK = 6 # PI notified merchant. nothing to be done.
MERCHANT_ACK = 7 # PI notified merchant. nothing to be done.
ERROR = 50 # generic error
NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccessful
MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX
@@ -77,11 +79,13 @@ class PaymentIdentifierType(IntEnum):
BIP70 = 3
MULTILINE = 4
BOLT11 = 5
LNURLP = 6
EMAILLIKE = 7
OPENALIAS = 8
LNADDR = 9
DOMAINLIKE = 10
LNURL = 6 # before the resolve it's unknown if pi is LNURLP or LNURLW
LNURLP = 7
LNURLW = 8
EMAILLIKE = 9
OPENALIAS = 10
LNADDR = 11
DOMAINLIKE = 12
class FieldsForGUI(NamedTuple):
@@ -133,8 +137,8 @@ class PaymentIdentifier(Logger):
self.merchant_ack_status = None
self.merchant_ack_message = None
#
self.lnurl = None
self.lnurl_data = None
self.lnurl = None # type: Optional[str]
self.lnurl_data = None # type: Optional[LNURLData]
self.parse(text)
@@ -223,7 +227,7 @@ class PaymentIdentifier(Logger):
self.set_state(PaymentIdentifierState.AVAILABLE)
elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
if invoice_or_lnurl.startswith('lnurl'):
self._type = PaymentIdentifierType.LNURLP
self._type = PaymentIdentifierType.LNURL
try:
self.lnurl = decode_lnurl(invoice_or_lnurl)
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
@@ -317,7 +321,7 @@ class PaymentIdentifier(Logger):
# prefers lnurl over openalias if both are available
lnurl = lightning_address_to_url(self.emaillike) if self.emaillike else None
if lnurl is not None and (lnurl_result := await try_resolve_lnurl(lnurl)):
if lnurl is not None and (lnurl_result := await try_resolve_lnurlpay(lnurl)):
openalias_task.cancel()
self._type = PaymentIdentifierType.LNADDR
self.lnurl = lnurl
@@ -353,7 +357,14 @@ class PaymentIdentifier(Logger):
elif self.lnurl:
data = await request_lnurl(self.lnurl)
self.lnurl_data = data
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
if isinstance(data, LNURL6Data):
self._type = PaymentIdentifierType.LNURLP
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
elif isinstance(data, LNURL3Data):
self._type = PaymentIdentifierType.LNURLW
self.set_state(PaymentIdentifierState.LNURLW_FINALIZE)
else:
raise NotImplementedError(f"Invalid LNURL type? {data=}")
self.logger.debug(f'LNURL data: {data!r}')
else:
self.set_state(PaymentIdentifierState.ERROR)
@@ -592,6 +603,7 @@ class PaymentIdentifier(Logger):
recipient, amount, description = self._get_bolt11_fields()
elif self.lnurl and self.lnurl_data:
assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}"
domain = urllib.parse.urlparse(self.lnurl).netloc
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
description = self.lnurl_data.metadata_plaintext
+82
View File
@@ -20,3 +20,85 @@ class TestLnurl(TestCase):
def test_lightning_address_to_url(self):
url = lnurl.lightning_address_to_url("mempool@jhoenicke.de")
self.assertEqual("https://jhoenicke.de/.well-known/lnurlp/mempool", url)
def test_parse_lnurl3_response(self):
# Test successful parsing with all fields
sample_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
'defaultDescription': 'Withdraw from service',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000,
}
result = lnurl._parse_lnurl3_response(sample_response)
self.assertEqual('https://service.io/withdraw?sessionid=123', result.callback_url)
self.assertEqual('abcdef1234567890', result.k1)
self.assertEqual('Withdraw from service', result.default_description)
self.assertEqual(10_000, result.min_withdrawable_sat)
self.assertEqual(100_000, result.max_withdrawable_sat)
# Test with .onion URL
onion_response = {
'callback': 'http://robosatsy56bwqn56qyadmcxkx767hnabg4mihxlmgyt6if5gnuxvzad.onion/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
result = lnurl._parse_lnurl3_response(onion_response)
self.assertEqual('http://robosatsy56bwqn56qyadmcxkx767hnabg4mihxlmgyt6if5gnuxvzad.onion/withdraw?sessionid=123',
result.callback_url)
self.assertEqual('', result.default_description) # Missing defaultDescription uses empty string
# Test missing callback (should raise error)
no_callback_response = {
'k1': 'abcdef1234567890',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(no_callback_response)
# Test unsafe callback URL
unsafe_response = {
'callback': 'http://service.io/withdraw?sessionid=123', # HTTP URL
'k1': 'abcdef1234567890',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(unsafe_response)
# Test missing k1 (should raise error)
no_k1_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'minWithdrawable': 10_000_000,
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(no_k1_response)
# Test missing withdrawable amounts (should raise error)
no_amounts_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(no_amounts_response)
# Test malformed withdrawable amounts (should raise error)
bad_amounts_response = {
'callback': 'https://service.io/withdraw?sessionid=123',
'k1': 'abcdef1234567890',
'minWithdrawable': 'this is not a number',
'maxWithdrawable': 100_000_000
}
with self.assertRaises(lnurl.LNURLError):
lnurl._parse_lnurl3_response(bad_amounts_response)
+107 -5
View File
@@ -1,9 +1,13 @@
import os
import asyncio
from unittest.mock import patch
from electrum import SimpleConfig
from electrum.invoices import Invoice
from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, PaymentIdentifier,
PaymentIdentifierType, invoice_from_payment_identifier)
PaymentIdentifierType, PaymentIdentifierState,
invoice_from_payment_identifier)
from electrum.lnurl import LNURL6Data, LNURL3Data, LNURLError
from electrum.transaction import PartialTxOutput
from . import ElectrumTestCase
@@ -143,14 +147,112 @@ class TestPaymentIdentifier(ElectrumTestCase):
pi = PaymentIdentifier(None, bip21)
self.assertFalse(pi.is_valid())
def test_lnurl(self):
lnurl = 'lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9'
pi = PaymentIdentifier(None, lnurl)
def test_lnurl_basic(self):
"""Test basic LNURL parsing without resolve"""
valid_lnurl = 'lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9'
pi = PaymentIdentifier(None, valid_lnurl)
self.assertTrue(pi.is_valid())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
self.assertFalse(pi.is_available())
self.assertTrue(pi.need_resolve())
self.assertEqual(PaymentIdentifierState.NEED_RESOLVE, pi.state)
# TODO: resolve mock
# Test with lightning: prefix
lightning_lnurl = f'lightning:{valid_lnurl}'
pi = PaymentIdentifier(None, lightning_lnurl)
self.assertTrue(pi.is_valid())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
self.assertTrue(pi.need_resolve())
@patch('electrum.payment_identifier.request_lnurl')
def test_lnurl_pay_resolve(self, mock_request_lnurl):
"""Test LNURL-pay (LNURL6) with mocked resolve"""
valid_lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TMVDE6HYMRS9ANRV46DXETQPJQCS4'
# Mock lnurl-p response
mock_lnurl6_data = LNURL6Data(
callback_url='https://example.com/lnurl-pay',
max_sendable_sat=1_000_000,
min_sendable_sat=1_000,
metadata_plaintext='Test payment',
comment_allowed=100,
)
mock_request_lnurl.return_value = mock_lnurl6_data
pi = PaymentIdentifier(None, valid_lnurl)
self.assertTrue(pi.need_resolve())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
async def run_resolve():
await pi._do_resolve()
asyncio.run(run_resolve())
self.assertEqual(PaymentIdentifierType.LNURLP, pi.type)
self.assertEqual(PaymentIdentifierState.LNURLP_FINALIZE, pi.state)
self.assertTrue(pi.need_finalize())
self.assertIsNotNone(pi.lnurl_data)
self.assertTrue(isinstance(pi.lnurl_data, LNURL6Data))
self.assertEqual(1_000, pi.lnurl_data.min_sendable_sat)
self.assertEqual(1_000_000, pi.lnurl_data.max_sendable_sat)
self.assertEqual('Test payment', pi.lnurl_data.metadata_plaintext)
self.assertEqual(100, pi.lnurl_data.comment_allowed)
@patch('electrum.payment_identifier.request_lnurl')
def test_lnurl_withdraw_resolve(self, mock_request_lnurl):
"""Test LNURL-withdraw (LNURL3) with mocked resolve"""
valid_lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TM4WPNHYCTYV4EJ7DFCVGENSDPH8QCRZETXVGCXGCMPVFJR' \
'WENP8P3NJEP3XE3NQWRPXFJR2VRRVSCX2V33V5UNVC3SXP3RXCFSVFSKVWPCV3SKZWTP8YUZ7AMFW35XGUNPWUHKZURF9AMRZT' \
'MVDE6HYMP0FETHVUNZDAMHQ7JSF4RX73TZ2VU9Z3J3GVMSLCJ57F'
# Mock lnurl-w response
mock_lnurl3_data = LNURL3Data(
callback_url='https://example.com/lnurl-withdraw',
k1='test-k1-value',
default_description='Test withdrawal',
min_withdrawable_sat=1_000,
max_withdrawable_sat=500_000,
)
mock_request_lnurl.return_value = mock_lnurl3_data
pi = PaymentIdentifier(None, valid_lnurl)
self.assertTrue(pi.need_resolve())
self.assertEqual(PaymentIdentifierType.LNURL, pi.type)
async def run_resolve():
await pi._do_resolve()
asyncio.run(run_resolve())
self.assertEqual(PaymentIdentifierType.LNURLW, pi.type)
self.assertEqual(PaymentIdentifierState.LNURLW_FINALIZE, pi.state)
self.assertIsNotNone(pi.lnurl_data)
self.assertEqual('test-k1-value', pi.lnurl_data.k1)
self.assertEqual('Test withdrawal', pi.lnurl_data.default_description)
self.assertEqual(1000, pi.lnurl_data.min_withdrawable_sat)
self.assertEqual(500000, pi.lnurl_data.max_withdrawable_sat)
@patch('electrum.payment_identifier.request_lnurl')
def test_lnurl_resolve_error(self, mock_request_lnurl):
"""Test LNURL resolve error handling"""
lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TM4WPNHYCTYV4EJ7DFCVGENSDPH8QCRZETXVGCXGCMPVFJR' \
'WENP8P3NJEP3XE3NQWRPXFJR2VRRVSCX2V33V5UNVC3SXP3RXCFSVFSKVWPCV3SKZWTP8YUZ7AMFW35XGUNPWUHKZURF9AMRZT' \
'MVDE6HYMP0FETHVUNZDAMHQ7JSF4RX73TZ2VU9Z3J3GVMSLCJ57F'
# Mock LNURL error
mock_request_lnurl.side_effect = LNURLError("Server error")
pi = PaymentIdentifier(None, lnurl)
self.assertTrue(pi.need_resolve())
async def run_resolve():
await pi._do_resolve()
asyncio.run(run_resolve())
self.assertEqual(PaymentIdentifierState.ERROR, pi.state)
self.assertTrue(pi.is_error())
self.assertIn("Server error", pi.get_error())
def test_multiline(self):
pi_str = '\n'.join([