1
0

Merge pull request #7839 from SomberNight/202202_lnurl_2

add lnurl-pay (`LUD-06`) support
This commit is contained in:
ghost43
2022-06-30 16:30:21 +00:00
committed by GitHub
15 changed files with 444 additions and 128 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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: