306cac192b
The LnAddr, lndecode and lnencode naming didn't imply that it is bolt 11 specific, making it confusing to work with, now that there are also bolt 12 "lnaddr". Renaming it to *bolt11* creates a clear separation to bolt 12 things and reduces mental load. This commit is pure renaming (using the PyCharm IDE refactor function), except for the removal of the `object` inheritance of LnAddr/BOLT11Addr, this is Python 2 legacy.
262 lines
9.9 KiB
Python
262 lines
9.9 KiB
Python
"""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 urllib.parse
|
|
|
|
import aiohttp.client_exceptions
|
|
|
|
from electrum import segwit_addr, util
|
|
from electrum.segwit_addr import bech32_decode, Encoding, convertbits, bech32_encode
|
|
from electrum.bolt11 import BOLT11DecodeException, BOLT11EncodeException
|
|
from electrum.network import Network
|
|
from electrum.logging import get_logger
|
|
from electrum.i18n import _
|
|
|
|
|
|
_logger = get_logger(__name__)
|
|
|
|
|
|
SUPPORTED_LNURL_SCHEMES = ('lnurlp', 'lnurlw')
|
|
|
|
|
|
class LNURLError(Exception): pass
|
|
|
|
class UntrustedLNURLError(LNURLError):
|
|
def __init__(self, message=""):
|
|
# use if error messages are returned by the LNURL server,
|
|
# some services could try to trick users into doing something
|
|
# by sending a malicious error message
|
|
if message:
|
|
message = (
|
|
f"{_('[DO NOT TRUST THIS MESSAGE]:')}\n"
|
|
f"{util.error_text_str_to_safe_str(message)}"
|
|
)
|
|
super().__init__(message)
|
|
|
|
|
|
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 BOLT11DecodeException("Bad bech32 checksum")
|
|
if decoded_bech32.encoding != Encoding.BECH32:
|
|
raise BOLT11DecodeException("Bad bech32 encoding: must be using vanilla BECH32")
|
|
if not hrp.startswith("lnurl"):
|
|
raise BOLT11DecodeException("Does not start with lnurl")
|
|
data = convertbits(data, 5, 8, False)
|
|
url = bytes(data).decode("utf-8")
|
|
return url
|
|
|
|
|
|
def encode_lnurl(url: str) -> str:
|
|
"""Encode url to bech32 lnurl string."""
|
|
try:
|
|
url = url.encode("utf-8")
|
|
except UnicodeError as e:
|
|
raise BOLT11EncodeException("invalid url") from e
|
|
bech32_data = convertbits(url, 8, 5, True)
|
|
assert bech32_data
|
|
lnurl = bech32_encode(
|
|
encoding=segwit_addr.Encoding.BECH32, hrp="lnurl", data=bech32_data)
|
|
return lnurl.upper()
|
|
|
|
|
|
def _is_url_safe_enough_for_lnurl(url: str) -> bool:
|
|
u = urllib.parse.urlparse(url)
|
|
if u.scheme.lower() == "https":
|
|
return True
|
|
if u.netloc.endswith(".onion"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _parse_lnurl_response_callback_url(lnurl_response: dict) -> str:
|
|
try:
|
|
callback_url = lnurl_response['callback']
|
|
except KeyError as e:
|
|
raise LNURLError(f"Missing 'callback' field in lnurl response.") from e
|
|
if not _is_url_safe_enough_for_lnurl(callback_url):
|
|
raise LNURLError(
|
|
f"This lnurl callback_url looks unsafe. It must use 'https://' or '.onion' (found: {callback_url[:10]}...)")
|
|
return callback_url
|
|
|
|
|
|
# payRequest
|
|
# https://github.com/lnurl/luds/blob/227f850b701e9ba893c080103c683273e2feb521/06.md
|
|
class LNURL6Data(NamedTuple):
|
|
callback_url: str
|
|
max_sendable_sat: int
|
|
min_sendable_sat: int
|
|
metadata_plaintext: str
|
|
comment_allowed: int
|
|
#tag: str = "payRequest"
|
|
|
|
# withdrawRequest
|
|
# https://github.com/lnurl/luds/blob/227f850b701e9ba893c080103c683273e2feb521/03.md
|
|
class LNURL3Data(NamedTuple):
|
|
# The URL which LN SERVICE would accept a withdrawal Lightning invoice as query parameter
|
|
callback_url: str
|
|
# Random or non-random string to identify the user's LN WALLET when using the callback URL
|
|
k1: str
|
|
# A default withdrawal invoice description
|
|
default_description: str
|
|
# Min amount the user can withdraw from LN SERVICE, or 0
|
|
min_withdrawable_sat: int
|
|
# Max amount the user can withdraw from LN SERVICE,
|
|
# or equal to minWithdrawable if the user has no choice over the amounts
|
|
max_withdrawable_sat: int
|
|
|
|
LNURLData = LNURL6Data | LNURL3Data
|
|
|
|
|
|
async def _request_lnurl(url: str) -> dict:
|
|
"""Requests payment data from a lnurl."""
|
|
if not _is_url_safe_enough_for_lnurl(url):
|
|
raise LNURLError(f"This lnurl looks unsafe. It must use 'https://' or '.onion' (found: {url[:10]}...)")
|
|
try:
|
|
response_raw = await Network.async_send_http_on_proxy("get", url, timeout=10)
|
|
except asyncio.TimeoutError as e:
|
|
raise LNURLError("LNURL server did not reply in time.") from e
|
|
except aiohttp.client_exceptions.ClientError as e:
|
|
raise LNURLError(f"Client error: {e}") from e
|
|
try:
|
|
response = json.loads(response_raw)
|
|
except json.JSONDecodeError:
|
|
raise LNURLError(f"Invalid response from LNURL server")
|
|
|
|
status = response.get("status")
|
|
if status and status == "ERROR":
|
|
raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
|
|
return response
|
|
|
|
|
|
def _parse_lnurl6_response(lnurl_response: dict) -> LNURL6Data:
|
|
# parse lnurl6 "metadata"
|
|
metadata_plaintext = ""
|
|
try:
|
|
metadata_raw = lnurl_response["metadata"]
|
|
metadata = json.loads(metadata_raw)
|
|
for m in metadata:
|
|
if m[0] == 'text/plain':
|
|
metadata_plaintext = str(m[1])
|
|
except Exception as e:
|
|
raise LNURLError(
|
|
f"Missing or malformed 'metadata' field in lnurl6 response. exc: {e!r}") from e
|
|
# parse lnurl6 "callback"
|
|
callback_url = _parse_lnurl_response_callback_url(lnurl_response)
|
|
# parse lnurl6 "minSendable"/"maxSendable"
|
|
try:
|
|
max_sendable_sat = int(lnurl_response['maxSendable']) // 1000
|
|
min_sendable_sat = int(lnurl_response['minSendable']) // 1000
|
|
except Exception as e:
|
|
raise LNURLError(
|
|
f"Missing or malformed 'minSendable'/'maxSendable' field in lnurl6 response. {e=!r}") from e
|
|
# parse lnurl6 "commentAllowed" (optional, described in lnurl-12)
|
|
try:
|
|
comment_allowed = int(lnurl_response['commentAllowed']) if 'commentAllowed' in lnurl_response else 0
|
|
except Exception as e:
|
|
raise LNURLError(f"Malformed 'commentAllowed' field in lnurl6 response. {e=!r}") from e
|
|
data = LNURL6Data(
|
|
callback_url=callback_url,
|
|
max_sendable_sat=max_sendable_sat,
|
|
min_sendable_sat=min_sendable_sat,
|
|
metadata_plaintext=metadata_plaintext,
|
|
comment_allowed=comment_allowed,
|
|
)
|
|
return data
|
|
|
|
|
|
def _parse_lnurl3_response(lnurl_response: dict) -> LNURL3Data:
|
|
"""Parses the server response received when requesting a LNURL-withdraw (lud3) request"""
|
|
callback_url = _parse_lnurl_response_callback_url(lnurl_response)
|
|
if not (k1 := lnurl_response.get('k1')):
|
|
raise UntrustedLNURLError(f"Missing k1 value in LNURL3 response: {lnurl_response=}")
|
|
default_description = lnurl_response.get('defaultDescription', '')
|
|
try:
|
|
min_withdrawable_sat = int(lnurl_response['minWithdrawable'] or 0) // 1000
|
|
max_withdrawable_sat = int(lnurl_response['maxWithdrawable']) // 1000
|
|
assert max_withdrawable_sat >= min_withdrawable_sat, f"Invalid amounts: max < min amount"
|
|
assert max_withdrawable_sat > 0, f"Invalid max amount: {max_withdrawable_sat} sat"
|
|
except Exception as e:
|
|
raise LNURLError(
|
|
f"Missing or malformed 'minWithdrawable'/'minWithdrawable' field in lnurl3 response. {e=!r}") from e
|
|
return LNURL3Data(
|
|
callback_url=callback_url,
|
|
k1=k1,
|
|
default_description=default_description,
|
|
min_withdrawable_sat=min_withdrawable_sat,
|
|
max_withdrawable_sat=max_withdrawable_sat,
|
|
)
|
|
|
|
|
|
async def request_lnurl(url: str) -> LNURLData:
|
|
lnurl_dict = await _request_lnurl(url)
|
|
tag = lnurl_dict.get('tag')
|
|
if tag == 'payRequest': # only LNURL6 is handled atm
|
|
return _parse_lnurl6_response(lnurl_dict)
|
|
elif tag == 'withdrawRequest':
|
|
return _parse_lnurl3_response(lnurl_dict)
|
|
raise UntrustedLNURLError(f"Unknown subtype of lnurl. tag={tag}")
|
|
|
|
|
|
async def try_resolve_lnurlpay(lnurl: Optional[str]) -> Optional[LNURL6Data]:
|
|
if lnurl:
|
|
try:
|
|
result = await request_lnurl(lnurl)
|
|
assert isinstance(result, LNURL6Data), f"lnurl result is not LNURL-pay response: {result=}"
|
|
return result
|
|
except Exception as request_error:
|
|
_logger.debug(f"Error resolving lnurl: {request_error!r}")
|
|
return None
|
|
|
|
async def request_lnurl_withdraw_callback(callback_url: str, k1: str, bolt_11: str) -> None:
|
|
assert bolt_11
|
|
params = {
|
|
"k1": k1,
|
|
"pr": bolt_11,
|
|
}
|
|
await callback_lnurl(
|
|
url=callback_url,
|
|
params=params
|
|
)
|
|
|
|
async def callback_lnurl(url: str, params: dict) -> dict:
|
|
"""Requests an invoice from a lnurl supporting server."""
|
|
if not _is_url_safe_enough_for_lnurl(url):
|
|
raise LNURLError(f"This lnurl looks unsafe. It must use 'https://' or '.onion' (found: {url[:10]}...)")
|
|
try:
|
|
response_raw = await Network.async_send_http_on_proxy("get", url, params=params)
|
|
except asyncio.TimeoutError as e:
|
|
raise LNURLError("LNURL server did not reply in time.") from e
|
|
except aiohttp.client_exceptions.ClientError as e:
|
|
raise LNURLError(f"Client error: {e}") from e
|
|
try:
|
|
response = json.loads(response_raw)
|
|
_logger.debug(f"lnurl response: {response}")
|
|
except json.JSONDecodeError:
|
|
raise LNURLError(f"Invalid response from LNURL server")
|
|
|
|
status = response.get("status")
|
|
if status and status == "ERROR":
|
|
raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
|
|
# TODO: handling of specific errors (validate fields, e.g. for lnurl6)
|
|
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}"
|