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:
@@ -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
|
Layout.fillHeight: true
|
||||||
|
|
||||||
hint: Daemon.currentWallet.isLightning
|
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, 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 or a PSBT')
|
||||||
|
|
||||||
onFoundText: (data) => {
|
onFoundText: (data) => {
|
||||||
dialog.dispatch(data)
|
dialog.dispatch(data)
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ Item {
|
|||||||
// Android based send dialog if on android
|
// Android based send dialog if on android
|
||||||
var scanner = app.scanDialog.createObject(mainView, {
|
var scanner = app.scanDialog.createObject(mainView, {
|
||||||
hint: Daemon.currentWallet.isLightning
|
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, 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 or a PSBT')
|
||||||
})
|
})
|
||||||
scanner.onFoundText.connect(function(data) {
|
scanner.onFoundText.connect(function(data) {
|
||||||
data = data.trim()
|
data = data.trim()
|
||||||
@@ -423,9 +423,18 @@ Item {
|
|||||||
|
|
||||||
onLnurlRetrieved: {
|
onLnurlRetrieved: {
|
||||||
closeSendDialog()
|
closeSendDialog()
|
||||||
var dialog = lnurlPayDialog.createObject(app, {
|
if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) {
|
||||||
invoiceParser: invoiceParser
|
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()
|
dialog.open()
|
||||||
}
|
}
|
||||||
onLnurlError: (code, message) => {
|
onLnurlError: (code, message) => {
|
||||||
@@ -739,6 +748,16 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: lnurlWithdrawDialog
|
||||||
|
LnurlWithdrawRequestDialog {
|
||||||
|
width: parent.width * 0.9
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
onClosed: destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: otpDialog
|
id: otpDialog
|
||||||
OtpDialog {
|
OtpDialog {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from electrum.invoices import (
|
|||||||
)
|
)
|
||||||
from electrum.transaction import PartialTxOutput, TxOutput
|
from electrum.transaction import PartialTxOutput, TxOutput
|
||||||
from electrum.lnutil import format_short_channel_id
|
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.bitcoin import COIN, address_to_script
|
||||||
from electrum.paymentrequest import PaymentRequest
|
from electrum.paymentrequest import PaymentRequest
|
||||||
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
|
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
|
||||||
@@ -33,6 +34,7 @@ class QEInvoice(QObject, QtEventListener):
|
|||||||
OnchainInvoice = 0
|
OnchainInvoice = 0
|
||||||
LightningInvoice = 1
|
LightningInvoice = 1
|
||||||
LNURLPayRequest = 2
|
LNURLPayRequest = 2
|
||||||
|
LNURLWithdrawRequest = 3
|
||||||
|
|
||||||
@pyqtEnum
|
@pyqtEnum
|
||||||
class Status(IntEnum):
|
class Status(IntEnum):
|
||||||
@@ -478,7 +480,7 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
|
|
||||||
@pyqtProperty(bool, notify=lnurlRetrieved)
|
@pyqtProperty(bool, notify=lnurlRetrieved)
|
||||||
def isLnurlPay(self):
|
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)
|
@pyqtProperty(bool, notify=busyChanged)
|
||||||
def busy(self):
|
def busy(self):
|
||||||
@@ -513,6 +515,12 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
self._effectiveInvoice = None
|
self._effectiveInvoice = None
|
||||||
self.invoiceChanged.emit()
|
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):
|
def create_onchain_invoice(self, outputs, message, payment_request, uri):
|
||||||
return self._wallet.wallet.create_invoice(
|
return self._wallet.wallet.create_invoice(
|
||||||
outputs=outputs,
|
outputs=outputs,
|
||||||
@@ -543,7 +551,7 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
|
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
|
||||||
if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
|
if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
|
||||||
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
|
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
|
||||||
PaymentIdentifierType.LNURLP,
|
PaymentIdentifierType.LNURL,
|
||||||
PaymentIdentifierType.EMAILLIKE,
|
PaymentIdentifierType.EMAILLIKE,
|
||||||
PaymentIdentifierType.DOMAINLIKE]:
|
PaymentIdentifierType.DOMAINLIKE]:
|
||||||
self.validationError.emit('unknown', _('Unknown invoice'))
|
self.validationError.emit('unknown', _('Unknown invoice'))
|
||||||
@@ -562,7 +570,11 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
self.resolve_pi()
|
self.resolve_pi()
|
||||||
return
|
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)
|
self.on_lnurl(self._pi.lnurl_data)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -622,7 +634,7 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
if pi.is_error():
|
if pi.is_error():
|
||||||
if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
|
if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
|
||||||
msg = _('Could not resolve address')
|
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()
|
msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
|
||||||
elif pi.type == PaymentIdentifierType.BIP70:
|
elif pi.type == PaymentIdentifierType.BIP70:
|
||||||
msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
|
msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
|
||||||
@@ -637,19 +649,32 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
|
|
||||||
self._pi.resolve(on_finished=on_finished)
|
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('on_lnurl')
|
||||||
self._logger.debug(f'{repr(lnurldata)}')
|
self._logger.debug(f'{repr(lnurldata)}')
|
||||||
|
|
||||||
self._lnurlData = {
|
if isinstance(lnurldata, LNURL6Data):
|
||||||
'domain': urlparse(lnurldata.callback_url).netloc,
|
self._lnurlData = {
|
||||||
'callback_url': lnurldata.callback_url,
|
'domain': urlparse(lnurldata.callback_url).netloc,
|
||||||
'min_sendable_sat': lnurldata.min_sendable_sat,
|
'callback_url': lnurldata.callback_url,
|
||||||
'max_sendable_sat': lnurldata.max_sendable_sat,
|
'min_sendable_sat': lnurldata.min_sendable_sat,
|
||||||
'metadata_plaintext': lnurldata.metadata_plaintext,
|
'max_sendable_sat': lnurldata.max_sendable_sat,
|
||||||
'comment_allowed': lnurldata.comment_allowed
|
'metadata_plaintext': lnurldata.metadata_plaintext,
|
||||||
}
|
'comment_allowed': lnurldata.comment_allowed,
|
||||||
self.setValidLNURLPayRequest()
|
}
|
||||||
|
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()
|
self.lnurlRetrieved.emit()
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
@@ -657,6 +682,7 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
def lnurlGetInvoice(self, comment=None):
|
def lnurlGetInvoice(self, comment=None):
|
||||||
assert self._lnurlData
|
assert self._lnurlData
|
||||||
assert self._pi.need_finalize()
|
assert self._pi.need_finalize()
|
||||||
|
assert self.invoiceType == QEInvoice.Type.LNURLPayRequest
|
||||||
self._logger.debug(f'{repr(self._lnurlData)}')
|
self._logger.debug(f'{repr(self._lnurlData)}')
|
||||||
|
|
||||||
amount = self.amountOverride.satsInt
|
amount = self.amountOverride.satsInt
|
||||||
@@ -681,6 +707,54 @@ class QEInvoiceParser(QEInvoice):
|
|||||||
|
|
||||||
self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
|
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):
|
def on_lnurl_invoice(self, orig_amount, invoice):
|
||||||
self._logger.debug('on_lnurl_invoice')
|
self._logger.debug('on_lnurl_invoice')
|
||||||
self._logger.debug(f'{repr(invoice)}')
|
self._logger.debug(f'{repr(invoice)}')
|
||||||
|
|||||||
+158
-4
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
|
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from PyQt6.QtCore import pyqtSignal, QPoint, Qt
|
from PyQt6.QtCore import pyqtSignal, QPoint, Qt
|
||||||
from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
|
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.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
|
||||||
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
|
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
|
||||||
from electrum.network import TxBroadcastError, BestEffortRequestFailed
|
from electrum.network import TxBroadcastError, BestEffortRequestFailed
|
||||||
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier, invoice_from_payment_identifier,
|
from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier,
|
||||||
payment_identifier_from_invoice)
|
invoice_from_payment_identifier,
|
||||||
|
payment_identifier_from_invoice, PaymentIdentifierState)
|
||||||
from electrum.submarine_swaps import SwapServerError
|
from electrum.submarine_swaps import SwapServerError
|
||||||
from electrum.fee_policy import FeePolicy, FixedFeePolicy
|
from electrum.fee_policy import FeePolicy, FixedFeePolicy
|
||||||
|
from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
|
||||||
|
|
||||||
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
|
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
|
||||||
from .paytoedit import InvalidPaymentIdentifier
|
from .paytoedit import InvalidPaymentIdentifier
|
||||||
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
|
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
|
from .invoice_list import InvoiceList
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -435,7 +439,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
|||||||
self.send_button.setEnabled(False)
|
self.send_button.setEnabled(False)
|
||||||
return
|
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.OPENALIAS, PaymentIdentifierType.BIP70,
|
||||||
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
|
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
|
||||||
lock_amount = pi.is_amount_locked()
|
lock_amount = pi.is_amount_locked()
|
||||||
@@ -504,6 +509,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
|||||||
self.show_error(pi.error)
|
self.show_error(pi.error)
|
||||||
self.do_clear()
|
self.do_clear()
|
||||||
return
|
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 openalias add openalias to contacts
|
||||||
if pi.type == PaymentIdentifierType.OPENALIAS:
|
if pi.type == PaymentIdentifierType.OPENALIAS:
|
||||||
key = pi.emaillike if pi.emaillike else pi.domainlike
|
key = pi.emaillike if pi.emaillike else pi.domainlike
|
||||||
@@ -830,3 +842,145 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
|||||||
else:
|
else:
|
||||||
total += output.value
|
total += output.value
|
||||||
self.amount_e.setAmount(total if outputs else None)
|
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)}")
|
||||||
|
|||||||
+92
-24
@@ -9,7 +9,6 @@ import re
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import aiohttp.client_exceptions
|
import aiohttp.client_exceptions
|
||||||
from aiohttp import ClientResponse
|
|
||||||
|
|
||||||
from electrum import segwit_addr
|
from electrum import segwit_addr
|
||||||
from electrum.segwit_addr import bech32_decode, Encoding, convertbits, bech32_encode
|
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.network import Network
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Coroutine
|
|
||||||
|
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LNURLError(Exception):
|
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:
|
def decode_lnurl(lnurl: str) -> str:
|
||||||
@@ -68,6 +68,19 @@ def _is_url_safe_enough_for_lnurl(url: str) -> bool:
|
|||||||
return False
|
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):
|
class LNURL6Data(NamedTuple):
|
||||||
callback_url: str
|
callback_url: str
|
||||||
max_sendable_sat: int
|
max_sendable_sat: int
|
||||||
@@ -76,6 +89,23 @@ class LNURL6Data(NamedTuple):
|
|||||||
comment_allowed: int
|
comment_allowed: int
|
||||||
#tag: str = "payRequest"
|
#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:
|
async def _request_lnurl(url: str) -> dict:
|
||||||
"""Requests payment data from a lnurl."""
|
"""Requests payment data from a lnurl."""
|
||||||
@@ -98,37 +128,30 @@ async def _request_lnurl(url: str) -> dict:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def request_lnurl(url: str) -> LNURL6Data:
|
def _parse_lnurl6_response(lnurl_response: dict) -> 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}")
|
|
||||||
# parse lnurl6 "metadata"
|
# parse lnurl6 "metadata"
|
||||||
metadata_plaintext = ""
|
metadata_plaintext = ""
|
||||||
try:
|
try:
|
||||||
metadata_raw = lnurl_dict["metadata"]
|
metadata_raw = lnurl_response["metadata"]
|
||||||
metadata = json.loads(metadata_raw)
|
metadata = json.loads(metadata_raw)
|
||||||
for m in metadata:
|
for m in metadata:
|
||||||
if m[0] == 'text/plain':
|
if m[0] == 'text/plain':
|
||||||
metadata_plaintext = str(m[1])
|
metadata_plaintext = str(m[1])
|
||||||
except Exception as e:
|
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"
|
# parse lnurl6 "callback"
|
||||||
try:
|
callback_url = _parse_lnurl_response_callback_url(lnurl_response)
|
||||||
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]}...)")
|
|
||||||
# parse lnurl6 "minSendable"/"maxSendable"
|
# parse lnurl6 "minSendable"/"maxSendable"
|
||||||
try:
|
try:
|
||||||
max_sendable_sat = int(lnurl_dict['maxSendable']) // 1000
|
max_sendable_sat = int(lnurl_response['maxSendable']) // 1000
|
||||||
min_sendable_sat = int(lnurl_dict['minSendable']) // 1000
|
min_sendable_sat = int(lnurl_response['minSendable']) // 1000
|
||||||
except Exception as e:
|
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)
|
# parse lnurl6 "commentAllowed" (optional, described in lnurl-12)
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
raise LNURLError(f"Malformed 'commentAllowed' field in lnurl6 response. {e=!r}") from e
|
raise LNURLError(f"Malformed 'commentAllowed' field in lnurl6 response. {e=!r}") from e
|
||||||
data = LNURL6Data(
|
data = LNURL6Data(
|
||||||
@@ -141,14 +164,59 @@ async def request_lnurl(url: str) -> LNURL6Data:
|
|||||||
return data
|
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:
|
if lnurl:
|
||||||
try:
|
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:
|
except Exception as request_error:
|
||||||
_logger.debug(f"Error resolving lnurl: {request_error!r}")
|
_logger.debug(f"Error resolving lnurl: {request_error!r}")
|
||||||
return None
|
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:
|
async def callback_lnurl(url: str, params: dict) -> dict:
|
||||||
"""Requests an invoice from a lnurl supporting server."""
|
"""Requests an invoice from a lnurl supporting server."""
|
||||||
|
|||||||
@@ -2258,7 +2258,7 @@ class LNWallet(LNWorker):
|
|||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
needs_jit: bool = self.receive_requires_jit_channel(amount_msat)
|
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)
|
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()
|
invoice_features = self.features.for_invoice()
|
||||||
if not self.uses_trampoline():
|
if not self.uses_trampoline():
|
||||||
invoice_features &= ~ LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM
|
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 parse_max_spend, InvoiceError
|
||||||
from .util import get_asyncio_loop, log_exceptions
|
from .util import get_asyncio_loop, log_exceptions
|
||||||
from .transaction import PartialTxOutput
|
from .transaction import PartialTxOutput
|
||||||
from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url,
|
from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError,
|
||||||
try_resolve_lnurl)
|
lightning_address_to_url, try_resolve_lnurlpay, LNURL6Data,
|
||||||
|
LNURL3Data, LNURLData)
|
||||||
from .bitcoin import opcodes, construct_script
|
from .bitcoin import opcodes, construct_script
|
||||||
from .lnaddr import LnInvoiceException
|
from .lnaddr import LnInvoiceException
|
||||||
from .lnutil import IncompatibleOrInsaneFeatures
|
from .lnutil import IncompatibleOrInsaneFeatures
|
||||||
@@ -60,10 +61,11 @@ class PaymentIdentifierState(IntEnum):
|
|||||||
# of the channels Electrum supports (on-chain, lightning)
|
# of the channels Electrum supports (on-chain, lightning)
|
||||||
NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step
|
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
|
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,
|
# the merchant payment processor of the tx after on-chain broadcast,
|
||||||
# and supply a refund address (bip70)
|
# 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
|
ERROR = 50 # generic error
|
||||||
NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccessful
|
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
|
MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX
|
||||||
@@ -77,11 +79,13 @@ class PaymentIdentifierType(IntEnum):
|
|||||||
BIP70 = 3
|
BIP70 = 3
|
||||||
MULTILINE = 4
|
MULTILINE = 4
|
||||||
BOLT11 = 5
|
BOLT11 = 5
|
||||||
LNURLP = 6
|
LNURL = 6 # before the resolve it's unknown if pi is LNURLP or LNURLW
|
||||||
EMAILLIKE = 7
|
LNURLP = 7
|
||||||
OPENALIAS = 8
|
LNURLW = 8
|
||||||
LNADDR = 9
|
EMAILLIKE = 9
|
||||||
DOMAINLIKE = 10
|
OPENALIAS = 10
|
||||||
|
LNADDR = 11
|
||||||
|
DOMAINLIKE = 12
|
||||||
|
|
||||||
|
|
||||||
class FieldsForGUI(NamedTuple):
|
class FieldsForGUI(NamedTuple):
|
||||||
@@ -133,8 +137,8 @@ class PaymentIdentifier(Logger):
|
|||||||
self.merchant_ack_status = None
|
self.merchant_ack_status = None
|
||||||
self.merchant_ack_message = None
|
self.merchant_ack_message = None
|
||||||
#
|
#
|
||||||
self.lnurl = None
|
self.lnurl = None # type: Optional[str]
|
||||||
self.lnurl_data = None
|
self.lnurl_data = None # type: Optional[LNURLData]
|
||||||
|
|
||||||
self.parse(text)
|
self.parse(text)
|
||||||
|
|
||||||
@@ -223,7 +227,7 @@ class PaymentIdentifier(Logger):
|
|||||||
self.set_state(PaymentIdentifierState.AVAILABLE)
|
self.set_state(PaymentIdentifierState.AVAILABLE)
|
||||||
elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
|
elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
|
||||||
if invoice_or_lnurl.startswith('lnurl'):
|
if invoice_or_lnurl.startswith('lnurl'):
|
||||||
self._type = PaymentIdentifierType.LNURLP
|
self._type = PaymentIdentifierType.LNURL
|
||||||
try:
|
try:
|
||||||
self.lnurl = decode_lnurl(invoice_or_lnurl)
|
self.lnurl = decode_lnurl(invoice_or_lnurl)
|
||||||
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
|
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
|
||||||
@@ -317,7 +321,7 @@ class PaymentIdentifier(Logger):
|
|||||||
|
|
||||||
# prefers lnurl over openalias if both are available
|
# prefers lnurl over openalias if both are available
|
||||||
lnurl = lightning_address_to_url(self.emaillike) if self.emaillike else None
|
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()
|
openalias_task.cancel()
|
||||||
self._type = PaymentIdentifierType.LNADDR
|
self._type = PaymentIdentifierType.LNADDR
|
||||||
self.lnurl = lnurl
|
self.lnurl = lnurl
|
||||||
@@ -353,7 +357,14 @@ class PaymentIdentifier(Logger):
|
|||||||
elif self.lnurl:
|
elif self.lnurl:
|
||||||
data = await request_lnurl(self.lnurl)
|
data = await request_lnurl(self.lnurl)
|
||||||
self.lnurl_data = data
|
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}')
|
self.logger.debug(f'LNURL data: {data!r}')
|
||||||
else:
|
else:
|
||||||
self.set_state(PaymentIdentifierState.ERROR)
|
self.set_state(PaymentIdentifierState.ERROR)
|
||||||
@@ -592,6 +603,7 @@ class PaymentIdentifier(Logger):
|
|||||||
recipient, amount, description = self._get_bolt11_fields()
|
recipient, amount, description = self._get_bolt11_fields()
|
||||||
|
|
||||||
elif self.lnurl and self.lnurl_data:
|
elif self.lnurl and self.lnurl_data:
|
||||||
|
assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}"
|
||||||
domain = urllib.parse.urlparse(self.lnurl).netloc
|
domain = urllib.parse.urlparse(self.lnurl).netloc
|
||||||
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
|
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
|
||||||
description = self.lnurl_data.metadata_plaintext
|
description = self.lnurl_data.metadata_plaintext
|
||||||
|
|||||||
@@ -20,3 +20,85 @@ class TestLnurl(TestCase):
|
|||||||
def test_lightning_address_to_url(self):
|
def test_lightning_address_to_url(self):
|
||||||
url = lnurl.lightning_address_to_url("mempool@jhoenicke.de")
|
url = lnurl.lightning_address_to_url("mempool@jhoenicke.de")
|
||||||
self.assertEqual("https://jhoenicke.de/.well-known/lnurlp/mempool", url)
|
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