From fe2fbbd9b1b7598baf4b86c5696234944ca75a37 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 23 Nov 2021 14:42:43 +0100 Subject: [PATCH 1/6] 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) From 82e33984ad2ce7aaea5d43162f977360d3b225bd Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 25 Feb 2022 22:40:05 +0100 Subject: [PATCH 2/6] lnurl for kivy: I need this to buy beers --- electrum/gui/kivy/main_window.py | 14 +-- electrum/gui/kivy/uix/screens.py | 103 ++++++++++++++++++++--- electrum/gui/kivy/uix/ui_screens/send.kv | 6 +- 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index 0ccd8afd0..d10126257 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_lightning_payment_identifier, parse_max_spend) + maybe_extract_lightning_payment_identifier, parse_max_spend, is_uri) from electrum.util import EventListener, event_listener from electrum.invoices import PR_PAID, PR_FAILED, Invoice from electrum import blockchain @@ -235,10 +235,6 @@ class ElectrumWindow(App, Logger, EventListener): def set_URI(self, uri): self.send_screen.set_URI(uri) - @switch_to_send_screen - def set_ln_invoice(self, invoice): - self.send_screen.set_ln_invoice(invoice) - def on_new_intent(self, intent): data = str(intent.getDataString()) scheme = str(intent.getScheme()).lower() @@ -482,19 +478,15 @@ class ElectrumWindow(App, Logger, EventListener): def on_qr(self, data: str): from electrum.bitcoin import is_address data = data.strip() - if is_address(data): + if is_address(data): # TODO does this actually work? self.set_URI(data) return - if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): + if is_uri(data) or maybe_extract_lightning_payment_identifier(data): self.set_URI(data) return if data.lower().startswith('channel_backup:'): self.import_channel_backup(data) return - bolt11_invoice = maybe_extract_lightning_payment_identifier(data) - if bolt11_invoice is not None: - self.set_ln_invoice(bolt11_invoice) - return # try to decode transaction from electrum.transaction import tx_from_any try: diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 0b619f348..71efbe000 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -2,6 +2,7 @@ import asyncio from decimal import Decimal import threading from typing import TYPE_CHECKING, List, Optional, Dict, Any +from urllib.parse import urlparse from kivy.app import App from kivy.clock import Clock @@ -17,8 +18,9 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING, from electrum import bitcoin, constants from electrum.transaction import tx_from_any, PartialTxOutput from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, - InvoiceError, format_time, parse_max_spend) + InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME) from electrum.lnaddr import lndecode, LnInvoiceException +from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, lightning_address_to_url, LNURLError from electrum.logging import Logger from .dialogs.confirm_tx_dialog import ConfirmTxDialog @@ -167,17 +169,40 @@ class SendScreen(CScreen, Logger): CScreen.__init__(self, **kwargs) Logger.__init__(self) self.is_max = False + # note: most the fields get declared in send.kv, this way they are kivy Properties def set_URI(self, text: str): + """Takes + Lightning identifiers: + * lightning-URI (containing bolt11 or lnurl) + * bolt11 invoice + * lnurl + * lightning address + Bitcoin identifiers: + * bitcoin-URI + * bitcoin address TODO + and sets the sending screen. + + TODO maybe rename method... + """ if not self.app.wallet: return - # interpret as lighting URI - bolt11_invoice = maybe_extract_lightning_payment_identifier(text) - if bolt11_invoice: - self.set_ln_invoice(bolt11_invoice) - # interpret as BIP21 URI - else: + text = text.strip() + if not text: + return + 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(BITCOIN_BIP21_URI_SCHEME + ':'): self.set_bip21(text) + else: + self.app.show_error(f"Failed to parse text: {text[:10]}...") + return def set_bip21(self, text: str): try: @@ -194,7 +219,7 @@ class SendScreen(CScreen, Logger): self.payment_request = None self.is_lightning = False - def set_ln_invoice(self, invoice: str): + def set_bolt11(self, invoice: str): try: invoice = str(invoice).lower() lnaddr = lndecode(invoice) @@ -207,6 +232,30 @@ class SendScreen(CScreen, Logger): self.payment_request = None self.is_lightning = True + 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.app.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.address = "invoice from lnurl" + self.message = f"lnurl: {domain}: {m[1]}" + self.amount = self.app.format_amount_and_units(self.lnurl_min_sendable_sat) + #self.save_button.setDisabled(True) + #self.send_button.setText('Get Invoice') + self.is_lightning = True + def update(self): if self.app.wallet is None: return @@ -263,6 +312,10 @@ class SendScreen(CScreen, Logger): self.is_bip70 = False self.parsed_URI = None self.is_max = False + self.is_lnurl = False + self.lnurl_max_sendable_sat = None + self.lnurl_min_sendable_sat = None + self.lnurl_callback_url = None def set_request(self, pr: 'PaymentRequest'): self.address = pr.get_requestor() @@ -287,11 +340,7 @@ class SendScreen(CScreen, Logger): self.app.tx_dialog(tx) return # try to decode as URI/address - bolt11_invoice = maybe_extract_lightning_payment_identifier(data) - if bolt11_invoice is not None: - self.set_ln_invoice(bolt11_invoice) - else: - self.set_URI(data) + self.set_URI(data) def read_invoice(self): address = str(self.address) @@ -341,6 +390,34 @@ class SendScreen(CScreen, Logger): self.update() def do_pay(self): + if self.is_lnurl: + try: + amount = self.app.get_amount(self.amount) + except: + self.app.show_error(_('Invalid amount') + ':\n' + self.amount) + return + if not (self.lnurl_min_sendable_sat <= amount <= self.lnurl_max_sendable_sat): + self.app.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': amount * 1000}, + request_over_proxy=self.app.network.send_http_on_proxy, + ) + except LNURLError as e: + self.app.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 invoice = self.read_invoice() if not invoice: return diff --git a/electrum/gui/kivy/uix/ui_screens/send.kv b/electrum/gui/kivy/uix/ui_screens/send.kv index b19ef2043..99deaf5bf 100644 --- a/electrum/gui/kivy/uix/ui_screens/send.kv +++ b/electrum/gui/kivy/uix/ui_screens/send.kv @@ -69,6 +69,7 @@ message: '' is_bip70: False is_lightning: False + is_lnurl: False is_locked: self.is_lightning or self.is_bip70 BoxLayout padding: '12dp', '12dp', '12dp', '12dp' @@ -109,7 +110,7 @@ id: amount_e default_text: _('Amount') text: s.amount if s.amount else _('Amount') - disabled: root.is_bip70 or (root.is_lightning and s.amount) + disabled: root.is_bip70 or (root.is_lightning and s.amount and not root.is_lnurl) on_release: Clock.schedule_once(lambda dt: app.amount_dialog(s, not root.is_lightning)) CardSeparator: color: blue_bottom.foreground_color @@ -135,6 +136,7 @@ size_hint: 0.5, 1 on_release: s.do_save() icon: f'atlas://{KIVY_GUI_PATH}/theming/atlas/light/save' + disabled: root.is_lnurl IconButton: size_hint: 0.5, 1 on_release: s.do_clear() @@ -149,7 +151,7 @@ size_hint: 1, 1 on_release: Clock.schedule_once(lambda dt: app.scan_qr(on_complete=app.on_qr)) Button: - text: _('Pay') + text: _('Pay') if not root.is_lnurl else _('Get Invoice') size_hint: 1, 1 on_release: s.do_pay() Widget: From 649cad0122c877307948d322652955df9f9f1b90 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 30 May 2022 20:49:08 +0200 Subject: [PATCH 3/6] lnurl: clean-up --- electrum/contacts.py | 4 +- electrum/gui/kivy/main_window.py | 7 +- electrum/gui/kivy/uix/screens.py | 90 ++++++++++------------- electrum/gui/qt/main_window.py | 122 +++++++++++++++---------------- electrum/gui/qt/paytoedit.py | 97 ++++++++++++++---------- electrum/gui/qt/util.py | 4 + electrum/lnurl.py | 37 +++++++++- electrum/util.py | 2 +- 8 files changed, 204 insertions(+), 159 deletions(-) diff --git a/electrum/contacts.py b/electrum/contacts.py index 0fff1c3cc..3957e8359 100644 --- a/electrum/contacts.py +++ b/electrum/contacts.py @@ -21,6 +21,8 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import re +from typing import Optional, Tuple + import dns import threading from dns.exception import DNSException @@ -106,7 +108,7 @@ class Contacts(dict, Logger): t.daemon = True t.start() - def resolve_openalias(self, url): + def resolve_openalias(self, url: str) -> Optional[Tuple[str, str, bool]]: # support email-style addresses, per the OA standard url = url.replace('@', '.') try: diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index d10126257..ef9e65de9 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_lightning_payment_identifier, parse_max_spend, is_uri) + parse_max_spend) from electrum.util import EventListener, event_listener from electrum.invoices import PR_PAID, PR_FAILED, Invoice from electrum import blockchain @@ -475,13 +475,14 @@ class ElectrumWindow(App, Logger, EventListener): self.show_error("invoice error:" + pr.error) self.send_screen.do_clear() - def on_qr(self, data: str): + def on_qr(self, data: str): # TODO duplicate of send_screen.do_paste from electrum.bitcoin import is_address data = data.strip() - if is_address(data): # TODO does this actually work? + if is_address(data): self.set_URI(data) return if is_uri(data) or maybe_extract_lightning_payment_identifier(data): + # TODO what about "lightning address"? self.set_URI(data) return if data.lower().startswith('channel_backup:'): diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 71efbe000..b230863ff 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -20,7 +20,7 @@ from electrum.transaction import tx_from_any, PartialTxOutput from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME) from electrum.lnaddr import lndecode, LnInvoiceException -from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, lightning_address_to_url, LNURLError +from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, lightning_address_to_url, LNURLError, LNURL6Data from electrum.logging import Logger from .dialogs.confirm_tx_dialog import ConfirmTxDialog @@ -164,6 +164,7 @@ class SendScreen(CScreen, Logger): kvname = 'send' payment_request = None # type: Optional[PaymentRequest] parsed_URI = None + lnurl_data = None # type: Optional[LNURL6Data] def __init__(self, **kwargs): CScreen.__init__(self, **kwargs) @@ -180,7 +181,7 @@ class SendScreen(CScreen, Logger): * lightning address Bitcoin identifiers: * bitcoin-URI - * bitcoin address TODO + * bitcoin address and sets the sending screen. TODO maybe rename method... @@ -198,7 +199,7 @@ class SendScreen(CScreen, Logger): self.set_lnurl6(invoice_or_lnurl) else: self.set_bolt11(invoice_or_lnurl) - elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): + elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':') or bitcoin.is_address(text): self.set_bip21(text) else: self.app.show_error(f"Failed to parse text: {text[:10]}...") @@ -238,23 +239,14 @@ class SendScreen(CScreen, Logger): url = decode_lnurl(lnurl) domain = urlparse(url).netloc lnurl_data = request_lnurl(url, self.app.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.address = "invoice from lnurl" - self.message = f"lnurl: {domain}: {m[1]}" - self.amount = self.app.format_amount_and_units(self.lnurl_min_sendable_sat) - #self.save_button.setDisabled(True) - #self.send_button.setText('Get Invoice') + if not lnurl_data: + return + self.lnurl_data = lnurl_data + self.address = "invoice from lnurl" + self.message = f"lnurl: {domain}: {lnurl_data.metadata_plaintext}" + self.amount = self.app.format_amount_and_units(lnurl_data.min_sendable_sat) self.is_lightning = True + self.is_lnurl = True # `bool(self.lnurl_data)` should be equivalent, this is only here as it is a kivy Property def update(self): if self.app.wallet is None: @@ -312,10 +304,8 @@ class SendScreen(CScreen, Logger): self.is_bip70 = False self.parsed_URI = None self.is_max = False + self.lnurl_data = None self.is_lnurl = False - self.lnurl_max_sendable_sat = None - self.lnurl_min_sendable_sat = None - self.lnurl_callback_url = None def set_request(self, pr: 'PaymentRequest'): self.address = pr.get_requestor() @@ -325,7 +315,7 @@ class SendScreen(CScreen, Logger): self.locked = True self.payment_request = pr - def do_paste(self): + def do_paste(self): # TODO duplicate of app.on_qr data = self.app._clipboard.paste().strip() if not data: self.app.show_info(_("Clipboard is empty")) @@ -389,34 +379,34 @@ class SendScreen(CScreen, Logger): self.do_clear() self.update() + def _lnurl_get_invoice(self) -> None: + assert self.lnurl_data + try: + amount = self.app.get_amount(self.amount) + except: + self.app.show_error(_('Invalid amount') + ':\n' + self.amount) + return + if not (self.lnurl_data.min_sendable_sat <= amount <= self.lnurl_data.max_sendable_sat): + self.app.show_error(f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.') + return + try: + invoice_data = callback_lnurl( + self.lnurl_data.callback_url, + params={'amount': amount * 1000}, + request_over_proxy=self.app.network.send_http_on_proxy, + ) + except LNURLError as e: + self.app.show_error(f"LNURL request encountered error: {e}") + self.do_clear() + return + invoice = invoice_data.get('pr') + self.set_bolt11(invoice) + self.lnurl_data = None + self.is_lnurl = False + def do_pay(self): - if self.is_lnurl: - try: - amount = self.app.get_amount(self.amount) - except: - self.app.show_error(_('Invalid amount') + ':\n' + self.amount) - return - if not (self.lnurl_min_sendable_sat <= amount <= self.lnurl_max_sendable_sat): - self.app.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': amount * 1000}, - request_over_proxy=self.app.network.send_http_on_proxy, - ) - except LNURLError as e: - self.app.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 + if self.lnurl_data: + self._lnurl_get_invoice() return invoice = self.read_invoice() if not invoice: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 9dc937ace..fa7af9213 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -82,7 +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 electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, lightning_address_to_url, LNURLError, LNURL6Data from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit, SizedFreezableLineEdit @@ -207,6 +207,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): show_error_signal = pyqtSignal(str) payment_request: Optional[paymentrequest.PaymentRequest] + _lnurl_data: Optional[LNURL6Data] = None def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet): QMainWindow.__init__(self) @@ -914,8 +915,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): # this updates "synchronizing" progress self.update_status() # resolve aliases - # FIXME this is a blocking network call that has a timeout of 5 sec - self.payto_e.resolve() + # FIXME this might do blocking network calls that has a timeout of several seconds + self.payto_e.check_text() self.notify_transactions() def format_amount(self, amount_sat, is_diff=False, whitespaces=False) -> str: @@ -1577,7 +1578,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.save_button = EnterButton(_("Save"), self.do_save_invoice) 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) @@ -1912,30 +1912,34 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.invoice_list.update() self.pending_invoice = None + def _lnurl_get_invoice(self) -> None: + assert self._lnurl_data + amount = self.amount_e.get_amount() + if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat): + self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.') + return + try: + invoice_data = callback_lnurl( + self._lnurl_data.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.restore_original_text() + self._lnurl_data = None + 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 + if self._lnurl_data: + self._lnurl_get_invoice() return self.pending_invoice = self.read_invoice() if not self.pending_invoice: @@ -2203,7 +2207,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): for e in [self.payto_e, self.message_e]: e.setFrozen(True) self.lock_amount(True) - self.payto_e.setText(_("please wait...")) + self.payto_e.setTextNoCheck(_("please wait...")) return True def delete_invoices(self, keys): @@ -2226,7 +2230,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.payto_e.setGreen() else: self.payto_e.setExpired() - self.payto_e.setText(pr.get_requestor()) + self.payto_e.setTextNoCheck(pr.get_requestor()) self.amount_e.setAmount(pr.get_amount()) self.message_e.setText(pr.get_memo()) # signal to set fee @@ -2248,28 +2252,27 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): else: self.payment_request_error_signal.emit() - def set_lnurl6(self, lnurl: str): - url = lightning_address_to_url(lnurl) - if not url: + def set_lnurl6_bech32(self, lnurl: str): + try: url = decode_lnurl(lnurl) + except LnInvoiceException as e: + self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") + return + self.set_lnurl6_url(url) + + def set_lnurl6_url(self, url: str, *, lnurl_data: LNURL6Data = None): 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') + if lnurl_data is None: + lnurl_data = request_lnurl(url, self.network.send_http_on_proxy) + if not lnurl_data: + return + self._lnurl_data = lnurl_data + self.payto_e.setFrozen(True) + self.payto_e.setTextNoCheck(f"invoice from lnurl") + self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}") + self.amount_e.setAmount(lnurl_data.min_sendable_sat) + self.save_button.setDisabled(True) + self.send_button.setText(_('Get Invoice')) self.set_onchain(False) def set_bolt11(self, invoice: str): @@ -2288,7 +2291,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): else: description = '' self.payto_e.setFrozen(True) - self.payto_e.setTextNosignal(pubkey) + self.payto_e.setTextNoCheck(pubkey) self.payto_e.lightning_invoice = invoice if not self.message_e.text(): self.message_e.setText(description) @@ -2337,7 +2340,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): * lightning-URI (containing bolt11 or lnurl) * bolt11 invoice * lnurl - * lightning address Bitcoin identifiers: * bitcoin-URI and sets the sending screen. @@ -2346,11 +2348,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if not text: return 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: if invoice_or_lnurl.startswith('lnurl'): - self.set_lnurl6(invoice_or_lnurl) + self.set_lnurl6_bech32(invoice_or_lnurl) else: self.set_bolt11(invoice_or_lnurl) elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'): @@ -2362,19 +2362,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): 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._lnurl_data = None + self.send_button.restore_original_text() self.max_button.setChecked(False) self.payment_request = None self.payto_URI = None - self.payto_e.is_pr = False + self.payto_e.do_clear() self.set_onchain(False) - for e in [self.payto_e, self.message_e, self.amount_e]: + for e in [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]: + for e in [self.send_button, self.save_button, self.amount_e, self.fiat_send_e]: e.setEnabled(True) self.update_status() run_hook('do_clear', self) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 8a6bbb8a5..7291c2cd9 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -29,14 +29,13 @@ 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, parse_max_spend from electrum.transaction import PartialTxOutput from electrum.bitcoin import opcodes, construct_script from electrum.logging import Logger -from electrum.lnurl import LNURLError +from electrum.lnurl import LNURLError, lightning_address_to_url, request_lnurl, LNURL6Data from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit @@ -85,10 +84,6 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.c = None - 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 @@ -98,22 +93,22 @@ 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) + def setTextNoCheck(self, text: str): + """Sets the text, while also ensuring the new value will not be resolved/checked.""" self.setText(text) - self.blockSignals(False) + self.previous_payto = text + + def do_clear(self): + self.is_pr = False + self.is_alias = False + self.setText('') + self.setFrozen(False) + self.setEnabled(True) def setGreen(self): self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) @@ -174,6 +169,18 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): return address def check_text(self): + if self.hasFocus(): + return + if self.is_pr: + return + text = str(self.toPlainText()) + text = text.strip() # strip whitespaces + if text == self.previous_payto: + return + self.previous_payto = text + self._check_text() + + def _check_text(self): self.errors = [] if self.is_pr: return @@ -189,6 +196,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): try: self.win.handle_payment_identifier(data) except LNURLError as e: + self.logger.exception("") self.show_error(e) except ValueError: pass @@ -210,6 +218,17 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.win.set_onchain(True) self.win.lock_amount(False) return + # try lightning address lnurl-16 (note: names can collide with openalias, so order matters) + lnurl_data = self._resolve_lightning_address_lnurl16(data) + if lnurl_data: + url = lightning_address_to_url(data) + self.win.set_lnurl6_url(url, lnurl_data=lnurl_data) + return + # try openalias + oa_data = self._resolve_openalias(data) + if oa_data: + self._set_openalias(key=data, data=oa_data) + return else: # there are multiple lines self._parse_as_multiline(lines, raise_errors=False) @@ -271,7 +290,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): return len(self.lines()) > 1 def paytomany(self): - self.setText("\n\n\n") + self.setTextNoCheck("\n\n\n") self.update_size() def update_size(self): @@ -291,38 +310,28 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): # The scrollbar visibility can have changed so we update the overlay position here self._updateOverlayPos() - def resolve(self): - self.is_alias = False - if self.hasFocus(): - return - if self.is_multiline(): # only supports single line entries atm - return - if self.is_pr: - return - key = str(self.toPlainText()) + def _resolve_openalias(self, text: str) -> Optional[dict]: + key = text key = key.strip() # strip whitespaces - if key == self.previous_payto: - return - self.previous_payto = key - if not (('.' in key) and (not '<' in key) and (not ' ' in key)): - return + if not (('.' in key) and ('<' not in key) and (' ' not in key)): + return None parts = key.split(sep=',') # assuming single line if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): - return + return None try: data = self.win.contacts.resolve(key) except Exception as e: self.logger.info(f'error resolving address/alias: {repr(e)}') - return - if not data: - return - self.is_alias = True + return None + return data or None + def _set_openalias(self, *, key: str, data: dict) -> bool: + self.is_alias = True + key = key.strip() # strip whitespaces address = data.get('address') name = data.get('name') new_url = key + ' <' + address + '>' - self.setText(new_url) - self.previous_payto = new_url + self.setTextNoCheck(new_url) #if self.win.config.get('openalias_autoadd') == 'checked': self.win.contacts[key] = ('openalias', name) @@ -337,3 +346,15 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.setExpired() else: self.validated = None + return True + + def _resolve_lightning_address_lnurl16(self, text: str) -> Optional[LNURL6Data]: + url = lightning_address_to_url(text) + if not url: + return None + try: + lnurl_data = request_lnurl(url, self.win.network.send_http_on_proxy) + return lnurl_data + except LNURLError as e: + self.logger.info(f"failed to resolve {text} as lnurl16 lightning address. got exc: {e!r}") + return None diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 5c6028d89..173cadaab 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -75,11 +75,15 @@ class EnterButton(QPushButton): QPushButton.__init__(self, text) self.func = func self.clicked.connect(func) + self._orig_text = text def keyPressEvent(self, e): if e.key() in [Qt.Key_Return, Qt.Key_Enter]: self.func() + def restore_original_text(self): + self.setText(self._orig_text) + class ThreadedButton(QPushButton): def __init__(self, text, task, on_success=None, on_error=None): diff --git a/electrum/lnurl.py b/electrum/lnurl.py index e0760ff3b..84661b801 100644 --- a/electrum/lnurl.py +++ b/electrum/lnurl.py @@ -4,7 +4,7 @@ import asyncio import json -from typing import Callable, Optional +from typing import Callable, Optional, NamedTuple, Any import re import aiohttp.client_exceptions @@ -36,10 +36,18 @@ def decode_lnurl(lnurl: str) -> str: return url -def request_lnurl(url: str, request_over_proxy: Callable) -> dict: +class LNURL6Data(NamedTuple): + callback_url: str + max_sendable_sat: int + min_sendable_sat: int + metadata_plaintext: str + #tag: str = "payRequest" + + +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) + response = request_over_proxy("get", url, timeout=10) except asyncio.TimeoutError as e: raise LNURLError("Server did not reply in time.") from e except aiohttp.client_exceptions.ClientError as e: @@ -54,6 +62,25 @@ def request_lnurl(url: str, request_over_proxy: Callable) -> dict: return response +def request_lnurl(url: str, request_over_proxy: Callable) -> Optional[LNURL6Data]: + lnurl_dict = _request_lnurl(url, request_over_proxy) + tag = lnurl_dict.get('tag') + if tag != 'payRequest': # only LNURL6 is handled atm + return None + metadata = lnurl_dict.get('metadata') + metadata_plaintext = "" + for m in metadata: + if m[0] == 'text/plain': + metadata_plaintext = str(m[1]) + data = LNURL6Data( + callback_url=lnurl_dict['callback'], + max_sendable_sat=int(lnurl_dict['maxSendable']) // 1000, + min_sendable_sat=int(lnurl_dict['minSendable']) // 1000, + metadata_plaintext=metadata_plaintext, + ) + return data + + def callback_lnurl(url: str, params: dict, request_over_proxy: Callable) -> dict: """Requests an invoice from a lnurl supporting server.""" try: @@ -69,7 +96,9 @@ def callback_lnurl(url: str, params: dict, request_over_proxy: Callable) -> dict def lightning_address_to_url(address: str) -> Optional[str]: - """Converts an email-type lightning address to a decoded lnurl.""" + """Converts an email-type lightning address to a decoded lnurl. + see https://github.com/fiatjaf/lnurl-rfc/blob/luds/16.md + """ if re.match(r"[^@]+@[^@]+\.[^@]+", address): username, domain = address.split("@") return f"https://{domain}/.well-known/lnurlp/{username}" diff --git a/electrum/util.py b/electrum/util.py index bb4e69710..94629d055 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1079,7 +1079,7 @@ def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: def is_uri(data: str) -> bool: data = data.lower() if (data.startswith(LIGHTNING_URI_SCHEME + ":") or - data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): + data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): return True return False From a2d66ac90cb3a08e67eeb9c8e5480ee80b470118 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 30 May 2022 21:07:58 +0200 Subject: [PATCH 4/6] kivy: rm code dupe between app.on_qr and send_screen.do_paste --- electrum/gui/kivy/main_window.py | 20 ++++++++------------ electrum/gui/kivy/uix/screens.py | 14 ++------------ 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index ef9e65de9..7da461150 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -475,20 +475,16 @@ class ElectrumWindow(App, Logger, EventListener): self.show_error("invoice error:" + pr.error) self.send_screen.do_clear() - def on_qr(self, data: str): # TODO duplicate of send_screen.do_paste - from electrum.bitcoin import is_address + def on_qr(self, data: str): + self.on_data_input(data) + + def on_data_input(self, data: str) -> None: + """on_qr / on_paste shared logic""" data = data.strip() - if is_address(data): - self.set_URI(data) - return - if is_uri(data) or maybe_extract_lightning_payment_identifier(data): - # TODO what about "lightning address"? - self.set_URI(data) - return if data.lower().startswith('channel_backup:'): self.import_channel_backup(data) return - # try to decode transaction + # try to decode as transaction from electrum.transaction import tx_from_any try: tx = tx_from_any(data) @@ -497,8 +493,8 @@ class ElectrumWindow(App, Logger, EventListener): if tx: self.tx_dialog(tx) return - # show error - self.show_error("Unable to decode QR data") + # try to decode as URI/address + self.set_URI(data) def update_tab(self, name): s = getattr(self, name + '_screen', None) diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index b230863ff..3a82a474e 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -315,22 +315,12 @@ class SendScreen(CScreen, Logger): self.locked = True self.payment_request = pr - def do_paste(self): # TODO duplicate of app.on_qr + def do_paste(self): data = self.app._clipboard.paste().strip() if not data: self.app.show_info(_("Clipboard is empty")) return - # try to decode as transaction - try: - tx = tx_from_any(data) - tx.deserialize() - except: - tx = None - if tx: - self.app.tx_dialog(tx) - return - # try to decode as URI/address - self.set_URI(data) + self.app.on_data_input(data) def read_invoice(self): address = str(self.address) From df974c2384600f94d21509dfdd8ba6b35da66eca Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 31 May 2022 16:37:38 +0200 Subject: [PATCH 5/6] qt paytoedit: evaluate text on textChanged(), but no network requests - add param to _check_text to toggle if network requests are allowed ("full check") - every 0.5 sec, if the textedit has no focus, do full check - on textChanged() - detect if user copy-pasted by comparing current text against clipboard contents, if so, do full check - otherwise, do partial check - on clicking ButtonsWidget btns (scan qr, paste, read file), do full check --- electrum/gui/qt/main_window.py | 24 ++++++------- electrum/gui/qt/paytoedit.py | 62 ++++++++++++++++++++-------------- electrum/gui/qt/qrtextedit.py | 13 +++++-- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index fa7af9213..01d6720ec 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -916,7 +916,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.update_status() # resolve aliases # FIXME this might do blocking network calls that has a timeout of several seconds - self.payto_e.check_text() + self.payto_e.on_timer_check_text() self.notify_transactions() def format_amount(self, amount_sat, is_diff=False, whitespaces=False) -> str: @@ -1527,7 +1527,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): from .paytoedit import PayToEdit self.amount_e = BTCAmountEdit(self.get_decimal_point) self.payto_e = PayToEdit(self) - self.payto_e.addPasteButton() msg = (_("Recipient of the funds.") + "\n\n" + _("You may enter a Bitcoin address, a label from your list of contacts " "(a list of completions will be proposed), " @@ -2252,17 +2251,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): else: self.payment_request_error_signal.emit() - def set_lnurl6_bech32(self, lnurl: str): + def set_lnurl6_bech32(self, lnurl: str, *, can_use_network: bool = True): try: url = decode_lnurl(lnurl) except LnInvoiceException as e: self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") return - self.set_lnurl6_url(url) + self.set_lnurl6_url(url, can_use_network=can_use_network) - def set_lnurl6_url(self, url: str, *, lnurl_data: LNURL6Data = None): + def set_lnurl6_url(self, url: str, *, lnurl_data: LNURL6Data = None, can_use_network: bool = True): domain = urlparse(url).netloc - if lnurl_data is None: + if lnurl_data is None and can_use_network: lnurl_data = request_lnurl(url, self.network.send_http_on_proxy) if not lnurl_data: return @@ -2303,9 +2302,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self._is_onchain = b self.max_button.setEnabled(b) - def set_bip21(self, text: str): + def set_bip21(self, text: str, *, can_use_network: bool = True): + on_bip70_pr = self.on_pr if can_use_network else None try: - out = util.parse_URI(text, self.on_pr) + out = util.parse_URI(text, on_bip70_pr) except InvalidBitcoinURI as e: self.show_error(_("Error parsing URI") + f":\n{e}") return @@ -2313,7 +2313,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): r = out.get('r') sig = out.get('sig') name = out.get('name') - if r or (name and sig): + if (r or (name and sig)) and can_use_network: self.prepare_for_payment_request() return address = out.get('address') @@ -2322,7 +2322,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): message = out.get('message') lightning = out.get('lightning') if lightning: - self.handle_payment_identifier(lightning) + self.handle_payment_identifier(lightning, can_use_network=can_use_network) return # use label as description (not BIP21 compliant) if label and not message: @@ -2334,7 +2334,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if amount: self.amount_e.setAmount(amount) - def handle_payment_identifier(self, text: str): + def handle_payment_identifier(self, text: str, *, can_use_network: bool = True): """Takes Lightning identifiers: * lightning-URI (containing bolt11 or lnurl) @@ -2350,7 +2350,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): invoice_or_lnurl = maybe_extract_lightning_payment_identifier(text) if invoice_or_lnurl: if invoice_or_lnurl.startswith('lnurl'): - self.set_lnurl6_bech32(invoice_or_lnurl) + self.set_lnurl6_bech32(invoice_or_lnurl, can_use_network=can_use_network) else: self.set_bolt11(invoice_or_lnurl) elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'): diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 7291c2cd9..55c43a476 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -63,9 +63,10 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def __init__(self, win: 'ElectrumWindow'): CompletionTextEdit.__init__(self) - ScanQRTextEdit.__init__(self, config=win.config) + ScanQRTextEdit.__init__(self, config=win.config, setText=self._on_input_btn) Logger.__init__(self) self.win = win + self.app = win.app self.amount_edit = win.amount_e self.setFont(QFont(MONOSPACE_FONT)) document = self.document() @@ -84,6 +85,8 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.c = None + self.addPasteButton(setText=self._on_input_btn) + self.textChanged.connect(self._on_text_changed) self.outputs = [] # type: List[PartialTxOutput] self.errors = [] # type: List[PayToLineError] self.is_pr = False @@ -100,8 +103,8 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def setTextNoCheck(self, text: str): """Sets the text, while also ensuring the new value will not be resolved/checked.""" - self.setText(text) self.previous_payto = text + self.setText(text) def do_clear(self): self.is_pr = False @@ -168,19 +171,27 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): assert bitcoin.is_address(address) return address - def check_text(self): + def _on_input_btn(self, text: str): + self.setText(text) + self._check_text(full_check=True) + + def _on_text_changed(self): + if self.app.clipboard().text() == self.toPlainText(): + # user likely pasted from clipboard + self._check_text(full_check=True) + else: + self._check_text(full_check=False) + + def on_timer_check_text(self): if self.hasFocus(): return - if self.is_pr: - return - text = str(self.toPlainText()) - text = text.strip() # strip whitespaces - if text == self.previous_payto: - return - self.previous_payto = text - self._check_text() + self._check_text(full_check=True) - def _check_text(self): + def _check_text(self, *, full_check: bool): + if self.previous_payto == str(self.toPlainText()).strip(): + return + if full_check: + self.previous_payto = str(self.toPlainText()).strip() self.errors = [] if self.is_pr: return @@ -194,10 +205,10 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): if len(lines) == 1: data = lines[0] try: - self.win.handle_payment_identifier(data) + self.win.handle_payment_identifier(data, can_use_network=full_check) except LNURLError as e: self.logger.exception("") - self.show_error(e) + self.win.show_error(e) except ValueError: pass else: @@ -218,17 +229,18 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.win.set_onchain(True) self.win.lock_amount(False) return - # try lightning address lnurl-16 (note: names can collide with openalias, so order matters) - lnurl_data = self._resolve_lightning_address_lnurl16(data) - if lnurl_data: - url = lightning_address_to_url(data) - self.win.set_lnurl6_url(url, lnurl_data=lnurl_data) - return - # try openalias - oa_data = self._resolve_openalias(data) - if oa_data: - self._set_openalias(key=data, data=oa_data) - return + if full_check: # network requests + # try lightning address lnurl-16 (note: names can collide with openalias, so order matters) + lnurl_data = self._resolve_lightning_address_lnurl16(data) + if lnurl_data: + url = lightning_address_to_url(data) + self.win.set_lnurl6_url(url, lnurl_data=lnurl_data) + return + # try openalias + oa_data = self._resolve_openalias(data) + if oa_data: + self._set_openalias(key=data, data=oa_data) + return else: # there are multiple lines self._parse_as_multiline(lines, raise_errors=False) diff --git a/electrum/gui/qt/qrtextedit.py b/electrum/gui/qt/qrtextedit.py index 3cd404658..22d89913e 100644 --- a/electrum/gui/qt/qrtextedit.py +++ b/electrum/gui/qt/qrtextedit.py @@ -1,3 +1,5 @@ +from typing import Callable + from electrum.i18n import _ from electrum.plugin import run_hook from electrum.simple_config import SimpleConfig @@ -21,11 +23,16 @@ class ShowQRTextEdit(ButtonsTextEdit): class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin): - def __init__(self, text="", allow_multi: bool = False, *, config: SimpleConfig): + def __init__( + self, text="", allow_multi: bool = False, + *, + config: SimpleConfig, + setText: Callable[[str], None] = None, + ): ButtonsTextEdit.__init__(self, text) self.setReadOnly(False) - self.add_file_input_button(config=config, show_error=self.show_error) - self.add_qr_input_button(config=config, show_error=self.show_error, allow_multi=allow_multi) + self.add_file_input_button(config=config, show_error=self.show_error, setText=setText) + self.add_qr_input_button(config=config, show_error=self.show_error, allow_multi=allow_multi, setText=setText) run_hook('scan_text_edit', self) def contextMenuEvent(self, e): From ed1567e841935acc4e07edc050fd9cfd9a71f0aa Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 28 Jun 2022 18:37:02 +0200 Subject: [PATCH 6/6] lnurl: make requests async, don't block Qt GUI, rm LUD-16 support - in lnurl.py, make request methods async - in Qt GUI, lnurl network requests no longer block the GUI thread - but they still do in the kivy GUI - "lightning address" (LUD-16) support is removed for now as the email addresses are indistinguishable from openalias email addresses (both protocols should have added and enforced a prefix, or similar, to remove this kind of ambiguity -- now we would need to make a network request just to identify what kind of ID we were given) --- electrum/gui/kivy/uix/screens.py | 22 ++--- electrum/gui/qt/completion_text_edit.py | 2 + electrum/gui/qt/main_window.py | 104 +++++++++++++++--------- electrum/gui/qt/paytoedit.py | 29 ++----- electrum/lnurl.py | 18 ++-- electrum/network.py | 2 +- 6 files changed, 95 insertions(+), 82 deletions(-) diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 3a82a474e..0aec77690 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -20,8 +20,9 @@ from electrum.transaction import tx_from_any, PartialTxOutput from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME) from electrum.lnaddr import lndecode, LnInvoiceException -from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, lightning_address_to_url, LNURLError, LNURL6Data +from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from electrum.logging import Logger +from electrum.network import Network from .dialogs.confirm_tx_dialog import ConfirmTxDialog @@ -178,7 +179,6 @@ class SendScreen(CScreen, Logger): * lightning-URI (containing bolt11 or lnurl) * bolt11 invoice * lnurl - * lightning address Bitcoin identifiers: * bitcoin-URI * bitcoin address @@ -191,10 +191,7 @@ class SendScreen(CScreen, Logger): text = text.strip() if not text: return - 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 := maybe_extract_lightning_payment_identifier(text): if invoice_or_lnurl.startswith('lnurl'): self.set_lnurl6(invoice_or_lnurl) else: @@ -234,11 +231,10 @@ class SendScreen(CScreen, Logger): self.is_lightning = True def set_lnurl6(self, lnurl: str): - url = lightning_address_to_url(lnurl) - if not url: - url = decode_lnurl(lnurl) + url = decode_lnurl(lnurl) domain = urlparse(url).netloc - lnurl_data = request_lnurl(url, self.app.network.send_http_on_proxy) + # FIXME network request blocking GUI thread: + lnurl_data = Network.run_from_another_thread(request_lnurl(url)) if not lnurl_data: return self.lnurl_data = lnurl_data @@ -380,11 +376,11 @@ class SendScreen(CScreen, Logger): self.app.show_error(f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.') return try: - invoice_data = callback_lnurl( + # FIXME network request blocking GUI thread: + invoice_data = Network.run_from_another_thread(callback_lnurl( self.lnurl_data.callback_url, params={'amount': amount * 1000}, - request_over_proxy=self.app.network.send_http_on_proxy, - ) + )) except LNURLError as e: self.app.show_error(f"LNURL request encountered error: {e}") self.do_clear() diff --git a/electrum/gui/qt/completion_text_edit.py b/electrum/gui/qt/completion_text_edit.py index ded06d15c..9c036ec69 100644 --- a/electrum/gui/qt/completion_text_edit.py +++ b/electrum/gui/qt/completion_text_edit.py @@ -81,6 +81,8 @@ class CompletionTextEdit(ButtonsTextEdit): return QPlainTextEdit.keyPressEvent(self, e) + if self.isReadOnly(): # if field became read-only *after* keyPress, exit now + return ctrlOrShift = bool(e.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)) if self.completer is None or (ctrlOrShift and not e.text()): diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 01d6720ec..2068b1476 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -58,7 +58,7 @@ from electrum import (keystore, ecc, constants, util, bitcoin, commands, from electrum.bitcoin import COIN, is_address from electrum.plugin import run_hook, BasePlugin from electrum.i18n import _ -from electrum.util import (format_time, +from electrum.util import (format_time, get_asyncio_loop, UserCancelled, profiler, bh2u, bfh, InvalidPassword, UserFacingException, @@ -82,7 +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, LNURL6Data +from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit, SizedFreezableLineEdit @@ -201,6 +201,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): payment_request_ok_signal = pyqtSignal() payment_request_error_signal = pyqtSignal() + lnurl6_round1_signal = pyqtSignal(object, object) + lnurl6_round2_signal = pyqtSignal(object) + clear_send_tab_signal = pyqtSignal() #ln_payment_attempt_signal = pyqtSignal(str) computing_privkeys_signal = pyqtSignal() show_privkeys_signal = pyqtSignal() @@ -312,6 +315,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.payment_request_ok_signal.connect(self.payment_request_ok) self.payment_request_error_signal.connect(self.payment_request_error) + self.lnurl6_round1_signal.connect(self.on_lnurl6_round1) + self.lnurl6_round2_signal.connect(self.on_lnurl6_round2) + self.clear_send_tab_signal.connect(self.do_clear) + self.show_error_signal.connect(self.show_error) self.history_list.setFocus(True) @@ -1917,22 +1924,30 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat): self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.') return - try: - invoice_data = callback_lnurl( - self._lnurl_data.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) + + async def f(): + try: + invoice_data = await callback_lnurl( + self._lnurl_data.callback_url, + params={'amount': self.amount_e.get_amount() * 1000}, + ) + except LNURLError as e: + self.show_error_signal.emit(f"LNURL request encountered error: {e}") + self.clear_send_tab_signal.emit() + return + invoice = invoice_data.get('pr') + self.lnurl6_round2_signal.emit(invoice) + + asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable + self.prepare_for_send_tab_network_lookup() + + def on_lnurl6_round2(self, bolt11_invoice: str): + self.set_bolt11(bolt11_invoice) self.payto_e.setFrozen(True) - self.amount_e.setDisabled(True) - self.fiat_send_e.setDisabled(True) - self.save_button.setEnabled(True) + self.amount_e.setEnabled(False) + self.fiat_send_e.setEnabled(False) + for btn in [self.send_button, self.clear_button, self.save_button]: + btn.setEnabled(True) self.send_button.restore_original_text() self._lnurl_data = None @@ -2200,14 +2215,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.amount_e.setFrozen(b) self.max_button.setEnabled(not b) - def prepare_for_payment_request(self): + def prepare_for_send_tab_network_lookup(self): self.show_send_tab() - self.payto_e.is_pr = True + self.payto_e.disable_checks = True for e in [self.payto_e, self.message_e]: e.setFrozen(True) self.lock_amount(True) + for btn in [self.save_button, self.send_button, self.clear_button]: + btn.setEnabled(False) self.payto_e.setTextNoCheck(_("please wait...")) - return True def delete_invoices(self, keys): for key in keys: @@ -2224,7 +2240,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.do_clear() self.payment_request = None return - self.payto_e.is_pr = True + self.payto_e.disable_checks = True if not pr.has_expired(): self.payto_e.setGreen() else: @@ -2232,6 +2248,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.payto_e.setTextNoCheck(pr.get_requestor()) self.amount_e.setAmount(pr.get_amount()) self.message_e.setText(pr.get_memo()) + self.set_onchain(True) + self.max_button.setEnabled(False) + for btn in [self.send_button, self.clear_button]: + btn.setEnabled(True) # signal to set fee self.amount_e.textEdited.emit("") @@ -2244,34 +2264,43 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.do_clear() def on_pr(self, request: 'paymentrequest.PaymentRequest'): - self.set_onchain(True) self.payment_request = request if self.payment_request.verify(self.contacts): self.payment_request_ok_signal.emit() else: self.payment_request_error_signal.emit() - def set_lnurl6_bech32(self, lnurl: str, *, can_use_network: bool = True): + def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True): try: url = decode_lnurl(lnurl) except LnInvoiceException as e: self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") return - self.set_lnurl6_url(url, can_use_network=can_use_network) - - def set_lnurl6_url(self, url: str, *, lnurl_data: LNURL6Data = None, can_use_network: bool = True): - domain = urlparse(url).netloc - if lnurl_data is None and can_use_network: - lnurl_data = request_lnurl(url, self.network.send_http_on_proxy) - if not lnurl_data: + if not can_use_network: return + + async def f(): + try: + lnurl_data = await request_lnurl(url) + except LNURLError as e: + self.show_error_signal.emit(f"LNURL request encountered error: {e}") + self.clear_send_tab_signal.emit() + return + self.lnurl6_round1_signal.emit(lnurl_data, url) + + asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable + self.prepare_for_send_tab_network_lookup() + + def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str): self._lnurl_data = lnurl_data - self.payto_e.setFrozen(True) + domain = urlparse(url).netloc self.payto_e.setTextNoCheck(f"invoice from lnurl") self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}") self.amount_e.setAmount(lnurl_data.min_sendable_sat) - self.save_button.setDisabled(True) + self.amount_e.setFrozen(False) self.send_button.setText(_('Get Invoice')) + for btn in [self.send_button, self.clear_button]: + btn.setEnabled(True) self.set_onchain(False) def set_bolt11(self, invoice: str): @@ -2314,7 +2343,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): sig = out.get('sig') name = out.get('name') if (r or (name and sig)) and can_use_network: - self.prepare_for_payment_request() + self.prepare_for_send_tab_network_lookup() return address = out.get('address') amount = out.get('amount') @@ -2347,14 +2376,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): text = text.strip() if not text: return - invoice_or_lnurl = maybe_extract_lightning_payment_identifier(text) - if invoice_or_lnurl: + if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): if invoice_or_lnurl.startswith('lnurl'): - self.set_lnurl6_bech32(invoice_or_lnurl, can_use_network=can_use_network) + self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network) else: self.set_bolt11(invoice_or_lnurl) elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'): - self.set_bip21(text) + self.set_bip21(text, can_use_network=can_use_network) else: raise ValueError("Could not handle payment identifier.") # update fiat amount @@ -2372,7 +2400,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): for e in [self.message_e, self.amount_e]: e.setText('') e.setFrozen(False) - for e in [self.send_button, self.save_button, self.amount_e, self.fiat_send_e]: + for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: e.setEnabled(True) self.update_status() run_hook('do_clear', self) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 55c43a476..876c96c0e 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -35,7 +35,7 @@ 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.lnurl import LNURLError, lightning_address_to_url, request_lnurl, LNURL6Data +from electrum.lnurl import LNURLError from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit @@ -89,7 +89,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.textChanged.connect(self._on_text_changed) self.outputs = [] # type: List[PartialTxOutput] self.errors = [] # type: List[PayToLineError] - self.is_pr = False + self.disable_checks = False self.is_alias = False self.update_size() self.payto_scriptpubkey = None # type: Optional[bytes] @@ -107,7 +107,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.setText(text) def do_clear(self): - self.is_pr = False + self.disable_checks = False self.is_alias = False self.setText('') self.setFrozen(False) @@ -193,7 +193,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): if full_check: self.previous_payto = str(self.toPlainText()).strip() self.errors = [] - if self.is_pr: + if self.disable_checks: return # filter out empty lines lines = [i for i in self.lines() if i] @@ -229,13 +229,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.win.set_onchain(True) self.win.lock_amount(False) return - if full_check: # network requests - # try lightning address lnurl-16 (note: names can collide with openalias, so order matters) - lnurl_data = self._resolve_lightning_address_lnurl16(data) - if lnurl_data: - url = lightning_address_to_url(data) - self.win.set_lnurl6_url(url, lnurl_data=lnurl_data) - return + if full_check: # network requests # FIXME blocking GUI thread # try openalias oa_data = self._resolve_openalias(data) if oa_data: @@ -339,6 +333,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def _set_openalias(self, *, key: str, data: dict) -> bool: self.is_alias = True + self.setFrozen(True) key = key.strip() # strip whitespaces address = data.get('address') name = data.get('name') @@ -349,7 +344,6 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.win.contacts[key] = ('openalias', name) self.win.contact_list.update() - self.setFrozen(True) if data.get('type') == 'openalias': self.validated = data.get('validated') if self.validated: @@ -359,14 +353,3 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): else: self.validated = None return True - - def _resolve_lightning_address_lnurl16(self, text: str) -> Optional[LNURL6Data]: - url = lightning_address_to_url(text) - if not url: - return None - try: - lnurl_data = request_lnurl(url, self.win.network.send_http_on_proxy) - return lnurl_data - except LNURLError as e: - self.logger.info(f"failed to resolve {text} as lnurl16 lightning address. got exc: {e!r}") - return None diff --git a/electrum/lnurl.py b/electrum/lnurl.py index 84661b801..28e9ab93b 100644 --- a/electrum/lnurl.py +++ b/electrum/lnurl.py @@ -4,7 +4,7 @@ import asyncio import json -from typing import Callable, Optional, NamedTuple, Any +from typing import Callable, Optional, NamedTuple, Any, TYPE_CHECKING import re import aiohttp.client_exceptions @@ -12,6 +12,10 @@ from aiohttp import ClientResponse from electrum.segwit_addr import bech32_decode, Encoding, convertbits from electrum.lnaddr import LnDecodeException +from electrum.network import Network + +if TYPE_CHECKING: + from collections.abc import Coroutine class LNURLError(Exception): @@ -44,10 +48,10 @@ class LNURL6Data(NamedTuple): #tag: str = "payRequest" -def _request_lnurl(url: str, request_over_proxy: Callable) -> dict: +async def _request_lnurl(url: str) -> dict: """Requests payment data from a lnurl.""" try: - response = request_over_proxy("get", url, timeout=10) + response = await Network.async_send_http_on_proxy("get", url, timeout=10) except asyncio.TimeoutError as e: raise LNURLError("Server did not reply in time.") from e except aiohttp.client_exceptions.ClientError as e: @@ -62,8 +66,8 @@ def _request_lnurl(url: str, request_over_proxy: Callable) -> dict: return response -def request_lnurl(url: str, request_over_proxy: Callable) -> Optional[LNURL6Data]: - lnurl_dict = _request_lnurl(url, request_over_proxy) +async def request_lnurl(url: str) -> Optional[LNURL6Data]: + lnurl_dict = await _request_lnurl(url) tag = lnurl_dict.get('tag') if tag != 'payRequest': # only LNURL6 is handled atm return None @@ -81,10 +85,10 @@ def request_lnurl(url: str, request_over_proxy: Callable) -> Optional[LNURL6Data return data -def callback_lnurl(url: str, params: dict, request_over_proxy: Callable) -> dict: +async def callback_lnurl(url: str, params: dict) -> dict: """Requests an invoice from a lnurl supporting server.""" try: - response = request_over_proxy("get", url, params=params) + response = await Network.async_send_http_on_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 diff --git a/electrum/network.py b/electrum/network.py index b95c910d1..30eaa21a6 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -1297,7 +1297,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): @classmethod async def async_send_http_on_proxy( cls, method: str, url: str, *, - params: str = None, + params: dict = None, body: bytes = None, json: dict = None, headers=None,