Files
routstr-core/routstr/payment/lnurl.py
T
2026-07-05 23:35:10 +02:00

244 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import math
from typing import TypedDict
import httpx
from cashu.wallet.wallet import Proof, Wallet
# The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung or
# very slow mint can block a melt (and any caller, e.g. the payout loop)
# indefinitely. Bound it here so callers fail instead of hanging forever.
MELT_TIMEOUT_SECONDS = 60
try:
from bech32 import bech32_decode, convertbits # type: ignore
except ModuleNotFoundError: # pragma: no cover allow runtime miss
bech32_decode = None # type: ignore
convertbits = None # type: ignore
class LNURLData(TypedDict):
"""LNURL payRequest data."""
callback_url: str
min_sendable: int # millisatoshi
max_sendable: int # millisatoshi
class LNURLError(Exception):
"""LNURL related errors."""
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
Handles:
- lightning: prefix
- user@host format
- bech32 encoded lnurl
- direct HTTPS URLs
Args:
lnurl: LNURL string in any supported format
Returns:
The decoded HTTPS URL
Raises:
LNURLError: If the LNURL format is invalid
"""
# Remove lightning: prefix if present
if lnurl.startswith("lightning:"):
lnurl = lnurl[10:]
# Handle user@host format (Lightning Address)
if "@" in lnurl and len(lnurl.split("@")) == 2:
user, host = lnurl.split("@")
return f"https://{host}/.well-known/lnurlp/{user}"
# Handle bech32 encoded LNURL
if lnurl.lower().startswith("lnurl"):
if bech32_decode is None or convertbits is None:
raise ImportError(
"bech32 library is required for LNURL bech32 decoding. "
"Install it with: pip install bech32"
)
try:
hrp, data = bech32_decode(lnurl)
if data is None:
raise LNURLError("Invalid bech32 data in LNURL")
decoded_data = convertbits(data, 5, 8, False)
if decoded_data is None:
raise LNURLError("Failed to convert LNURL bits")
return bytes(decoded_data).decode("utf-8")
except Exception as e:
raise LNURLError(f"Failed to decode LNURL: {e}") from e
# Assume it's a direct URL
if not lnurl.startswith("https://"):
raise LNURLError("Direct LNURL must use HTTPS")
return lnurl
async def get_lnurl_data(lnurl: str) -> LNURLData:
"""Fetch LNURL payRequest data.
Args:
lnurl: LNURL string in any supported format
Returns:
LNURLData with callback URL and sendable amounts
Raises:
LNURLError: If the LNURL data is invalid
httpx.HTTPError: If the HTTP request fails
"""
url = await decode_lnurl(lnurl)
async with httpx.AsyncClient() as client:
response = await client.get(url, follow_redirects=True, timeout=10)
response.raise_for_status()
lnurl_data = response.json()
# Validate payRequest data
if lnurl_data.get("tag") != "payRequest":
raise LNURLError(
f"Invalid LNURL tag: expected 'payRequest', got '{lnurl_data.get('tag')}'"
)
if not isinstance(lnurl_data.get("callback"), str):
raise LNURLError("Invalid LNURL payRequest: missing callback URL")
return LNURLData(
callback_url=lnurl_data["callback"],
min_sendable=lnurl_data.get("minSendable", 1000), # Default 1 sat
max_sendable=lnurl_data.get("maxSendable", 1000000000), # Default 1000 BTC
)
async def get_lnurl_invoice(
callback_url: str, amount_msat: int
) -> tuple[str, dict[str, object]]:
"""Request a Lightning invoice from LNURL callback.
Args:
callback_url: The LNURL callback URL
amount_msat: Amount in millisatoshi
Returns:
Tuple of (bolt11_invoice, full_response_data)
Raises:
LNURLError: If the response is invalid
httpx.HTTPError: If the HTTP request fails
"""
async with httpx.AsyncClient() as client:
response = await client.get(
callback_url,
params={"amount": amount_msat},
follow_redirects=True,
timeout=10,
)
response.raise_for_status()
invoice_data = response.json()
if "pr" not in invoice_data:
# Check if there's an error in the response
if "reason" in invoice_data:
raise LNURLError(f"LNURL error: {invoice_data['reason']}")
raise LNURLError(f"Invalid LNURL invoice response: {invoice_data}")
return invoice_data["pr"], invoice_data
async def raw_send_to_lnurl(
wallet: Wallet,
proofs: list[Proof],
lnurl: str,
unit: str,
amount: int | None = None,
) -> int:
"""Send funds to an LNURL address.
Args:
wallet: Wallet instance
lnurl: LNURL string (can be lightning:, user@host, bech32, or direct URL)
amount: Amount to send in the specified currency unit
Returns:
Amount actually paid in the specified currency unit
Raises:
WalletError: If amount is outside LNURL limits or insufficient balance
LNURLError: If LNURL operations fail
Example:
# Send 1000 sats to a Lightning Address
paid = await wallet.send_to_lnurl("user@getalby.com", 1000)
print(f"Paid {paid} sats")
# Send USD to Lightning Address
paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd")
"""
total_balance = sum(proof.amount for proof in proofs)
if amount and total_balance < amount:
raise ValueError("Amount to send is higher than available proofs.")
else:
assert isinstance(amount, int)
total_balance = amount
lnurl_data = await get_lnurl_data(lnurl)
if unit == "sat":
amount_msat = total_balance * 1000
min_sendable_sat = lnurl_data["min_sendable"] // 1000
max_sendable_sat = lnurl_data["max_sendable"] // 1000
elif unit == "msat":
amount_msat = (total_balance // 1000) * 1000
min_sendable_sat = lnurl_data["min_sendable"]
max_sendable_sat = lnurl_data["max_sendable"]
else:
raise ValueError(f"Currency {unit} not supported for LNURL")
if not (lnurl_data["min_sendable"] <= amount_msat <= lnurl_data["max_sendable"]):
raise ValueError(
f"Amount {total_balance} {unit} is outside LNURL limits "
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
)
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
estimated_fees_msat = estimated_fees_sat * 1000
final_amount = amount_msat - estimated_fees_msat
bolt11_invoice, _ = await get_lnurl_invoice(
lnurl_data["callback_url"], final_amount
)
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
if amount:
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
try:
_ = await asyncio.wait_for(
wallet.melt(
proofs=proofs,
invoice=bolt11_invoice,
fee_reserve_sat=melt_quote_resp.fee_reserve,
quote_id=melt_quote_resp.quote,
),
timeout=MELT_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError as e:
raise LNURLError(
f"Melt timed out after {MELT_TIMEOUT_SECONDS}s (mint unresponsive)"
) from e
return final_amount