1
0

lnurl: implement LNURL-withdraw

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

View File

@@ -0,0 +1,161 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material
import org.electrum 1.0
import "controls"
ElDialog {
id: dialog
title: qsTr('LNURL Withdraw request')
iconSource: '../../../icons/link.png'
property InvoiceParser invoiceParser
padding: 0
property int walletCanReceive: invoiceParser.wallet.lightningCanReceive.satsInt
property int providerMinWithdrawable: parseInt(invoiceParser.lnurlData['min_withdrawable_sat'])
property int providerMaxWithdrawable: parseInt(invoiceParser.lnurlData['max_withdrawable_sat'])
property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1)
property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive)
property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive
property bool liquidityWarning: providerMaxWithdrawable > walletCanReceive
property bool amountValid: !dialog.insufficientLiquidity &&
amountBtc.textAsSats.satsInt >= dialog.effectiveMinWithdrawable &&
amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable
property bool valid: amountValid
ColumnLayout {
width: parent.width
GridLayout {
id: rootLayout
columns: 2
Layout.fillWidth: true
Layout.leftMargin: constants.paddingLarge
Layout.rightMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
compact: true
visible: dialog.insufficientLiquidity
text: qsTr('Too little incoming liquidity to satisfy this withdrawal request.')
+ '\n\n'
+ qsTr('Can receive: %1')
.arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit)
+ '\n'
+ qsTr('Minimum withdrawal amount: %1')
.arg(Config.formatSats(dialog.providerMinWithdrawable) + ' ' + Config.baseUnit)
+ '\n\n'
+ qsTr('Do a submarine swap in the \'Channels\' tab to get more incoming liquidity.')
iconStyle: InfoTextArea.IconStyle.Error
}
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
compact: true
visible: !dialog.insufficientLiquidity && dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable
text: qsTr('Amount must be between %1 and %2 %3')
.arg(Config.formatSats(dialog.effectiveMinWithdrawable))
.arg(Config.formatSats(dialog.effectiveMaxWithdrawable))
.arg(Config.baseUnit)
}
InfoTextArea {
Layout.columnSpan: 2
Layout.fillWidth: true
compact: true
visible: dialog.liquidityWarning && !dialog.insufficientLiquidity
text: qsTr('The maximum withdrawable amount (%1) is larger than what your channels can receive (%2).')
.arg(Config.formatSats(dialog.providerMaxWithdrawable) + ' ' + Config.baseUnit)
.arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit)
+ ' '
+ qsTr('You may need to do a submarine swap to increase your incoming liquidity.')
iconStyle: InfoTextArea.IconStyle.Warn
}
Label {
text: qsTr('Provider')
color: Material.accentColor
}
Label {
Layout.fillWidth: true
text: invoiceParser.lnurlData['domain']
}
Label {
text: qsTr('Description')
color: Material.accentColor
visible: invoiceParser.lnurlData['default_description']
}
Label {
Layout.fillWidth: true
text: invoiceParser.lnurlData['default_description']
visible: invoiceParser.lnurlData['default_description']
wrapMode: Text.Wrap
}
Label {
text: qsTr('Amount')
color: Material.accentColor
}
RowLayout {
Layout.fillWidth: true
BtcField {
id: amountBtc
Layout.preferredWidth: rootLayout.width / 3
text: Config.formatSatsForEditing(dialog.effectiveMaxWithdrawable)
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
color: Material.foreground // override gray-out on disabled
fiatfield: amountFiat
onTextAsSatsChanged: {
invoiceParser.amountOverride = textAsSats
}
}
Label {
text: Config.baseUnit
color: Material.accentColor
}
}
Item { visible: Daemon.fx.enabled; Layout.preferredWidth: 1; Layout.preferredHeight: 1 }
RowLayout {
visible: Daemon.fx.enabled
FiatField {
id: amountFiat
Layout.preferredWidth: rootLayout.width / 3
btcfield: amountBtc
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
color: Material.foreground
}
Label {
text: Daemon.fx.fiatCurrency
color: Material.accentColor
}
}
}
FlatButton {
Layout.topMargin: constants.paddingLarge
Layout.fillWidth: true
text: qsTr('Withdraw...')
icon.source: '../../icons/confirmed.png'
enabled: valid
onClicked: {
invoiceParser.lnurlRequestWithdrawal()
dialog.close()
}
}
}
}

View File

