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:
161
electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
Normal file
161
electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}')
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user