What changed, and why it matters
This commit adds a new feature to Electrum that lets users withdraw bitcoin from a service by scanning or pasting an LNURL-withdraw code. It is a feature addition, not a fix for a known vulnerability. The code does include some safety checks, such as only allowing HTTPS or .onion callback URLs and warning users not to trust error messages from the LNURL server. However, because this is brand-new code handling real money withdrawals and interacting with external services, it introduces new attack surface. There is no direct evidence in the commit of an exploitable bug, but the change is security-relevant by nature.
Treat this as a security-relevant feature addition requiring focused review. Audit the LNURL3 parsing and callback flow for: missing or insufficient validation of k1, defaultDescription, and amount fields; correct handling of network errors and timeouts; whether the BOLT11 invoice creation uses appropriate expiry and routing hints; and that the GUI cannot be tricked into sending an invoice to a malicious callback. Consider adding tests for malformed responses, large amounts, and callback failure modes. No immediate emergency action is warranted based solely on the diff.
Security signals we found
New network-facing feature that creates BOLT11 invoices and sends them to arbitrary (but URL-validated) LNURL service callbacks
Callback URL scheme validation restricts to https:// or .onion to reduce MITM/phishing risk
LNURLError messages are explicitly distrusted to mitigate social-engineering via malicious server error text
Amount bounds validated against channel inbound liquidity before initiating withdrawal
Payment identifier state machine extended with LNURLW_FINALIZE to separate withdraw flow from pay flow
Minor logging fix in lnworker.py: operator precedence corrected for satoshi amount display
Evidence from the diff
The commit implements LNURL-withdraw (LUD-03) support across Electrum’s QML and Qt GUIs, payment identifier logic, and LNURL networking layer. Key additions: LNURL3Data NamedTuple for withdrawRequest responses; request_lnurl_withdraw_callback() to POST a BOLT11 invoice to the service’s callback URL; GUI dialogs for amount selection and liquidity checks; PaymentIdentifierType.LNURL/LNURLW/LNURLP split; and tests for parsing LNURL3 responses. Security-relevant details: callback URL validation requires HTTPS or .onion; LNURLError messages are prefixed with ‘[DO NOT TRUST THIS MESSAGE]’ to mitigate social engineering via server error text; amount bounds are validated against wallet receive capacity. The commit also fixes a minor operator-precedence bug in a logging line in lnworker.py. No CVE, advisory, or vendor security disclosure is present in the materials.
Changed components
electrum/lnurl.pyelectrum/payment_identifier.pyelectrum/gui/qt/send_tab.pyelectrum/gui/qml/qeinvoice.pyelectrum/gui/qml/components/LnurlWithdrawRequestDialog.qmlelectrum/gui/qml/components/SendDialog.qmlelectrum/gui/qml/components/WalletMainView.qmlelectrum/lnworker.pytests/test_lnurl.pyInspect captured patch +634 / −64
diff --git a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
new file mode 100644
index 0000000..63eac2f
--- /dev/null
+++ b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
@@ -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()
+ }
+ }
+ }
+
+}
diff --git a/electrum/gui/qml/components/SendDialog.qml b/electrum/gui/qml/components/SendDialog.qml
index 7564e4b..f747111 100644
--- a/electrum/gui/qml/components/SendDialog.qml
+++ b/electrum/gui/qml/components/SendDialog.qml
@@ -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)
diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml
index d87bbd4..fb3387f 100644
--- a/electrum/gui/qml/components/WalletMainView.qml
+++ b/electrum/gui/qml/components/WalletMainView.qml
@@ -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()
- var dialog = lnurlPayDialog.createObject(app, {
- invoiceParser: invoiceParser
- })
+ 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 {
diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py
index 9e8d14a..5d18dc4 100644
--- a/electrum/gui/qml/qeinvoice.py
+++ b/electrum/gui/qml/qeinvoice.py
@@ -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)}')
- self._lnurlData = {
- 'domain': urlparse(lnurldata.callback_url).netloc,
- 'callback_url': lnurldata.callback_url,
- 'min_sendable_sat': lnurldata.min_sendable_sat,
- 'max_sendable_sat': lnurldata.max_sendable_sat,
- 'metadata_plaintext': lnurldata.metadata_plaintext,
- 'comment_allowed': lnurldata.comment_allowed
- }
- self.setValidLNURLPayRequest()
+ if isinstance(lnurldata, LNURL6Data):
+ self._lnurlData = {
+ 'domain': urlparse(lnurldata.callback_url).netloc,
+ 'callback_url': lnurldata.callback_url,
+ 'min_sendable_sat': lnurldata.min_sendable_sat,
+ 'max_sendable_sat': lnurldata.max_sendable_sat,
+ 'metadata_plaintext': lnurldata.metadata_plaintext,
+ 'comment_allowed': lnurldata.comment_allowed,
+ }
+ self.setValidLNURLPayRequest()
+ elif isinstance(lnurldata, LNURL3Data):
+ self._lnurlData = {
+ 'domain': urlparse(lnurldata.callback_url).netloc,
+ 'callback_url': lnurldata.callback_url,
+ 'min_withdrawable_sat': lnurldata.min_withdrawable_sat,
+ 'max_withdrawable_sat': lnurldata.max_withdrawable_sat,
+ 'default_description': lnurldata.default_description,
+ 'k1': lnurldata.k1,
+ }
+ self.setValidLNURLWithdrawRequest()
+ else:
+ raise NotImplementedError(f"Invalid lnurl type in on_lnurl {lnurldata=}")
self.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)}')
diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py
index 1550e85..73f398e 100644
--- a/electrum/gui/qt/send_tab.py
+++ b/electrum/gui/qt/send_tab.py
@@ -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)}")
diff --git a/electrum/lnurl.py b/electrum/lnurl.py
index 2f57e7d..a3676b0 100644
--- a/electrum/lnurl.py
+++ b/electrum/lnurl.py
@@ -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."""
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 0261e3f..cb1de6b 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -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
diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py
index 79774a6..f7f0b53 100644
--- a/electrum/payment_identifier.py
+++ b/electrum/payment_identifier.py
@@ -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
diff --git a/tests/test_lnurl.py b/tests/test_lnurl.py
index 88c9238..3f3b869 100644
--- a/tests/test_lnurl.py
+++ b/tests/test_lnurl.py
@@ -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)
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.