From fe2fbbd9b1b7598baf4b86c5696234944ca75a37 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 23 Nov 2021 14:42:43 +0100 Subject: [PATCH] add lnurl-pay and lightning address support * bundles all payment identifiers into handle_payment_identifier * adds lnurl decoding * adds lightning address decoding --- electrum/gui/kivy/main_window.py | 4 +- electrum/gui/kivy/uix/screens.py | 6 +- electrum/gui/qt/__init__.py | 4 +- electrum/gui/qt/main_window.py | 105 ++++++++++++++++++++++++++----- electrum/gui/qt/paytoedit.py | 37 +++++++---- electrum/lnurl.py | 75 ++++++++++++++++++++++ electrum/tests/test_lnurl.py | 12 ++++ electrum/util.py | 10 ++- run_electrum | 7 +-- 9 files changed, 221 insertions(+), 39 deletions(-) create mode 100644 electrum/lnurl.py create mode 100644 electrum/tests/test_lnurl.py diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index ce9fe054c..0ccd8afd0 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -18,7 +18,7 @@ from electrum.plugin import run_hook from electrum import util from electrum.util import (profiler, InvalidPassword, send_exception_to_crash_reporter, format_satoshis, format_satoshis_plain, format_fee_satoshis, - maybe_extract_bolt11_invoice, parse_max_spend) + maybe_extract_lightning_payment_identifier, parse_max_spend) from electrum.util import EventListener, event_listener from electrum.invoices import PR_PAID, PR_FAILED, Invoice from electrum import blockchain @@ -491,7 +491,7 @@ class ElectrumWindow(App, Logger, EventListener): if data.lower().startswith('channel_backup:'): self.import_channel_backup(data) return - bolt11_invoice = maybe_extract_bolt11_invoice(data) + bolt11_invoice = maybe_extract_lightning_payment_identifier(data) if bolt11_invoice is not None: self.set_ln_invoice(bolt11_invoice) return diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 419d5a632..0b619f348 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -16,7 +16,7 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING, pr_expiration_values, Invoice) from electrum import bitcoin, constants from electrum.transaction import tx_from_any, PartialTxOutput -from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice, +from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, InvoiceError, format_time, parse_max_spend) from electrum.lnaddr import lndecode, LnInvoiceException from electrum.logging import Logger @@ -172,7 +172,7 @@ class SendScreen(CScreen, Logger): if not self.app.wallet: return # interpret as lighting URI - bolt11_invoice = maybe_extract_bolt11_invoice(text) + bolt11_invoice = maybe_extract_lightning_payment_identifier(text) if bolt11_invoice: self.set_ln_invoice(bolt11_invoice) # interpret as BIP21 URI @@ -287,7 +287,7 @@ class SendScreen(CScreen, Logger): self.app.tx_dialog(tx) return # try to decode as URI/address - bolt11_invoice = maybe_extract_bolt11_invoice(data) + bolt11_invoice = maybe_extract_lightning_payment_identifier(data) if bolt11_invoice is not None: self.set_ln_invoice(bolt11_invoice) else: diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index c5bb05ce4..994aafa64 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -87,7 +87,7 @@ class OpenFileEventFilter(QObject): def eventFilter(self, obj, event): if event.type() == QtCore.QEvent.FileOpen: if len(self.windows) >= 1: - self.windows[0].pay_to_URI(event.url().toString()) + self.windows[0].handle_payment_identifier(event.url().toString()) return True return False @@ -383,7 +383,7 @@ class ElectrumGui(BaseElectrumGui, Logger): self.start_new_window(path, uri=None, force_wizard=True) return if uri: - window.pay_to_URI(uri) + window.handle_payment_identifier(uri) window.bring_to_top() window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index e4f561352..9dc937ace 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -38,6 +38,7 @@ import queue import asyncio from typing import Optional, TYPE_CHECKING, Sequence, List, Union, Dict, Set import concurrent.futures +from urllib.parse import urlparse from PyQt5.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont from PyQt5.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal, QPoint @@ -62,7 +63,7 @@ from electrum.util import (format_time, bh2u, bfh, InvalidPassword, UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, - InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds, + InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds, NoDynamicFeeEstimates, AddTransactionException, BITCOIN_BIP21_URI_SCHEME, InvoiceError, parse_max_spend) @@ -81,6 +82,7 @@ from electrum.simple_config import SimpleConfig from electrum.logging import Logger from electrum.lnutil import ln_dummy_address, extract_nodeid, ConnStringFormatError from electrum.lnaddr import lndecode, LnInvoiceException +from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, lightning_address_to_url, LNURLError from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit, SizedFreezableLineEdit @@ -820,7 +822,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): d = self.network.get_donation_address() if d: host = self.network.get_parameters().server.host - self.pay_to_URI('bitcoin:%s?message=donation for %s'%(d, host)) + self.handle_payment_identifier('bitcoin:%s?message=donation for %s' % (d, host)) else: self.show_error(_('No donation address for this server')) @@ -1573,8 +1575,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): grid.addWidget(self.max_button, 3, 3) self.save_button = EnterButton(_("Save"), self.do_save_invoice) - self.send_button = EnterButton(_("Pay") + "...", self.do_pay) + self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) self.clear_button = EnterButton(_("Clear"), self.do_clear) + self._is_lnurl = False buttons = QHBoxLayout() buttons.addStretch(1) @@ -1909,7 +1912,31 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.invoice_list.update() self.pending_invoice = None - def do_pay(self): + def do_pay_or_get_invoice(self): + if self._is_lnurl: + amount = self.amount_e.get_amount() + if not (self.lnurl_min_sendable_sat <= amount <= self.lnurl_max_sendable_sat): + self.show_error(f'Amount must be between {self.lnurl_min_sendable_sat} and {self.lnurl_max_sendable_sat} sat.') + return + try: + invoice_data = callback_lnurl( + self.lnurl_callback_url, + params={'amount': self.amount_e.get_amount() * 1000}, + request_over_proxy=self.network.send_http_on_proxy, + ) + except LNURLError as e: + self.show_error(f"LNURL request encountered error: {e}") + self.do_clear() + return + invoice = invoice_data.get('pr') + self.set_bolt11(invoice) + self.payto_e.setFrozen(True) + self.amount_e.setDisabled(True) + self.fiat_send_e.setDisabled(True) + self.save_button.setEnabled(True) + self.send_button.setText('Pay...') + self._is_lnurl = False + return self.pending_invoice = self.read_invoice() if not self.pending_invoice: return @@ -2221,7 +2248,31 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): else: self.payment_request_error_signal.emit() - def set_ln_invoice(self, invoice: str): + def set_lnurl6(self, lnurl: str): + url = lightning_address_to_url(lnurl) + if not url: + url = decode_lnurl(lnurl) + domain = urlparse(url).netloc + lnurl_data = request_lnurl(url, self.network.send_http_on_proxy) + self.lnurl_callback_url = lnurl_data.get('callback') + self.lnurl_max_sendable_sat = int(lnurl_data.get('maxSendable')) // 1000 + self.lnurl_min_sendable_sat = int(lnurl_data.get('minSendable')) // 1000 + metadata = lnurl_data.get('metadata') + tag = lnurl_data.get('tag') + + if tag == 'payRequest': + self.payto_e.setFrozen(True) + for m in metadata: + if m[0] == 'text/plain': + self._is_lnurl = True + self.payto_e.setTextNosignal(f"invoice from lnurl") + self.message_e.setText(f"lnurl: {domain}: {m[1]}") + self.amount_e.setAmount(self.lnurl_min_sendable_sat) + self.save_button.setDisabled(True) + self.send_button.setText('Get Invoice') + self.set_onchain(False) + + def set_bolt11(self, invoice: str): """Parse ln invoice, and prepare the send tab for it.""" try: lnaddr = lndecode(invoice) @@ -2237,9 +2288,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): else: description = '' self.payto_e.setFrozen(True) - self.payto_e.setText(pubkey) + self.payto_e.setTextNosignal(pubkey) self.payto_e.lightning_invoice = invoice - self.message_e.setText(description) + if not self.message_e.text(): + self.message_e.setText(description) if lnaddr.get_amount_sat() is not None: self.amount_e.setAmount(lnaddr.get_amount_sat()) self.set_onchain(False) @@ -2267,7 +2319,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): message = out.get('message') lightning = out.get('lightning') if lightning: - self.set_ln_invoice(lightning) + self.handle_payment_identifier(lightning) return # use label as description (not BIP21 compliant) if label and not message: @@ -2279,20 +2331,41 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if amount: self.amount_e.setAmount(amount) - def pay_to_URI(self, text: str): + def handle_payment_identifier(self, text: str): + """Takes + Lightning identifiers: + * lightning-URI (containing bolt11 or lnurl) + * bolt11 invoice + * lnurl + * lightning address + Bitcoin identifiers: + * bitcoin-URI + and sets the sending screen. + """ + text = text.strip() if not text: return - # first interpret as lightning invoice - bolt11_invoice = maybe_extract_bolt11_invoice(text) - if bolt11_invoice: - self.set_ln_invoice(bolt11_invoice) - else: + invoice_or_lnurl = maybe_extract_lightning_payment_identifier(text) + if lightning_address_to_url(text): + self.set_lnurl6(text) + elif invoice_or_lnurl: + if invoice_or_lnurl.startswith('lnurl'): + self.set_lnurl6(invoice_or_lnurl) + else: + self.set_bolt11(invoice_or_lnurl) + elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'): self.set_bip21(text) + else: + raise ValueError("Could not handle payment identifier.") # update fiat amount self.amount_e.textEdited.emit("") self.show_send_tab() def do_clear(self): + self.lnurl_max_sendable_sat = None + self.lnurl_min_sendable_sat = None + self.lnurl_callback_url = None + self._is_lnurl = False self.max_button.setChecked(False) self.payment_request = None self.payto_URI = None @@ -2301,6 +2374,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): for e in [self.payto_e, self.message_e, self.amount_e]: e.setText('') e.setFrozen(False) + for e in [self.send_button, self.save_button, self.payto_e, self.amount_e, self.fiat_send_e]: + e.setEnabled(True) self.update_status() run_hook('do_clear', self) @@ -3120,7 +3195,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): return # if the user scanned a bitcoin URI if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): - self.pay_to_URI(data) + self.handle_payment_identifier(data) return if data.lower().startswith('channel_backup:'): self.import_channel_backup(data) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 7afed1fe8..8a6bbb8a5 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -29,13 +29,14 @@ from decimal import Decimal from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING from PyQt5.QtGui import QFontMetrics, QFont +from PyQt5.QtCore import QTimer from electrum import bitcoin -from electrum.util import bfh, maybe_extract_bolt11_invoice, BITCOIN_BIP21_URI_SCHEME, parse_max_spend +from electrum.util import bfh, parse_max_spend from electrum.transaction import PartialTxOutput from electrum.bitcoin import opcodes, construct_script from electrum.logging import Logger -from electrum.lnaddr import LnDecodeException +from electrum.lnurl import LNURLError from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit @@ -84,7 +85,10 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.c = None - self.textChanged.connect(self.check_text) + self.timer = QTimer() + self.timer.setSingleShot(True) + self.textChanged.connect(self.start_timer) + self.timer.timeout.connect(self.check_text) self.outputs = [] # type: List[PartialTxOutput] self.errors = [] # type: List[PayToLineError] self.is_pr = False @@ -94,11 +98,23 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.lightning_invoice = None self.previous_payto = '' + def start_timer(self): + # we insert a timer between textChanged and check_text to not immediately + # resolve lightning addresses, but rather to wait until the address is typed out fully + delay_time_msec = 300 # about the average typing time in msec a person types a character + self.logger.info("timer fires") + self.timer.start(delay_time_msec) + def setFrozen(self, b): self.setReadOnly(b) self.setStyleSheet(frozen_style if b else normal_style) self.overlay_widget.setHidden(b) + def setTextNosignal(self, text: str): + self.blockSignals(True) + self.setText(text) + self.blockSignals(False) + def setGreen(self): self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) @@ -170,14 +186,13 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): if len(lines) == 1: data = lines[0] - # try bip21 URI - if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): - self.win.pay_to_URI(data) - return - # try LN invoice - bolt11_invoice = maybe_extract_bolt11_invoice(data) - if bolt11_invoice is not None: - self.win.set_ln_invoice(bolt11_invoice) + try: + self.win.handle_payment_identifier(data) + except LNURLError as e: + self.show_error(e) + except ValueError: + pass + else: return # try "address, amount" on-chain format try: diff --git a/electrum/lnurl.py b/electrum/lnurl.py new file mode 100644 index 000000000..e0760ff3b --- /dev/null +++ b/electrum/lnurl.py @@ -0,0 +1,75 @@ +"""Module for lnurl-related functionality.""" +# https://github.com/sipa/bech32/tree/master/ref/python +# https://github.com/lnbits/lnurl + +import asyncio +import json +from typing import Callable, Optional +import re + +import aiohttp.client_exceptions +from aiohttp import ClientResponse + +from electrum.segwit_addr import bech32_decode, Encoding, convertbits +from electrum.lnaddr import LnDecodeException + + +class LNURLError(Exception): + pass + + +def decode_lnurl(lnurl: str) -> str: + """Converts bech32 encoded lnurl to url.""" + decoded_bech32 = bech32_decode( + lnurl, ignore_long_length=True + ) + hrp = decoded_bech32.hrp + data = decoded_bech32.data + if decoded_bech32.encoding is None: + raise LnDecodeException("Bad bech32 checksum") + if decoded_bech32.encoding != Encoding.BECH32: + raise LnDecodeException("Bad bech32 encoding: must be using vanilla BECH32") + if not hrp.startswith("lnurl"): + raise LnDecodeException("Does not start with lnurl") + data = convertbits(data, 5, 8, False) + url = bytes(data).decode("utf-8") + return url + + +def request_lnurl(url: str, request_over_proxy: Callable) -> dict: + """Requests payment data from a lnurl.""" + try: + response = request_over_proxy("get", url, timeout=2) + 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 + # TODO: handling of specific client errors + response = json.loads(response) + if "metadata" in response: + response["metadata"] = json.loads(response["metadata"]) + status = response.get("status") + if status and status == "ERROR": + raise LNURLError(f"LNURL request encountered an error: {response['reason']}") + return response + + +def callback_lnurl(url: str, params: dict, request_over_proxy: Callable) -> dict: + """Requests an invoice from a lnurl supporting server.""" + try: + response = request_over_proxy("get", url, params=params) + except aiohttp.client_exceptions.ClientError as e: + raise LNURLError(f"Client error: {e}") from e + # TODO: handling of specific errors + response = json.loads(response) + status = response.get("status") + if status and status == "ERROR": + raise LNURLError(f"LNURL request encountered an error: {response['reason']}") + return response + + +def lightning_address_to_url(address: str) -> Optional[str]: + """Converts an email-type lightning address to a decoded lnurl.""" + if re.match(r"[^@]+@[^@]+\.[^@]+", address): + username, domain = address.split("@") + return f"https://{domain}/.well-known/lnurlp/{username}" diff --git a/electrum/tests/test_lnurl.py b/electrum/tests/test_lnurl.py new file mode 100644 index 000000000..48ef8a3a5 --- /dev/null +++ b/electrum/tests/test_lnurl.py @@ -0,0 +1,12 @@ +from unittest import TestCase + +from electrum import lnurl + + +class TestLnurl(TestCase): + def test_decode(self): + LNURL = ( + "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E5K7TELWY7NXENRXVMRGDTZXSENJCM98PJNWXQ96S9" + ) + url = lnurl.decode_lnurl(LNURL) + self.assertTrue("https://service.io/?q=3fc3645b439ce8e7", url) diff --git a/electrum/util.py b/electrum/util.py index 111911c96..bb4e69710 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1065,7 +1065,7 @@ def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], return str(urllib.parse.urlunparse(p)) -def maybe_extract_bolt11_invoice(data: str) -> Optional[str]: +def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: data = data.strip() # whitespaces data = data.lower() if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): @@ -1076,6 +1076,14 @@ def maybe_extract_bolt11_invoice(data: str) -> Optional[str]: return None +def is_uri(data: str) -> bool: + data = data.lower() + if (data.startswith(LIGHTNING_URI_SCHEME + ":") or + data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): + return True + return False + + # Python bug (http://bugs.python.org/issue1927) causes raw_input # to be redirected improperly between stdin/stderr on Unix systems #TODO: py3 diff --git a/run_electrum b/run_electrum index aa6f71456..96da77c66 100755 --- a/run_electrum +++ b/run_electrum @@ -98,7 +98,7 @@ from electrum.wallet_db import WalletDB from electrum.wallet import Wallet from electrum.storage import WalletStorage from electrum.util import print_msg, print_stderr, json_encode, json_decode, UserCancelled -from electrum.util import InvalidPassword, BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.util import InvalidPassword from electrum.commands import get_parser, known_commands, Commands, config_variables from electrum import daemon from electrum import keystore @@ -362,10 +362,7 @@ def main(): # check uri uri = config_options.get('url') - if uri and not ( - uri.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':') or - uri.lower().startswith(LIGHTNING_URI_SCHEME + ':') - ): + if uri and not util.is_uri(uri): print_stderr('unknown command:', uri) sys.exit(1)