From fdeada3f51990e8831878b124c54a5eefe120961 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 27 Jun 2025 17:48:26 +0200 Subject: [PATCH] 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. --- .../components/LnurlWithdrawRequestDialog.qml | 161 +++++++++++++++++ electrum/gui/qml/components/SendDialog.qml | 4 +- .../gui/qml/components/WalletMainView.qml | 29 +++- electrum/gui/qml/qeinvoice.py | 102 +++++++++-- electrum/gui/qt/send_tab.py | 162 +++++++++++++++++- electrum/lnurl.py | 116 ++++++++++--- electrum/lnworker.py | 2 +- electrum/payment_identifier.py | 40 +++-- tests/test_lnurl.py | 82 +++++++++ 9 files changed, 634 insertions(+), 64 deletions(-) create mode 100644 electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml diff --git a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml new file mode 100644 index 000000000..63eac2f5e --- /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 7564e4b4f..f74711112 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 d87bbd4bf..fb3387fd4 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 9e8d14aaf..5d18dc4eb 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 1550e85fd..73f398eb7 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 2f57e7d5a..a3676b01e 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 0261e3fe0..cb1de6bc4 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 79774a680..f7f0b536e 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 88c9238e2..3f3b869f7 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)