From ed1567e841935acc4e07edc050fd9cfd9a71f0aa Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 28 Jun 2022 18:37:02 +0200 Subject: [PATCH] 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,