From fdeada3f51990e8831878b124c54a5eefe120961 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 27 Jun 2025 17:48:26 +0200 Subject: [PATCH 1/6] 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) From 72dfc61d9cad53917a141ff6abbdc7699042b45a Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 1 Jul 2025 11:45:04 +0200 Subject: [PATCH 2/6] tests: add more lnurl testing for PaymentIdentifier adds some more detailed tests to `test_payment_identifier.py` to test lnurlp and lnurlw separately and mock their resolve. --- tests/test_payment_identifier.py | 112 +++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index 6f4b4f506..04f59d93b 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -1,9 +1,13 @@ import os +import asyncio +from unittest.mock import patch from electrum import SimpleConfig from electrum.invoices import Invoice from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, PaymentIdentifier, - PaymentIdentifierType, invoice_from_payment_identifier) + PaymentIdentifierType, PaymentIdentifierState, + invoice_from_payment_identifier) +from electrum.lnurl import LNURL6Data, LNURL3Data, LNURLError from electrum.transaction import PartialTxOutput from . import ElectrumTestCase @@ -143,14 +147,112 @@ class TestPaymentIdentifier(ElectrumTestCase): pi = PaymentIdentifier(None, bip21) self.assertFalse(pi.is_valid()) - def test_lnurl(self): - lnurl = 'lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9' - pi = PaymentIdentifier(None, lnurl) + def test_lnurl_basic(self): + """Test basic LNURL parsing without resolve""" + valid_lnurl = 'lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9' + pi = PaymentIdentifier(None, valid_lnurl) self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.LNURL, pi.type) self.assertFalse(pi.is_available()) self.assertTrue(pi.need_resolve()) + self.assertEqual(PaymentIdentifierState.NEED_RESOLVE, pi.state) - # TODO: resolve mock + # Test with lightning: prefix + lightning_lnurl = f'lightning:{valid_lnurl}' + pi = PaymentIdentifier(None, lightning_lnurl) + self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.LNURL, pi.type) + self.assertTrue(pi.need_resolve()) + + @patch('electrum.payment_identifier.request_lnurl') + def test_lnurl_pay_resolve(self, mock_request_lnurl): + """Test LNURL-pay (LNURL6) with mocked resolve""" + valid_lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TMVDE6HYMRS9ANRV46DXETQPJQCS4' + + # Mock lnurl-p response + mock_lnurl6_data = LNURL6Data( + callback_url='https://example.com/lnurl-pay', + max_sendable_sat=1_000_000, + min_sendable_sat=1_000, + metadata_plaintext='Test payment', + comment_allowed=100, + ) + mock_request_lnurl.return_value = mock_lnurl6_data + + pi = PaymentIdentifier(None, valid_lnurl) + self.assertTrue(pi.need_resolve()) + self.assertEqual(PaymentIdentifierType.LNURL, pi.type) + + async def run_resolve(): + await pi._do_resolve() + + asyncio.run(run_resolve()) + + self.assertEqual(PaymentIdentifierType.LNURLP, pi.type) + self.assertEqual(PaymentIdentifierState.LNURLP_FINALIZE, pi.state) + self.assertTrue(pi.need_finalize()) + self.assertIsNotNone(pi.lnurl_data) + self.assertTrue(isinstance(pi.lnurl_data, LNURL6Data)) + self.assertEqual(1_000, pi.lnurl_data.min_sendable_sat) + self.assertEqual(1_000_000, pi.lnurl_data.max_sendable_sat) + self.assertEqual('Test payment', pi.lnurl_data.metadata_plaintext) + self.assertEqual(100, pi.lnurl_data.comment_allowed) + + @patch('electrum.payment_identifier.request_lnurl') + def test_lnurl_withdraw_resolve(self, mock_request_lnurl): + """Test LNURL-withdraw (LNURL3) with mocked resolve""" + valid_lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TM4WPNHYCTYV4EJ7DFCVGENSDPH8QCRZETXVGCXGCMPVFJR' \ + 'WENP8P3NJEP3XE3NQWRPXFJR2VRRVSCX2V33V5UNVC3SXP3RXCFSVFSKVWPCV3SKZWTP8YUZ7AMFW35XGUNPWUHKZURF9AMRZT' \ + 'MVDE6HYMP0FETHVUNZDAMHQ7JSF4RX73TZ2VU9Z3J3GVMSLCJ57F' + + # Mock lnurl-w response + mock_lnurl3_data = LNURL3Data( + callback_url='https://example.com/lnurl-withdraw', + k1='test-k1-value', + default_description='Test withdrawal', + min_withdrawable_sat=1_000, + max_withdrawable_sat=500_000, + ) + mock_request_lnurl.return_value = mock_lnurl3_data + + pi = PaymentIdentifier(None, valid_lnurl) + self.assertTrue(pi.need_resolve()) + self.assertEqual(PaymentIdentifierType.LNURL, pi.type) + + async def run_resolve(): + await pi._do_resolve() + + asyncio.run(run_resolve()) + + self.assertEqual(PaymentIdentifierType.LNURLW, pi.type) + self.assertEqual(PaymentIdentifierState.LNURLW_FINALIZE, pi.state) + self.assertIsNotNone(pi.lnurl_data) + self.assertEqual('test-k1-value', pi.lnurl_data.k1) + self.assertEqual('Test withdrawal', pi.lnurl_data.default_description) + self.assertEqual(1000, pi.lnurl_data.min_withdrawable_sat) + self.assertEqual(500000, pi.lnurl_data.max_withdrawable_sat) + + @patch('electrum.payment_identifier.request_lnurl') + def test_lnurl_resolve_error(self, mock_request_lnurl): + """Test LNURL resolve error handling""" + lnurl = 'LNURL1DP68GURN8GHJ7MRWVF5HGUEWD3HXZERYWFJHXUEWVDHK6TM4WPNHYCTYV4EJ7DFCVGENSDPH8QCRZETXVGCXGCMPVFJR' \ + 'WENP8P3NJEP3XE3NQWRPXFJR2VRRVSCX2V33V5UNVC3SXP3RXCFSVFSKVWPCV3SKZWTP8YUZ7AMFW35XGUNPWUHKZURF9AMRZT' \ + 'MVDE6HYMP0FETHVUNZDAMHQ7JSF4RX73TZ2VU9Z3J3GVMSLCJ57F' + + # Mock LNURL error + mock_request_lnurl.side_effect = LNURLError("Server error") + + pi = PaymentIdentifier(None, lnurl) + self.assertTrue(pi.need_resolve()) + + async def run_resolve(): + await pi._do_resolve() + + asyncio.run(run_resolve()) + + self.assertEqual(PaymentIdentifierState.ERROR, pi.state) + self.assertTrue(pi.is_error()) + self.assertIn("Server error", pi.get_error()) def test_multiline(self): pi_str = '\n'.join([ From b90090e2ddb706ff40cbf0dd267575882bb87b22 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 15 Jul 2025 00:17:42 +0200 Subject: [PATCH 3/6] qml: separate PI resolving from QEInvoiceParser separates the resolving step from the QEInvoiceParser so the 'recipient' can be resolved first and then either an QEInvoiceParser can be used if it is a sending request that has been resolved (invoice, address, lnurlp, ...), or RequestDetails can be used if the resolved 'recipient' turns out to be a voucher/LNURLW string. # Conflicts: # electrum/gui/qml/qeinvoice.py --- .../components/LnurlWithdrawRequestDialog.qml | 34 ++-- electrum/gui/qml/components/SendDialog.qml | 5 +- .../gui/qml/components/WalletMainView.qml | 54 ++++-- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qeinvoice.py | 165 ++++-------------- electrum/gui/qml/qepiresolver.py | 100 +++++++++++ electrum/gui/qml/qerequestdetails.py | 86 +++++++++ electrum/lnurl.py | 1 + 8 files changed, 287 insertions(+), 160 deletions(-) create mode 100644 electrum/gui/qml/qepiresolver.py diff --git a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml index 63eac2f5e..d923866f2 100644 --- a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml +++ b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml @@ -13,13 +13,14 @@ ElDialog { title: qsTr('LNURL Withdraw request') iconSource: '../../../icons/link.png' - property InvoiceParser invoiceParser + property Wallet wallet: Daemon.currentWallet + property RequestDetails requestDetails 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 walletCanReceive: 0 + property int providerMinWithdrawable: parseInt(requestDetails.lnurlData['min_withdrawable_sat']) + property int providerMaxWithdrawable: parseInt(requestDetails.lnurlData['max_withdrawable_sat']) property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1) property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive) property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive @@ -30,6 +31,12 @@ ElDialog { amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable property bool valid: amountValid + Component.onCompleted: { + // Initialize walletCanReceive (instead of binding wallet.lightningCanReceive.satsInt) + // to prevent binding loop if wallet.lightningCanReceive.satsInt changes + walletCanReceive = wallet.lightningCanReceive.satsInt + } + ColumnLayout { width: parent.width @@ -89,17 +96,17 @@ ElDialog { } Label { Layout.fillWidth: true - text: invoiceParser.lnurlData['domain'] + text: requestDetails.lnurlData['domain'] } Label { text: qsTr('Description') color: Material.accentColor - visible: invoiceParser.lnurlData['default_description'] + visible: requestDetails.lnurlData['default_description'] } Label { Layout.fillWidth: true - text: invoiceParser.lnurlData['default_description'] - visible: invoiceParser.lnurlData['default_description'] + text: requestDetails.lnurlData['default_description'] + visible: requestDetails.lnurlData['default_description'] wrapMode: Text.Wrap } @@ -117,9 +124,6 @@ ElDialog { 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 @@ -150,12 +154,12 @@ ElDialog { Layout.fillWidth: true text: qsTr('Withdraw...') icon.source: '../../icons/confirmed.png' - enabled: valid + enabled: valid && !requestDetails.busy onClicked: { - invoiceParser.lnurlRequestWithdrawal() - dialog.close() + var satsAmount = amountBtc.textAsSats.satsInt; + requestDetails.lnurlRequestWithdrawal(satsAmount); + dialog.close(); } } } - } diff --git a/electrum/gui/qml/components/SendDialog.qml b/electrum/gui/qml/components/SendDialog.qml index f74711112..e17c45ce4 100644 --- a/electrum/gui/qml/components/SendDialog.qml +++ b/electrum/gui/qml/components/SendDialog.qml @@ -12,6 +12,7 @@ ElDialog { id: dialog property InvoiceParser invoiceParser + property PIResolver piResolver signal txFound(data: string) signal channelBackupFound(data: string) @@ -36,7 +37,7 @@ ElDialog { } else if (Daemon.currentWallet.isValidChannelBackup(data)) { channelBackupFound(data) } else { - invoiceParser.recipient = data + piResolver.recipient = data } } @@ -71,7 +72,7 @@ ElDialog { FlatButton { Layout.fillWidth: true Layout.preferredWidth: 1 - enabled: !invoiceParser.busy + enabled: !invoiceParser.busy && !piResolver.busy icon.source: '../../icons/copy_bw.png' text: qsTr('Paste') onClicked: { diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index fb3387fd4..d8f44ce7a 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -36,7 +36,7 @@ Item { function openSendDialog() { // Qt based send dialog if not on android if (!AppController.isAndroid()) { - _sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser}) + _sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser, piResolver: piResolver}) _sendDialog.open() return } @@ -61,7 +61,7 @@ Item { }) dialog.open() } else { - invoiceParser.recipient = data + piResolver.recipient = data } //scanner.destroy() // TODO }) @@ -362,7 +362,7 @@ Item { Layout.preferredWidth: 1 icon.source: '../../icons/tab_send.png' text: qsTr('Send') - enabled: !invoiceParser.busy + enabled: !invoiceParser.busy && !piResolver.busy && !requestDetails.busy onClicked: openSendDialog() onPressAndHold: { Config.userKnowsPressAndHold = true @@ -373,6 +373,45 @@ Item { } } + PIResolver { + id: piResolver + wallet: Daemon.currentWallet + + onResolveError: (code, message) => { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Error'), + iconSource: Qt.resolvedUrl('../../icons/warning.png'), + text: message + }) + dialog.open() + } + + Component.onCompleted: { + piResolver.invoiceResolved.connect(invoiceParser.fromResolvedPaymentIdentifier) + piResolver.requestResolved.connect(requestDetails.fromResolvedPaymentIdentifier) + } + } + + RequestDetails { + id: requestDetails + wallet: Daemon.currentWallet + onNeedsLNURLUserInput: { + closeSendDialog() + var dialog = lnurlWithdrawDialog.createObject(app, { + requestDetails: requestDetails + }) + dialog.open() + } + onLnurlError: (code, message) => { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Error'), + iconSource: Qt.resolvedUrl('../../icons/warning.png'), + text: message + }) + dialog.open() + } + } + Invoice { id: invoice wallet: Daemon.currentWallet @@ -420,17 +459,12 @@ Item { }) dialog.open() } - 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 @@ -460,7 +494,7 @@ Item { _intentUri = uri return } - invoiceParser.recipient = uri + piResolver.recipient = uri } } @@ -469,7 +503,7 @@ Item { function onWalletLoaded() { infobanner.hide() // start hidden when switching wallets if (_intentUri) { - invoiceParser.recipient = _intentUri + piResolver.recipient = _intentUri _intentUri = '' } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 45e9feaf2..dadc5d206 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -33,6 +33,7 @@ from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider from .qeinvoice import QEInvoice, QEInvoiceParser +from .qepiresolver import QEPIResolver from .qerequestdetails import QERequestDetails from .qetypes import QEAmount, QEBytes from .qeaddressdetails import QEAddressDetails @@ -439,6 +440,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner') qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') + qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 5d18dc4eb..a5f2a77e4 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -14,7 +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.lnurl import LNURL6Data from electrum.bitcoin import COIN, address_to_script from electrum.paymentrequest import PaymentRequest from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType @@ -23,7 +23,6 @@ from electrum.network import Network from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval, QtEventListener, event_listener -from ...fee_policy import FeePolicy from ...util import InvoiceError @@ -34,7 +33,6 @@ class QEInvoice(QObject, QtEventListener): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 - LNURLWithdrawRequest = 3 @pyqtEnum class Status(IntEnum): @@ -453,26 +451,19 @@ class QEInvoiceParser(QEInvoice): def __init__(self, parent=None): super().__init__(parent) - self._recipient = '' self._pi = None self._lnurlData = None self._busy = False self.clear() - recipientChanged = pyqtSignal() - @pyqtProperty(str, notify=recipientChanged) - def recipient(self): - return self._recipient - - @recipient.setter - def recipient(self, recipient: str): + @pyqtSlot(object) + def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None: self.canPay = False - self._recipient = recipient self.amountOverride = QEAmount() - if recipient: - self.validateRecipient(recipient) - self.recipientChanged.emit() + if resolved_pi: + assert not resolved_pi.need_resolve() + self.validateRecipient(resolved_pi) @pyqtProperty('QVariantMap', notify=lnurlRetrieved) def lnurlData(self): @@ -480,7 +471,7 @@ class QEInvoiceParser(QEInvoice): @pyqtProperty(bool, notify=lnurlRetrieved) def isLnurlPay(self): - return self._lnurlData is not None and self.invoiceType == QEInvoice.Type.LNURLPayRequest + return self._lnurlData is not None @pyqtProperty(bool, notify=busyChanged) def busy(self): @@ -488,7 +479,6 @@ class QEInvoiceParser(QEInvoice): @pyqtSlot() def clear(self): - self.recipient = '' self.setInvoiceType(QEInvoice.Type.Invalid) self._lnurlData = None self.canSave = False @@ -515,12 +505,6 @@ 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,17 +527,18 @@ class QEInvoiceParser(QEInvoice): else: self.validationError.emit('unknown', f'invoice error:\n{pr.error}') - def validateRecipient(self, recipient): - if not recipient: + def validateRecipient(self, pi: PaymentIdentifier): + if not pi: self.setInvoiceType(QEInvoice.Type.Invalid) return - self._pi = PaymentIdentifier(self._wallet.wallet, recipient) - if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21, - PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, - PaymentIdentifierType.LNURL, - PaymentIdentifierType.EMAILLIKE, - PaymentIdentifierType.DOMAINLIKE]: + self._pi = pi + if not self._pi.is_valid() or self._pi.type not in [ + PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21, + PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, + PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP, + PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE + ]: self.validationError.emit('unknown', _('Unknown invoice')) return @@ -566,16 +551,13 @@ class QEInvoiceParser(QEInvoice): self._update_from_payment_identifier() def _update_from_payment_identifier(self): - if self._pi.need_resolve(): - self.resolve_pi() - return + assert not self._pi.need_resolve(), "Should have been resolved by QEPIResolver" if self._pi.type in [ PaymentIdentifierType.LNURLP, - PaymentIdentifierType.LNURLW, PaymentIdentifierType.LNADDR, ]: - self.on_lnurl(self._pi.lnurl_data) + self.on_lnurl_pay(self._pi.lnurl_data) return if self._pi.type == PaymentIdentifierType.BIP70: @@ -624,57 +606,20 @@ class QEInvoiceParser(QEInvoice): self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() - def resolve_pi(self): - assert self._pi.need_resolve() - - def on_finished(pi: PaymentIdentifier): - self._busy = False - self.busyChanged.emit() - - if pi.is_error(): - if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]: - msg = _('Could not resolve address') - elif pi.type == PaymentIdentifierType.LNURL: - msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error() - elif pi.type == PaymentIdentifierType.BIP70: - msg = _('Could not resolve BIP70 payment request: {}').format(pi.error) - else: - msg = _('Could not resolve') - self.validationError.emit('resolve', msg) - else: - self._update_from_payment_identifier() - - self._busy = True - self.busyChanged.emit() - - self._pi.resolve(on_finished=on_finished) - - def on_lnurl(self, lnurldata: LNURLData): + def on_lnurl_pay(self, lnurldata: LNURL6Data): + assert isinstance(lnurldata, LNURL6Data) 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, - } - 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._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() self.lnurlRetrieved.emit() @pyqtSlot() @@ -707,54 +652,6 @@ 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)}') @@ -763,7 +660,9 @@ class QEInvoiceParser(QEInvoice): if orig_amount * 1000 != invoice.amount_msat: # TODO msat precision can cause trouble here raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount') - self.recipient = invoice.lightning_invoice + self.fromResolvedPaymentIdentifier( + PaymentIdentifier(self._wallet.wallet, invoice.lightning_invoice) + ) @pyqtSlot(result=bool) def saveInvoice(self) -> bool: diff --git a/electrum/gui/qml/qepiresolver.py b/electrum/gui/qml/qepiresolver.py new file mode 100644 index 000000000..9699a4b06 --- /dev/null +++ b/electrum/gui/qml/qepiresolver.py @@ -0,0 +1,100 @@ +from enum import IntEnum +from typing import Optional + +from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType + +from .qewallet import QEWallet + + +class QEPIResolver(QObject): + """Intended to handle a user input Payment Identifier (PI), resolve it if necessary, then + allow to distinguish between a Request/voucher/lnurlw and an Invoice (e.g. b11 or lnurlp).""" + _logger = get_logger(__name__) + + busyChanged = pyqtSignal() + resolveError = pyqtSignal([str, str], arguments=['code', 'message']) + invoiceResolved = pyqtSignal(object) + requestResolved = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + + self._wallet = None # type: Optional[QEWallet] + self._recipient = None + self._pi = None + self._busy = False + + self.clear() + + recipientChanged = pyqtSignal() + @pyqtProperty(str, notify=recipientChanged) + def recipient(self) -> Optional[str]: + return self._recipient + + @recipient.setter + def recipient(self, recipient: str) -> None: + self.clear() + if not recipient: + return + self._recipient = recipient + self.recipientChanged.emit() + self._pi = PaymentIdentifier(self._wallet.wallet, recipient) + if self._pi.need_resolve(): + self.resolve_pi() + else: + # assuming if the PI is an invoice if it doesn't need resolving + # as there are no request types that do not need resolving currently + self.invoiceResolved.emit(self._pi) + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self) -> Optional[QEWallet]: + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet) -> None: + self._wallet = wallet + + @pyqtProperty(bool, notify=busyChanged) + def busy(self): + return self._busy + + def resolve_pi(self) -> None: + assert self._pi is not None + assert self._pi.need_resolve() + + def on_finished(pi: PaymentIdentifier): + self._busy = False + self.busyChanged.emit() + + if pi.is_error(): + if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]: + msg = _('Could not resolve address') + elif pi.type == PaymentIdentifierType.LNURL: + msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error() + elif pi.type == PaymentIdentifierType.BIP70: + msg = _('Could not resolve BIP70 payment request: {}').format(pi.error) + else: + msg = _('Could not resolve') + self.resolveError.emit('resolve', msg) + else: + if pi.type == PaymentIdentifierType.LNURLW: + self.requestResolved.emit(pi) + else: + self.invoiceResolved.emit(pi) + + self._busy = True + self.busyChanged.emit() + + self._pi.resolve(on_finished=on_finished) + + def clear(self) -> None: + self._recipient = None + self._pi = None + self._busy = False + self.busyChanged.emit() + self.recipientChanged.emit() diff --git a/electrum/gui/qml/qerequestdetails.py b/electrum/gui/qml/qerequestdetails.py index 6f5e35dc1..e12f46319 100644 --- a/electrum/gui/qml/qerequestdetails.py +++ b/electrum/gui/qml/qerequestdetails.py @@ -1,5 +1,6 @@ from enum import IntEnum from typing import Optional +from urllib.parse import urlparse from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum @@ -8,6 +9,10 @@ from electrum.invoices import ( PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, LN_EXPIRY_NEVER ) from electrum.lnutil import MIN_FUNDING_SAT +from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError +from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierType +from electrum.i18n import _ +from electrum.network import Network from .qewallet import QEWallet from .qetypes import QEAmount @@ -31,6 +36,9 @@ class QERequestDetails(QObject, QtEventListener): detailsChanged = pyqtSignal() # generic request properties changed signal statusChanged = pyqtSignal() + needsLNURLUserInput = pyqtSignal() + lnurlError = pyqtSignal(str, str) # code, message + busyChanged = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -41,6 +49,9 @@ class QERequestDetails(QObject, QtEventListener): self._timer = None self._amount = None + self._lnurlData = None # type: Optional[dict] + self._busy = False + self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.timeout.connect(self.updateStatusString) @@ -150,6 +161,14 @@ class QERequestDetails(QObject, QtEventListener): def bip21(self): return self._req.get_bip21_URI() if self._req else '' + @pyqtProperty('QVariantMap', notify=detailsChanged) + def lnurlData(self) -> Optional[dict]: + return self._lnurlData + + @pyqtProperty(bool, notify=busyChanged) + def busy(self): + return self._busy + def initRequest(self): if self._wallet is None or self._key is None: return @@ -181,3 +200,70 @@ class QERequestDetails(QObject, QtEventListener): self.statusChanged.emit() self.set_status_timer() + @pyqtSlot(object) + def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None: + """ + Called when a payment identifier is resolved to a request (currently only LNURLW, but + could also be used for other "voucher" type input like redeeming ecash tokens or + some bolt12 thing). + """ + if not self._wallet: + return + if resolved_pi.type == PaymentIdentifierType.LNURLW: + lnurldata = resolved_pi.lnurl_data + assert isinstance(lnurldata, LNURL3Data), "Expected LNURL3Data type" + self._lnurlData = { + 'domain': urlparse(lnurldata.callback_url).netloc, + 'callback_url': lnurldata.callback_url, + 'min_withdrawable_sat': lnurldata.min_withdrawable_sat, + 'max_withdrawable_sat': lnurldata.max_withdrawable_sat, + 'default_description': lnurldata.default_description, + 'k1': lnurldata.k1, + } + self.needsLNURLUserInput.emit() + else: + raise NotImplementedError("Cannot request withdrawal for this payment identifier type") + + @pyqtSlot(int) + def lnurlRequestWithdrawal(self, amount_sat: int) -> None: + assert self._lnurlData + self._logger.debug(f'requesting lnurlw: {repr(self._lnurlData)}') + + try: + key = self.wallet.wallet.create_request( + amount_sat=amount_sat, + message=self._lnurlData.get('default_description', ''), + exp_delay=120, + address=None, + ) + req = self.wallet.wallet.get_request(key) + _lnaddr, b11_invoice = self.wallet.wallet.lnworker.get_bolt11_invoice( + payment_hash=req.payment_hash, + amount_msat=req.get_amount_msat(), + message=req.get_message(), + expiry=req.exp, + fallback_address=None + ) + except Exception as e: + self._logger.exception('') + self.lnurlError.emit( + 'lnurl', + _("Failed to create payment request for withdrawal: {}").format(str(e)) + ) + return + + self._busy = True + self.busyChanged.emit() + + coro = request_lnurl_withdraw_callback( + callback_url=self._lnurlData['callback_url'], + k1=self._lnurlData['k1'], + bolt_11=b11_invoice, + ) + try: + Network.run_from_another_thread(coro) + except LNURLError as e: + self.lnurlError.emit('lnurl', str(e)) + finally: + self._busy = False + self.busyChanged.emit() diff --git a/electrum/lnurl.py b/electrum/lnurl.py index a3676b01e..3f035bae1 100644 --- a/electrum/lnurl.py +++ b/electrum/lnurl.py @@ -230,6 +230,7 @@ async def callback_lnurl(url: str, params: dict) -> dict: raise LNURLError(f"Client error: {e}") from e try: response = json.loads(response_raw) + _logger.debug(f"lnurl response: {response}") except json.JSONDecodeError: raise LNURLError(f"Invalid response from LNURL server") From c2f5b3ba6b33893bb13cefdd29b764befa4bb5ee Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 11 Aug 2025 16:42:50 +0200 Subject: [PATCH 4/6] qml: lnurlw: bind walletCanReceive to in-liquidity binds the walletCanReceive variable to the available inbound liquidity so the withdraw button gets enabled when the channels reconnect if the user opens a lnurlw request dialog before the channels have connected. --- .../components/LnurlWithdrawRequestDialog.qml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml index d923866f2..4bbcab99b 100644 --- a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml +++ b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml @@ -32,9 +32,19 @@ ElDialog { property bool valid: amountValid Component.onCompleted: { - // Initialize walletCanReceive (instead of binding wallet.lightningCanReceive.satsInt) - // to prevent binding loop if wallet.lightningCanReceive.satsInt changes - walletCanReceive = wallet.lightningCanReceive.satsInt + dialog.walletCanReceive = wallet.lightningCanReceive.satsInt + } + + Connections { + // assign walletCanReceive directly to prevent a binding loop + target: wallet + function onLightningCanReceiveChanged() { + if (!requestDetails.busy) { + // don't assign while busy to prevent the view from changing while receiving + // the incoming payment + dialog.walletCanReceive = wallet.lightningCanReceive.satsInt + } + } } ColumnLayout { From 313c8a136a5579eb56fdd945c4cd9b972e00bb23 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 12 Aug 2025 10:07:07 +0200 Subject: [PATCH 5/6] qml: use declarative form for invoiceResolved and requestResolved in WalletMainView (cherry picked from commit 78b2e3df6c47ad5d5d119cbc2372e62807f372fd) --- electrum/gui/qml/components/WalletMainView.qml | 9 ++++++--- electrum/gui/qml/qepiresolver.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index d8f44ce7a..a9755ac81 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -386,9 +386,12 @@ Item { dialog.open() } - Component.onCompleted: { - piResolver.invoiceResolved.connect(invoiceParser.fromResolvedPaymentIdentifier) - piResolver.requestResolved.connect(requestDetails.fromResolvedPaymentIdentifier) + onInvoiceResolved: (pi) => { + invoiceParser.fromResolvedPaymentIdentifier(pi) + } + + onRequestResolved: (pi) => { + requestDetails.fromResolvedPaymentIdentifier(pi) } } diff --git a/electrum/gui/qml/qepiresolver.py b/electrum/gui/qml/qepiresolver.py index 9699a4b06..ae3d9e765 100644 --- a/electrum/gui/qml/qepiresolver.py +++ b/electrum/gui/qml/qepiresolver.py @@ -17,8 +17,8 @@ class QEPIResolver(QObject): busyChanged = pyqtSignal() resolveError = pyqtSignal([str, str], arguments=['code', 'message']) - invoiceResolved = pyqtSignal(object) - requestResolved = pyqtSignal(object) + invoiceResolved = pyqtSignal([object], arguments=['pi']) + requestResolved = pyqtSignal([object], arguments=['pi']) def __init__(self, parent=None): super().__init__(parent) From bcb740695b532c59070e45c6dbd34bc3fee6d4f7 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 27 Aug 2025 17:28:59 +0200 Subject: [PATCH 6/6] lnurl: only sanitize untrusted LNURL errors it is not neccessary to sanitize all LNURLErrors as most are just source strings. --- electrum/lnurl.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/electrum/lnurl.py b/electrum/lnurl.py index 3f035bae1..9bfd0592f 100644 --- a/electrum/lnurl.py +++ b/electrum/lnurl.py @@ -10,22 +10,30 @@ import urllib.parse import aiohttp.client_exceptions -from electrum import segwit_addr +from electrum import segwit_addr, util from electrum.segwit_addr import bech32_decode, Encoding, convertbits, bech32_encode from electrum.lnaddr import LnDecodeException, LnEncodeException from electrum.network import Network from electrum.logging import get_logger +from electrum.i18n import _ _logger = get_logger(__name__) -class LNURLError(Exception): - 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) +class LNURLError(Exception): pass + +class UntrustedLNURLError(LNURLError): + def __init__(self, message=""): + # use if error messages are returned by the LNURL server, + # some services could try to trick users into doing something + # by sending a malicious error message + if message: + message = ( + f"{_('[DO NOT TRUST THIS MESSAGE]:')}\n" + f"{util.error_text_str_to_safe_str(message)}" + ) + super().__init__(message) def decode_lnurl(lnurl: str) -> str: @@ -124,7 +132,7 @@ async def _request_lnurl(url: str) -> dict: status = response.get("status") if status and status == "ERROR": - raise LNURLError(f"LNURL request encountered an error: {response.get('reason', '')}") + raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '')}") return response @@ -168,7 +176,7 @@ 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=}") + raise UntrustedLNURLError(f"Missing k1 value in LNURL3 response: {lnurl_response=}") default_description = lnurl_response.get('defaultDescription', '') try: min_withdrawable_sat = int(lnurl_response['minWithdrawable']) // 1000 @@ -194,7 +202,7 @@ async def request_lnurl(url: str) -> LNURLData: return _parse_lnurl6_response(lnurl_dict) elif tag == 'withdrawRequest': return _parse_lnurl3_response(lnurl_dict) - raise LNURLError(f"Unknown subtype of lnurl. tag={tag}") + raise UntrustedLNURLError(f"Unknown subtype of lnurl. tag={tag}") async def try_resolve_lnurlpay(lnurl: Optional[str]) -> Optional[LNURL6Data]: @@ -236,7 +244,7 @@ async def callback_lnurl(url: str, params: dict) -> dict: status = response.get("status") if status and status == "ERROR": - raise LNURLError(f"LNURL request encountered an error: {response.get('reason', '')}") + raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '')}") # TODO: handling of specific errors (validate fields, e.g. for lnurl6) return response