Browse Source

add lnurl-pay and lightning address support

* bundles all payment identifiers into handle_payment_identifier
* adds lnurl decoding
* adds lightning address decoding
patch-4
bitromortac 3 years ago
committed by SomberNight
parent
commit
fe2fbbd9b1
No known key found for this signature in database GPG Key ID: B33B5F232C6271E9
  1. 4
      electrum/gui/kivy/main_window.py
  2. 6
      electrum/gui/kivy/uix/screens.py
  3. 4
      electrum/gui/qt/__init__.py
  4. 101
      electrum/gui/qt/main_window.py
  5. 37
      electrum/gui/qt/paytoedit.py
  6. 75
      electrum/lnurl.py
  7. 12
      electrum/tests/test_lnurl.py
  8. 10
      electrum/util.py
  9. 7
      run_electrum

4
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

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

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

101
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,8 +2288,9 @@ 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
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())
@ -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)
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)

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

75
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}"

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

10
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

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

Loading…
Cancel
Save