Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,14 @@ lnurl.bech32 # "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E5K7TELWY7NXENRXVMRGDTZXSENJCM98
lnurl.bech32.hrp # "lnurl"
lnurl.url # "https://service.io/?q=3fc3645b439ce8e7"
lnurl.url.host # "service.io"
lnurl.url.base # "https://service.io/"
lnurl.url.query # "q=3fc3645b439ce8e7"
lnurl.url.query_params # {"q": "3fc3645b439ce8e7"}
lnurl.url.query_params() # [("q", "3fc3645b439ce8e7")]
dict(lnurl.url.query_params()) # {"q": "3fc3645b439ce8e7"}
```

`query_params()` returns a list of `(key, value)` tuples so query parameters stay lossless and ordered.
Use `dict(...)` when you want convenient mapping-style access and duplicate keys do not matter.

Parsing LNURL responses
-----------------------

Expand All @@ -94,8 +97,9 @@ try:
res.ok # bool
res.maxSendable # int
res.max_sats # int
res.callback.base # str
res.callback.query_params # dict
res.callback.host # str
res.callback.query_params() # list[tuple] [("amount", "1000")]
dict(res.callback.query_params()) # dict
res.metadata # str
res.metadata.list() # list
res.metadata.text # str
Expand Down Expand Up @@ -125,10 +129,10 @@ For LNURL services, the `lnurl` package can be used to build **valid** responses

```python
from lnurl import CallbackUrl, LnurlWithdrawResponse, MilliSatoshi
from pydantic import parse_obj_as, ValidationError
from pydantic import TypeAdapter, ValidationError
try:
res = LnurlWithdrawResponse(
callback=parse_obj_as(CallbackUrl, "https://lnurl.bigsun.xyz/lnurl-withdraw/callback/9702808"),
callback=TypeAdapter(CallbackUrl).validate_python("https://lnurl.bigsun.xyz/lnurl-withdraw/callback/9702808"),
k1="38d304051c1b76dcd8c5ee17ee15ff0ebc02090c0afbc6c98100adfa3f920874",
minWithdrawable=MilliSatoshi(1000),
maxWithdrawable=MilliSatoshi(1000000),
Expand All @@ -144,8 +148,7 @@ All responses are `pydantic` models, so the information you provide will be vali
access to `.json()` and `.dict()` methods to export the data.

**Data is exported using :camel: camelCase keys by default, as per spec.**
You can also use camelCases when you parse the data, and it will be converted to snake_case to make your
Python code nicer.
Use the LNURL spec field names when parsing and exporting response models.

Will throw and ValidationError if the data is not valid, so you can catch it and return an error response.

Expand Down
4 changes: 1 addition & 3 deletions lnurl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# backward compatibility, MilliSatoshi is now imported from bolt11
from bolt11 import MilliSatoshi

from .core import decode, encode, execute, execute_login, execute_pay_request, execute_withdraw, get, handle
from .exceptions import (
InvalidLnurl,
Expand Down Expand Up @@ -63,6 +60,7 @@
LnurlResponseTag,
LnurlStatus,
Max144Str,
MilliSatoshi,
Url,
)

Expand Down
28 changes: 18 additions & 10 deletions lnurl/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import httpx
from bolt11 import Bolt11Exception, MilliSatoshi
from bolt11 import decode as bolt11_decode
from pydantic import ValidationError, parse_obj_as
from pydantic import TypeAdapter, ValidationError

from .exceptions import InvalidLnurl, InvalidUrl, LnurlResponseException
from .helpers import (
Expand All @@ -23,7 +23,7 @@
LnurlSuccessResponse,
LnurlWithdrawResponse,
)
from .types import CallbackUrl, LnAddress, Lnurl
from .types import CallbackUrl, LnAddress, Lnurl, Url

USER_AGENT = "lnbits/lnurl"
TIMEOUT = 5
Expand All @@ -44,24 +44,25 @@ def encode(url: str) -> Lnurl:


async def get(
url: str,
url: str | Url | CallbackUrl,
*,
response_class: Optional[Any] = None,
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
) -> LnurlResponseModel:
request_url = str(url)
headers = {"User-Agent": user_agent or USER_AGENT}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
res = await client.get(url, timeout=timeout or TIMEOUT)
res = await client.get(request_url, timeout=timeout or TIMEOUT)
res.raise_for_status()
except Exception as exc:
raise LnurlResponseException(str(exc)) from exc

try:
_json = res.json()
except JSONDecodeError as exc:
raise LnurlResponseException(f"Invalid JSON response from {url}") from exc
raise LnurlResponseException(f"Invalid JSON response from {request_url}") from exc

if response_class:
if not issubclass(response_class, LnurlResponseModel):
Expand All @@ -86,8 +87,15 @@ async def handle(
raise InvalidLnurl

if lnurl.is_login:
callback_url = parse_obj_as(CallbackUrl, lnurl.url)
return LnurlAuthResponse(callback=callback_url, k1=lnurl.url.query_params["k1"])
callback_url = TypeAdapter(CallbackUrl).validate_python(lnurl.url)
k1 = None
for param in lnurl.url.query_params():
if param[0] == "k1":
k1 = param[1]
break
if not k1:
raise LnurlResponseException("k1 parameter not found in LNURLauth URL")
return LnurlAuthResponse(callback=callback_url, k1=k1)

return await get(lnurl.url, response_class=response_class, user_agent=user_agent, timeout=timeout)

Expand Down Expand Up @@ -134,7 +142,7 @@ async def execute_pay_request(
headers = {"User-Agent": user_agent or USER_AGENT}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
res2 = await client.get(
url=res.callback,
url=str(res.callback),
params=params,
timeout=timeout or TIMEOUT,
)
Expand Down Expand Up @@ -178,7 +186,7 @@ async def execute_login(
headers = {"User-Agent": user_agent or USER_AGENT}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
res2 = await client.get(
url=res.callback,
url=str(res.callback),
params={
"key": key,
"sig": sig,
Expand Down Expand Up @@ -209,7 +217,7 @@ async def execute_withdraw(
headers = {"User-Agent": user_agent or USER_AGENT}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
res2 = await client.get(
url=res.callback,
url=str(res.callback),
params={
"k1": res.k1,
"pr": pr,
Expand Down
81 changes: 38 additions & 43 deletions lnurl/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
from abc import ABC
from typing import Optional, Union

from bolt11 import MilliSatoshi
from pydantic import BaseModel, Field, ValidationError, validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator

from .exceptions import LnurlResponseException
from .types import (
Expand All @@ -14,22 +13,40 @@
InitializationVectorBase64,
LightningInvoice,
LightningNodeUri,
Lnurl,
# Lnurl,
LnurlPayMetadata,
LnurlPaySuccessActionTag,
LnurlResponseTag,
LnurlStatus,
Lud17PayLink,
Max144Str,
MilliSatoshi,
Url,
)


class LnurlPayRouteHop(BaseModel):
class LnurlBaseModel(BaseModel):
model_config = ConfigDict(
use_enum_values=True,
extra="forbid",
)

def dict(self, *args, **kwargs):
kwargs.setdefault("mode", "json")
kwargs.setdefault("exclude_none", True)
return self.model_dump(*args, **kwargs)

def json(self, *args, **kwargs):
kwargs.setdefault("exclude_none", True)
return self.model_dump_json(*args, **kwargs)


class LnurlPayRouteHop(LnurlBaseModel):
nodeId: str
channelUpdate: str


class LnurlPaySuccessAction(BaseModel, ABC):
class LnurlPaySuccessAction(LnurlBaseModel, ABC):
tag: LnurlPaySuccessActionTag


Expand All @@ -52,20 +69,7 @@ class AesAction(LnurlPaySuccessAction):
iv: InitializationVectorBase64


class LnurlResponseModel(BaseModel):

class Config:
use_enum_values = True
extra = "forbid"

def dict(self, **kwargs):
kwargs["exclude_none"] = True
return super().dict(**kwargs)

def json(self, **kwargs):
kwargs["exclude_none"] = True
return super().json(**kwargs)

class LnurlResponseModel(LnurlBaseModel):
@property
def ok(self) -> bool:
return True
Expand Down Expand Up @@ -119,20 +123,20 @@ class LnurlHostedChannelResponse(LnurlResponseModel):


# LUD-18: Payer identity in payRequest protocol.
class LnurlPayResponsePayerDataOption(BaseModel):
class LnurlPayResponsePayerDataOption(LnurlBaseModel):
mandatory: bool


class LnurlPayResponsePayerDataOptionAuth(LnurlPayResponsePayerDataOption):
k1: str


class LnurlPayResponsePayerDataExtra(BaseModel):
class LnurlPayResponsePayerDataExtra(LnurlBaseModel):
name: str
field: LnurlPayResponsePayerDataOption


class LnurlPayResponsePayerData(BaseModel):
class LnurlPayResponsePayerData(LnurlBaseModel):
name: Optional[LnurlPayResponsePayerDataOption] = None
pubkey: Optional[LnurlPayResponsePayerDataOption] = None
identifier: Optional[LnurlPayResponsePayerDataOption] = None
Expand All @@ -141,13 +145,13 @@ class LnurlPayResponsePayerData(BaseModel):
extras: Optional[list[LnurlPayResponsePayerDataExtra]] = None


class LnurlPayerDataAuth(BaseModel):
class LnurlPayerDataAuth(LnurlBaseModel):
key: str
k1: str
sig: str


class LnurlPayerData(BaseModel):
class LnurlPayerData(LnurlBaseModel):
name: Optional[str] = None
pubkey: Optional[str] = None
identifier: Optional[str] = None
Expand All @@ -172,11 +176,11 @@ class LnurlPayResponse(LnurlResponseModel):
allowsNostr: Optional[bool] = None
nostrPubkey: Optional[str] = None

@validator("maxSendable")
def max_less_than_min(cls, value, values): # noqa
if "minSendable" in values and value < values["minSendable"]:
@model_validator(mode="after")
def max_less_than_min(self):
if self.maxSendable < self.minSendable:
raise ValueError("`maxSendable` cannot be less than `minSendable`.")
return value
return self

@property
def min_sats(self) -> int:
Expand Down Expand Up @@ -214,22 +218,13 @@ class LnurlWithdrawResponse(LnurlResponseModel):
balanceCheck: Optional[CallbackUrl] = None
currentBalance: Optional[MilliSatoshi] = None
# LUD-19: Pay link discoverable from withdraw link.
payLink: Optional[str] = None

@validator("payLink", pre=True)
def paylink_must_be_lud17(cls, value: Optional[str] = None) -> str | None:
if not value:
return None
lnurl = Lnurl(value)
if lnurl.is_lud17 and lnurl.lud17_prefix == "lnurlp":
return value
raise ValueError("`payLink` must be a valid LUD17 URL (lnurlp://).")

@validator("maxWithdrawable")
def max_less_than_min(cls, value, values):
if "minWithdrawable" in values and value < values["minWithdrawable"]:
payLink: Lud17PayLink | None = None

@model_validator(mode="after")
def max_less_than_min(self):
if self.maxWithdrawable < self.minWithdrawable:
raise ValueError("`maxWithdrawable` cannot be less than `minWithdrawable`.")
return value
return self

# LUD-08: Fast withdrawRequest.
@property
Expand Down
Loading
Loading