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 8e1fbfd42..239052914 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) + parse_max_spend) 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() @@ -481,22 +477,15 @@ class ElectrumWindow(App, Logger, EventListener): self.send_screen.do_clear() def on_qr(self, data: str): - from electrum.bitcoin import is_address + 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 data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): - self.set_URI(data) - return if data.lower().startswith('channel_backup:'): self.import_channel_backup(data) return - bolt11_invoice = maybe_extract_bolt11_invoice(data) - if bolt11_invoice is not None: - self.set_ln_invoice(bolt11_invoice) - return - # try to decode transaction + # try to decode as transaction from electrum.transaction import tx_from_any try: tx = tx_from_any(data) @@ -505,8 +494,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 419d5a632..0aec77690 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 @@ -16,10 +17,12 @@ 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, - InvoiceError, format_time, parse_max_spend) +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, LNURLError, LNURL6Data from electrum.logging import Logger +from electrum.network import Network from .dialogs.confirm_tx_dialog import ConfirmTxDialog @@ -162,22 +165,42 @@ 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) 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 + Bitcoin identifiers: + * bitcoin-URI + * bitcoin address + and sets the sending screen. + + TODO maybe rename method... + """ if not self.app.wallet: return - # interpret as lighting URI - bolt11_invoice = maybe_extract_bolt11_invoice(text) - if bolt11_invoice: - self.set_ln_invoice(bolt11_invoice) - # interpret as BIP21 URI - else: + text = text.strip() + if not text: + return + if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): + 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 + ':') or bitcoin.is_address(text): 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 +217,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 +230,20 @@ class SendScreen(CScreen, Logger): self.payment_request = None self.is_lightning = True + def set_lnurl6(self, lnurl: str): + url = decode_lnurl(lnurl) + domain = urlparse(url).netloc + # 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 + 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: return @@ -263,6 +300,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 def set_request(self, pr: 'PaymentRequest'): self.address = pr.get_requestor() @@ -277,21 +316,7 @@ class SendScreen(CScreen, Logger): 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 - bolt11_invoice = maybe_extract_bolt11_invoice(data) - if bolt11_invoice is not None: - self.set_ln_invoice(bolt11_invoice) - else: - self.set_URI(data) + self.app.on_data_input(data) def read_invoice(self): address = str(self.address) @@ -340,7 +365,35 @@ 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: + # FIXME network request blocking GUI thread: + invoice_data = Network.run_from_another_thread(callback_lnurl( + self.lnurl_data.callback_url, + params={'amount': amount * 1000}, + )) + 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.lnurl_data: + self._lnurl_get_invoice() + 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: 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/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 c00e92ea2..a12e30f11 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 @@ -57,12 +58,12 @@ 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, 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, LNURLError, LNURL6Data from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit, SizedFreezableLineEdit @@ -199,12 +201,16 @@ 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() 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) @@ -309,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) @@ -820,7 +830,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')) @@ -912,8 +922,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.on_timer_check_text() self.notify_transactions() def format_amount(self, amount_sat, is_diff=False, whitespaces=False) -> str: @@ -1524,7 +1534,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), " @@ -1573,7 +1582,7 @@ 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) buttons = QHBoxLayout() @@ -1910,7 +1919,43 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.invoice_list.update() self.pending_invoice = None - def do_pay(self): + 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 + + 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.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 + + def do_pay_or_get_invoice(self): + if self._lnurl_data: + self._lnurl_get_invoice() + return self.pending_invoice = self.read_invoice() if not self.pending_invoice: return @@ -2171,14 +2216,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) - self.payto_e.setText(_("please wait...")) - return True + for btn in [self.save_button, self.send_button, self.clear_button]: + btn.setEnabled(False) + self.payto_e.setTextNoCheck(_("please wait...")) def delete_invoices(self, keys): for key in keys: @@ -2195,14 +2241,18 @@ 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: 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()) + 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("") @@ -2215,14 +2265,46 @@ 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_ln_invoice(self, invoice: str): + 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 + 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 + 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.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): """Parse ln invoice, and prepare the send tab for it.""" try: lnaddr = lndecode(invoice) @@ -2238,9 +2320,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): else: description = '' self.payto_e.setFrozen(True) - self.payto_e.setText(pubkey) + self.payto_e.setTextNoCheck(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) @@ -2249,9 +2332,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 @@ -2259,8 +2343,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): r = out.get('r') sig = out.get('sig') name = out.get('name') - if r or (name and sig): - self.prepare_for_payment_request() + if (r or (name and sig)) and can_use_network: + self.prepare_for_send_tab_network_lookup() return address = out.get('address') amount = out.get('amount') @@ -2268,7 +2352,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, can_use_network=can_use_network) return # use label as description (not BIP21 compliant) if label and not message: @@ -2280,28 +2364,45 @@ 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, *, can_use_network: bool = True): + """Takes + Lightning identifiers: + * lightning-URI (containing bolt11 or lnurl) + * bolt11 invoice + * lnurl + 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) + if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): + if invoice_or_lnurl.startswith('lnurl'): + 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, can_use_network=can_use_network) else: - self.set_bip21(text) + 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_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.clear_button, self.amount_e, self.fiat_send_e]: + e.setEnabled(True) self.update_status() run_hook('do_clear', self) @@ -3121,7 +3222,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..876c96c0e 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -31,11 +31,11 @@ from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING from PyQt5.QtGui import QFontMetrics, QFont 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 @@ -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,10 +85,11 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.c = None - self.textChanged.connect(self.check_text) + 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 + self.disable_checks = False self.is_alias = False self.update_size() self.payto_scriptpubkey = None # type: Optional[bytes] @@ -99,6 +101,18 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.setStyleSheet(frozen_style if b else normal_style) self.overlay_widget.setHidden(b) + def setTextNoCheck(self, text: str): + """Sets the text, while also ensuring the new value will not be resolved/checked.""" + self.previous_payto = text + self.setText(text) + + def do_clear(self): + self.disable_checks = False + self.is_alias = False + self.setText('') + self.setFrozen(False) + self.setEnabled(True) + def setGreen(self): self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) @@ -157,9 +171,29 @@ 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 + self._check_text(full_check=True) + + 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: + if self.disable_checks: return # filter out empty lines lines = [i for i in self.lines() if i] @@ -170,14 +204,14 @@ 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, can_use_network=full_check) + except LNURLError as e: + self.logger.exception("") + self.win.show_error(e) + except ValueError: + pass + else: return # try "address, amount" on-chain format try: @@ -195,6 +229,12 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.win.set_onchain(True) self.win.lock_amount(False) return + if full_check: # network requests # FIXME blocking GUI thread + # 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) @@ -256,7 +296,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): @@ -276,44 +316,34 @@ 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 + self.setFrozen(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) self.win.contact_list.update() - self.setFrozen(True) if data.get('type') == 'openalias': self.validated = data.get('validated') if self.validated: @@ -322,3 +352,4 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.setExpired() else: self.validated = None + return True 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): diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 06f8c9100..4caf765e4 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 new file mode 100644 index 000000000..28e9ab93b --- /dev/null +++ b/electrum/lnurl.py @@ -0,0 +1,108 @@ +"""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, NamedTuple, Any, TYPE_CHECKING +import re + +import aiohttp.client_exceptions +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): + 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 + + +class LNURL6Data(NamedTuple): + callback_url: str + max_sendable_sat: int + min_sendable_sat: int + metadata_plaintext: str + #tag: str = "payRequest" + + +async def _request_lnurl(url: str) -> dict: + """Requests payment data from a lnurl.""" + try: + response = await Network.async_send_http_on_proxy("get", url, timeout=10) + 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 + + +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 + 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 + + +async def callback_lnurl(url: str, params: dict) -> dict: + """Requests an invoice from a lnurl supporting server.""" + try: + 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 + 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. + 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/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, 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..94629d055 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)