lnurl: implement LNURL-withdraw

adds handling of lnurl-withdraw payment identifiers which allow users to
withdraw bitcoin from a service by scanning a qr code or pasting the
lnurl-w code as "sending" address.
This commit is contained in:
f321x
2025-06-27 17:48:26 +02:00
parent 71255c1e73
commit fdeada3f51
9 changed files with 634 additions and 64 deletions

View File

@@ -0,0 +1,161 @@
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 InvoiceParser invoiceParser
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 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
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: invoiceParser.lnurlData['domain']
}
Label {
text: qsTr('Description')
color: Material.accentColor
visible: invoiceParser.lnurlData['default_description']
}
Label {
Layout.fillWidth: true
text: invoiceParser.lnurlData['default_description']
visible: invoiceParser.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
onTextAsSatsChanged: {
invoiceParser.amountOverride = textAsSats
}
}
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
onClicked: {
invoiceParser.lnurlRequestWithdrawal()
dialog.close()
}
}
}
}

View File

@@ -57,8 +57,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)

View File

@@ -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()
@@ -423,9 +423,18 @@ Item {
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
}
dialog.open()
}
onLnurlError: (code, message) => {
@@ -739,6 +748,16 @@ Item {
}
}
Component {
id: lnurlWithdrawDialog
LnurlWithdrawRequestDialog {
width: parent.width * 0.9
anchors.centerIn: parent
onClosed: destroy()
}
}
Component {
id: otpDialog
OtpDialog {

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 LNURLData, LNURL3Data, LNURL6Data, request_lnurl_withdraw_callback, LNURLError
from electrum.bitcoin import COIN, address_to_script
from electrum.paymentrequest import PaymentRequest
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
@@ -33,6 +34,7 @@ class QEInvoice(QObject, QtEventListener):
OnchainInvoice = 0
LightningInvoice = 1
LNURLPayRequest = 2
LNURLWithdrawRequest = 3
@pyqtEnum
class Status(IntEnum):
@@ -478,7 +480,7 @@ class QEInvoiceParser(QEInvoice):
@pyqtProperty(bool, notify=lnurlRetrieved)
def isLnurlPay(self):
return self._lnurlData is not None
return self._lnurlData is not None and self.invoiceType == QEInvoice.Type.LNURLPayRequest
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
@@ -513,6 +515,12 @@ 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,7 +551,7 @@ class QEInvoiceParser(QEInvoice):
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.LNURL,
PaymentIdentifierType.EMAILLIKE,
PaymentIdentifierType.DOMAINLIKE]:
self.validationError.emit('unknown', _('Unknown invoice'))
@@ -562,7 +570,11 @@ class QEInvoiceParser(QEInvoice):
self.resolve_pi()
return
if self._pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]:
if self._pi.type in [
PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNADDR,
]:
self.on_lnurl(self._pi.lnurl_data)
return
@@ -622,7 +634,7 @@ class QEInvoiceParser(QEInvoice):
if pi.is_error():
if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
msg = _('Could not resolve address')
elif pi.type == PaymentIdentifierType.LNURLP:
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)
@@ -637,19 +649,32 @@ class QEInvoiceParser(QEInvoice):
self._pi.resolve(on_finished=on_finished)
def on_lnurl(self, lnurldata):
def on_lnurl(self, lnurldata: LNURLData):
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
'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.lnurlRetrieved.emit()
@pyqtSlot()
@@ -657,6 +682,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
@@ -681,6 +707,54 @@ 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)}')

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:
@@ -435,7 +439,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()
@@ -504,6 +509,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
@@ -830,3 +842,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)}")

View File

@@ -9,7 +9,6 @@ import re
import urllib.parse
import aiohttp.client_exceptions
from aiohttp import ClientResponse
from electrum import segwit_addr
from electrum.segwit_addr import bech32_decode, Encoding, convertbits, bech32_encode
@@ -17,15 +16,16 @@ 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
_logger = get_logger(__name__)
class LNURLError(Exception):
pass
def __init__(self, message="", *args):
# error messages are returned by the LNURL server, some services could try to trick
# users into doing something by sending a malicious error message
modified_message = f"[DO NOT TRUST THIS MESSAGE]:\n{message}"
super().__init__(modified_message, *args)
def decode_lnurl(lnurl: str) -> str:
@@ -68,6 +68,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 +89,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."""
@@ -98,37 +128,30 @@ async def _request_lnurl(url: str) -> dict:
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 +164,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 LNURLError(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 LNURLError(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."""

View File

@@ -2258,7 +2258,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

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

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)