@@ -57,8 +57,8 @@ ElDialog {
Layout.fillHeight: true
hint: Daemon.currentWallet.isLightning
? qsTr('Scan an Invoice, an Address, an LNURL-pay, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL-pay or a PSBT')
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
onFoundText: (data) => {
dialog.dispatch(data)

View File

@@ -44,8 +44,8 @@ Item {
// Android based send dialog if on android
var scanner = app.scanDialog.createObject(mainView, {
hint: Daemon.currentWallet.isLightning
? qsTr('Scan an Invoice, an Address, an LNURL-pay, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL-pay or a PSBT')
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
})
scanner.onFoundText.connect(function(data) {
data = data.trim()
@@ -423,9 +423,18 @@ Item {
onLnurlRetrieved: {
closeSendDialog()
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 {

View File

@@ -14,6 +14,7 @@ from electrum.invoices import (
)
from electrum.transaction import PartialTxOutput, TxOutput
from electrum.lnutil import format_short_channel_id
from electrum.lnurl import LNURLData, LNURL3Data, LNURL6Data, request_lnurl_withdraw_callback, LNURLError
from electrum.bitcoin import COIN, address_to_script
from electrum.paymentrequest import PaymentRequest
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
@@ -33,6 +34,7 @@ class QEInvoice(QObject, QtEventListener):
OnchainInvoice = 0
LightningInvoice = 1
LNURLPayRequest = 2
LNURLWithdrawRequest = 3
@pyqtEnum
class Status(IntEnum):
@@ -478,7 +480,7 @@ class QEInvoiceParser(QEInvoice):
@pyqtProperty(bool, notify=lnurlRetrieved)
def isLnurlPay(self):
return self._lnurlData is not None
return self._lnurlData is not None and self.invoiceType == QEInvoice.Type.LNURLPayRequest
@pyqtProperty(bool, notify=busyChanged)
def busy(self):
@@ -513,6 +515,12 @@ class QEInvoiceParser(QEInvoice):
self._effectiveInvoice = None
self.invoiceChanged.emit()
def setValidLNURLWithdrawRequest(self):
self._logger.debug('setValidLNURLWithdrawRequest')
self.setInvoiceType(QEInvoice.Type.LNURLWithdrawRequest)
self._effectiveInvoice = None
self.invoiceChanged.emit()
def create_onchain_invoice(self, outputs, message, payment_request, uri):
return self._wallet.wallet.create_invoice(
outputs=outputs,
@@ -543,7 +551,7 @@ class QEInvoiceParser(QEInvoice):
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNURL,
PaymentIdentifierType.EMAILLIKE,
PaymentIdentifierType.DOMAINLIKE]:
self.validationError.emit('unknown', _('Unknown invoice'))
@@ -562,7 +570,11 @@ class QEInvoiceParser(QEInvoice):
self.resolve_pi()
return
if self._pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]:
if self._pi.type in [
PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNADDR,
]:
self.on_lnurl(self._pi.lnurl_data)
return
@@ -622,7 +634,7 @@ class QEInvoiceParser(QEInvoice):
if pi.is_error():
if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
msg = _('Could not resolve address')
elif pi.type == PaymentIdentifierType.LNURLP:
elif pi.type == PaymentIdentifierType.LNURL:
msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
elif pi.type == PaymentIdentifierType.BIP70:
msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
@@ -637,19 +649,32 @@ class QEInvoiceParser(QEInvoice):
self._pi.resolve(on_finished=on_finished)
def on_lnurl(self, lnurldata):
def on_lnurl(self, lnurldata: LNURLData):
self._logger.debug('on_lnurl')
self._logger.debug(f'{repr(lnurldata)}')
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)}')

View File

@@ -4,6 +4,7 @@
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
import urllib.parse
from PyQt6.QtCore import pyqtSignal, QPoint, Qt
from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
@@ -20,15 +21,18 @@ from electrum.util import (
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier, invoice_from_payment_identifier,
payment_identifier_from_invoice)
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier,
invoice_from_payment_identifier,
payment_identifier_from_invoice, PaymentIdentifierState)
from electrum.submarine_swaps import SwapServerError
from electrum.fee_policy import FeePolicy, FixedFeePolicy
from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, add_input_actions_to_context_menu)
get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, Buttons, WWLabel,
add_input_actions_to_context_menu, WindowModalDialog, OkButton, CancelButton)
from .invoice_list import InvoiceList
if TYPE_CHECKING:
@@ -435,7 +439,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.send_button.setEnabled(False)
return
lock_recipient = pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW,
PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70,
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
lock_amount = pi.is_amount_locked()
@@ -504,6 +509,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.show_error(pi.error)
self.do_clear()
return
if pi.type == PaymentIdentifierType.LNURLW:
assert pi.state == PaymentIdentifierState.LNURLW_FINALIZE, \
f"Detected LNURLW but not ready to finalize? {pi=}"
self.do_clear()
self.request_lnurl_withdraw_dialog(pi.lnurl_data)
return
# if openalias add openalias to contacts
if pi.type == PaymentIdentifierType.OPENALIAS:
key = pi.emaillike if pi.emaillike else pi.domainlike
@@ -830,3 +842,145 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
else:
total += output.value
self.amount_e.setAmount(total if outputs else None)
def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data):
if not self.wallet.has_lightning():
self.show_error(
_("Cannot request lightning withdrawal, wallet has no lightning channels.")
)
return
dialog = WindowModalDialog(self, _("Lightning Withdrawal"))
dialog.setMinimumWidth(400)
vbox = QVBoxLayout()
dialog.setLayout(vbox)
grid = QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1) # Make the last column stretch
row = 0
# provider url
domain_label = QLabel(_("Provider") + ":")
domain_text = WWLabel(urllib.parse.urlparse(lnurl_data.callback_url).netloc)
grid.addWidget(domain_label, row, 0)
grid.addWidget(domain_text, row, 1, 1, 3)
row += 1
if lnurl_data.default_description:
desc_label = QLabel(_("Description") + ":")
desc_text = WWLabel(lnurl_data.default_description)
grid.addWidget(desc_label, row, 0)
grid.addWidget(desc_text, row, 1, 1, 3)
row += 1
min_amount = max(lnurl_data.min_withdrawable_sat, 1)
max_amount = min(
lnurl_data.max_withdrawable_sat,
int(self.wallet.lnworker.num_sats_can_receive())
)
min_text = self.format_amount_and_units(lnurl_data.min_withdrawable_sat)
if min_amount > int(self.wallet.lnworker.num_sats_can_receive()):
self.show_error("".join([
_("Too little incoming liquidity to satisfy this withdrawal request."), "\n\n",
_("Can receive: {}").format(
self.format_amount_and_units(self.wallet.lnworker.num_sats_can_receive()),
), "\n",
_("Minimum withdrawal amount: {}").format(min_text), "\n\n",
_("Do a submarine swap in the 'Channels' tab to get more incoming liquidity.")
]))
return
is_fixed_amount = lnurl_data.min_withdrawable_sat == lnurl_data.max_withdrawable_sat
# Range information (only for non-fixed amounts)
if not is_fixed_amount:
range_label_text = QLabel(_("Range") + ":")
range_value = QLabel("{} - {}".format(
min_text,
self.format_amount_and_units(lnurl_data.max_withdrawable_sat)
))
grid.addWidget(range_label_text, row, 0)
grid.addWidget(range_value, row, 1, 1, 2)
row += 1
# Amount section
amount_label = QLabel(_("Amount") + ":")
amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount)
amount_edit.setAmount(max_amount)
grid.addWidget(amount_label, row, 0)
grid.addWidget(amount_edit, row, 1)
if is_fixed_amount:
# Fixed amount, just show the amount
amount_edit.setDisabled(True)
else:
# Range, show max button
max_button = EnterButton(_("Max"), lambda: amount_edit.setAmount(max_amount))
btn_width = 10 * char_width_in_lineedit()
max_button.setFixedWidth(btn_width)
grid.addWidget(max_button, row, 2)
row += 1
# Warning for insufficient liquidity
if lnurl_data.max_withdrawable_sat > int(self.wallet.lnworker.num_sats_can_receive()):
warning_text = WWLabel(
_("The maximum withdrawable amount is larger than what your channels can receive. "
"You may need to do a submarine swap to increase your incoming liquidity.")
)
warning_text.setStyleSheet("color: orange;")
grid.addWidget(warning_text, row, 0, 1, 4)
row += 1
vbox.addLayout(grid)
# Buttons
request_button = OkButton(dialog, _("Request Withdrawal"))
cancel_button = CancelButton(dialog)
vbox.addLayout(Buttons(cancel_button, request_button))
# Show dialog and handle result
if dialog.exec():
if is_fixed_amount:
amount_sat = lnurl_data.max_withdrawable_sat
else:
amount_sat = amount_edit.get_amount()
if not amount_sat or not (min_amount <= int(amount_sat) <= max_amount):
self.show_error(_("Enter a valid amount. You entered: {}").format(amount_sat))
return
else:
return
try:
key = self.wallet.create_request(
amount_sat=amount_sat,
message=lnurl_data.default_description,
exp_delay=120,
address=None,
)
req = self.wallet.get_request(key)
_lnaddr, b11_invoice = self.wallet.lnworker.get_bolt11_invoice(
payment_hash=req.payment_hash,
amount_msat=req.get_amount_msat(),
message=req.get_message(),
expiry=req.exp,
fallback_address=None
)
except Exception as e:
self.logger.exception('')
self.show_error(
f"{_('Failed to create payment request for withdrawal')}: {str(e)}"
)
return
coro = request_lnurl_withdraw_callback(
callback_url=lnurl_data.callback_url,
k1=lnurl_data.k1,
bolt_11=b11_invoice
)
try:
self.window.run_coroutine_dialog(coro, _("Requesting lightning withdrawal..."))
except LNURLError as e:
self.show_error(f"{_('Failed to request withdrawal')}:\n{str(e)}")