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: