From 8437e136664412e0b731a24e34d917284f017bd0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 28 Sep 2022 18:05:45 +0200 Subject: [PATCH] add initial lnurl-pay --- .../qml/components/LnurlPayRequestDialog.qml | 66 +++++++++++++ .../gui/qml/components/WalletMainView.qml | 14 +++ electrum/gui/qml/qeinvoice.py | 92 +++++++++++++++++-- electrum/lnurl.py | 5 +- 4 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 electrum/gui/qml/components/LnurlPayRequestDialog.qml diff --git a/electrum/gui/qml/components/LnurlPayRequestDialog.qml b/electrum/gui/qml/components/LnurlPayRequestDialog.qml new file mode 100644 index 000000000..25ffc519d --- /dev/null +++ b/electrum/gui/qml/components/LnurlPayRequestDialog.qml @@ -0,0 +1,66 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('LNURL Payment request') + + // property var lnurlData + property InvoiceParser invoiceParser + // property alias lnurlData: dialog.invoiceParser.lnurlData + + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + columns: 2 + implicitWidth: parent.width + + Label { + text: qsTr('Provider') + } + Label { + text: invoiceParser.lnurlData['domain'] + } + Label { + text: qsTr('Description') + } + Label { + text: invoiceParser.lnurlData['metadata_plaintext'] + } + Label { + text: invoiceParser.lnurlData['min_sendable_sat'] == invoiceParser.lnurlData['max_sendable_sat'] + ? qsTr('Amount') + : qsTr('Amount range') + } + Label { + text: invoiceParser.lnurlData['min_sendable_sat'] == invoiceParser.lnurlData['max_sendable_sat'] + ? invoiceParser.lnurlData['min_sendable_sat'] == 0 + ? qsTr('Unspecified') + : invoiceParser.lnurlData['min_sendable_sat'] + : invoiceParser.lnurlData['min_sendable_sat'] + ' < amount < ' + invoiceParser.lnurlData['max_sendable_sat'] + } + + Button { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr('Proceed') + onClicked: { + invoiceParser.lnurlGetInvoice(invoiceParser.lnurlData['min_sendable_sat']) + dialog.close() + } + } + } +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 658ec6a73..8186c5e9b 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -163,6 +163,11 @@ Item { } onInvoiceCreateError: console.log(code + ' ' + message) + onLnurlRetrieved: { + var dialog = lnurlPayDialog.createObject(app, { invoiceParser: invoiceParser }) + dialog.open() + } + onInvoiceSaved: { Daemon.currentWallet.invoiceModel.init_model() } @@ -249,5 +254,14 @@ Item { } } + Component { + id: lnurlPayDialog + LnurlPayRequestDialog { + width: parent.width * 0.9 + anchors.centerIn: parent + + onClosed: destroy() + } + } } diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index b76187743..a897497bf 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -1,3 +1,7 @@ +import threading +import asyncio +from urllib.parse import urlparse + from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS from electrum import bitcoin @@ -11,6 +15,7 @@ from electrum.logging import get_logger from electrum.transaction import PartialTxOutput from electrum.util import (parse_URI, InvalidBitcoinURI, InvoiceError, maybe_extract_lightning_payment_identifier) +from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl from .qetypes import QEAmount from .qewallet import QEWallet @@ -18,10 +23,10 @@ from .qewallet import QEWallet class QEInvoice(QObject): class Type: Invalid = -1 - OnchainOnlyAddress = 0 - OnchainInvoice = 1 - LightningInvoice = 2 - LightningAndOnchainInvoice = 3 + OnchainInvoice = 0 + LightningInvoice = 1 + LightningAndOnchainInvoice = 2 + LNURLPayRequest = 3 class Status: Unpaid = PR_UNPAID @@ -126,6 +131,9 @@ class QEInvoiceParser(QEInvoice): invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) + lnurlRetrieved = pyqtSignal() + lnurlError = pyqtSignal([str,str], arguments=['code', 'message']) + def __init__(self, parent=None): super().__init__(parent) self.clear() @@ -148,10 +156,15 @@ class QEInvoiceParser(QEInvoice): #if self._recipient != recipient: self.canPay = False self._recipient = recipient + self._lnurlData = None if recipient: self.validateRecipient(recipient) self.recipientChanged.emit() + @pyqtProperty('QVariantMap', notify=lnurlRetrieved) + def lnurlData(self): + return self._lnurlData + @pyqtProperty(str, notify=invoiceChanged) def message(self): return self._effectiveInvoice.message if self._effectiveInvoice else '' @@ -167,11 +180,10 @@ class QEInvoiceParser(QEInvoice): @amount.setter def amount(self, new_amount): - self._logger.debug('set amount') + self._logger.debug(f'set new amount {repr(new_amount)}') if self._effectiveInvoice: self._effectiveInvoice.amount_msat = int(new_amount.satsInt * 1000) - # TODO: side effects? - # TODO: recalc outputs for onchain + self.determine_can_pay() self.invoiceChanged.emit() @@ -220,6 +232,7 @@ class QEInvoiceParser(QEInvoice): self.recipient = '' self.setInvoiceType(QEInvoice.Type.Invalid) self._bip21 = None + self._lnurlData = None self.canSave = False self.canPay = False self.userinfo = '' @@ -306,6 +319,12 @@ class QEInvoiceParser(QEInvoice): raise Exception('unexpected Onchain invoice') self.set_effective_invoice(invoice) + def setValidLNURLPayRequest(self): + self._logger.debug('setValidLNURLPayRequest') + self.setInvoiceType(QEInvoice.Type.LNURLPayRequest) + self._effectiveInvoice = None + self.invoiceChanged.emit() + def create_onchain_invoice(self, outputs, message, payment_request, uri): return self._wallet.wallet.create_invoice( outputs=outputs, @@ -353,6 +372,9 @@ class QEInvoiceParser(QEInvoice): lninvoice = None maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice) if maybe_lightning_invoice is not None: + if maybe_lightning_invoice.startswith('lnurl'): + self.resolve_lnurl(maybe_lightning_invoice) + return try: lninvoice = Invoice.from_bech32(maybe_lightning_invoice) except InvoiceError as e: @@ -378,7 +400,8 @@ class QEInvoiceParser(QEInvoice): # TODO: lightning onchain fallback in ln invoice #self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet')) self.setValidLightningInvoice(lninvoice) - self.clear() + self.validationSuccess.emit() + # self.clear() return else: self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') @@ -403,6 +426,59 @@ class QEInvoiceParser(QEInvoice): self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() + def resolve_lnurl(self, lnurl): + self._logger.debug('resolve_lnurl') + url = decode_lnurl(lnurl) + self._logger.debug(f'{repr(url)}') + + def resolve_task(): + try: + coro = request_lnurl(url) + fut = asyncio.run_coroutine_threadsafe(coro, self._wallet.wallet.network.asyncio_loop) + self.on_lnurl(fut.result()) + except Exception as e: + self.validationError.emit('lnurl', repr(e)) + + threading.Thread(target=resolve_task).start() + + def on_lnurl(self, 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 + } + self.setValidLNURLPayRequest() + self.lnurlRetrieved.emit() + + @pyqtSlot('quint64') + def lnurlGetInvoice(self, amount): + assert self._lnurlData + + self._logger.debug(f'fetching callback url {self._lnurlData["callback_url"]}') + def fetch_invoice_task(): + try: + coro = callback_lnurl(self._lnurlData['callback_url'], { + 'amount': amount * 1000 # msats + }) + fut = asyncio.run_coroutine_threadsafe(coro, self._wallet.wallet.network.asyncio_loop) + self.on_lnurl_invoice(fut.result()) + except Exception as e: + self.lnurlError.emit('lnurl', repr(e)) + + threading.Thread(target=fetch_invoice_task).start() + + def on_lnurl_invoice(self, invoice): + self._logger.debug('on_lnurl_invoice') + self._logger.debug(f'{repr(invoice)}') + + invoice = invoice['pr'] + self.recipient = invoice + @pyqtSlot() def save_invoice(self): self.canSave = False diff --git a/electrum/lnurl.py b/electrum/lnurl.py index e41a73b58..cd9850a21 100644 --- a/electrum/lnurl.py +++ b/electrum/lnurl.py @@ -52,12 +52,15 @@ async def _request_lnurl(url: str) -> dict: """Requests payment data from a lnurl.""" try: response = await Network.async_send_http_on_proxy("get", url, timeout=10) + response = json.loads(response) except asyncio.TimeoutError as e: raise LNURLError("Server did not reply in time.") from e except aiohttp.client_exceptions.ClientError as e: raise LNURLError(f"Client error: {e}") from e + except json.JSONDecodeError: + raise LNURLError(f"Invalid response from server") # TODO: handling of specific client errors - response = json.loads(response) + if "metadata" in response: response["metadata"] = json.loads(response["metadata"]) status = response.get("status")