Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Check out the LUDS repository: [luds](https://github.com/lnurl/luds/)
- [x] LUD-20 - Long payment description for pay protocol
- [x] LUD-21 - verify LNURL-pay payments
- [x] LUD-23 - addressRequest base spec
- [x] LUD-25 (draft, [lnurl/luds#301](https://github.com/lnurl/luds/pull/301)) - `LNURLcash`: bearer assets


Configuration
Expand Down
4 changes: 4 additions & 0 deletions lnurl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
encode,
execute,
execute_address_request,
execute_cash_action,
execute_login,
execute_pay_request,
execute_withdraw,
Expand Down Expand Up @@ -53,6 +54,7 @@
LnurlResponseModel,
LnurlSuccessResponse,
LnurlWithdrawResponse,
LnurlWithdrawSuccessResponse,
MessageAction,
UrlAction,
)
Expand Down Expand Up @@ -84,6 +86,7 @@
"encode",
"execute",
"execute_address_request",
"execute_cash_action",
"execute_login",
"execute_pay_request",
"execute_withdraw",
Expand All @@ -108,6 +111,7 @@
"LnurlStatus",
"LnurlSuccessResponse",
"LnurlWithdrawResponse",
"LnurlWithdrawSuccessResponse",
"MilliSatoshi",
"CallbackUrl",
"Url",
Expand Down
42 changes: 42 additions & 0 deletions lnurl/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
LnurlResponseModel,
LnurlSuccessResponse,
LnurlWithdrawResponse,
LnurlWithdrawSuccessResponse,
)
from .types import CallbackUrl, LnAddress, Lnurl, Url

Expand Down Expand Up @@ -280,6 +281,47 @@ async def execute_withdraw(
return withdraw_res


# LUD-25 (draft): `LNURLcash` rotate/split/merge callback.
async def execute_cash_action(
res: LnurlWithdrawResponse,
k1s: Optional[list[str]] = None,
amount: Optional[int] = None,
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
) -> LnurlWithdrawSuccessResponse:
k1s = k1s or [res.k1]

params: list[tuple[str, str | int | float | bool | None]] = [("k1", k1) for k1 in k1s]
if amount is not None:
params.append(("amount", amount))

headers = {"User-Agent": user_agent or USER_AGENT}
proxy = tor_socks or TOR_SOCKS if res.callback.host and res.callback.host.endswith(".onion") else None
async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client:
try:
res2 = await client.get(
url=str(res.callback),
params=params,
timeout=timeout or TIMEOUT,
)
res2.raise_for_status()
except httpx.ConnectError as exc:
if proxy:
raise LnurlResponseException(
f"Failed to connect to {res.callback!s} via Tor proxy {proxy}. Is Tor running?"
) from exc
raise LnurlResponseException(f"Failed to connect to {res.callback!s}") from exc
except Exception as exc:
raise LnurlResponseException(str(exc))
cash_res = LnurlResponse.from_dict(res2.json())
if isinstance(cash_res, LnurlErrorResponse):
raise LnurlResponseException(cash_res.reason)
if not isinstance(cash_res, LnurlWithdrawSuccessResponse):
raise LnurlResponseException(f"Expected LnurlWithdrawSuccessResponse, got {type(cash_res)}")
return cash_res


# LUD-23: addressRequest base spec.
async def execute_address_request(
res: LnurlAddressRequestResponse,
Expand Down
25 changes: 25 additions & 0 deletions lnurl/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
LnurlResponseTag,
LnurlStatus,
Lud17PayLink,
Lud25WithdrawLink,
Max144Str,
MilliSatoshi,
Url,
Expand Down Expand Up @@ -92,6 +93,14 @@ class LnurlSuccessResponse(LnurlResponseModel):
status: LnurlStatus = LnurlStatus.ok


# LUD-25 (draft): `LNURLcash` withdrawSuccessResponse extension, for rotate/split/merge.
class LnurlWithdrawSuccessResponse(LnurlSuccessResponse):
k1: Optional[str] = Field(default=None, description="LUD-25: newly minted bearer secret.")
signature: Optional[str] = Field(default=None, description="LUD-25: recoverable signature for the new note.")
change: Optional[str] = Field(default=None, description="LUD-25: note carrying the remainder, after a split.")
changeSignature: Optional[str] = Field(default=None, description="LUD-25: signature for `change`, iff present.")


# LUD-21: verify base spec.
class LnurlPayVerifyResponse(LnurlSuccessResponse):
pr: LightningInvoice = Field(description="Payment request")
Expand Down Expand Up @@ -184,6 +193,11 @@ class LnurlPayResponse(LnurlResponseModel):
allowsNostr: Optional[bool] = None
nostrPubkey: Optional[str] = None

# LUD-25 (draft): `LNURLcash` bearer note extension.
withdrawLink: Optional[Lud25WithdrawLink] = Field(
default=None, description="LUD-25: withdrawRequest for the bearer note minted by paying this callback."
)

@model_validator(mode="after")
def max_less_than_min(self):
if self.maxSendable < self.minSendable:
Expand Down Expand Up @@ -227,6 +241,10 @@ class LnurlWithdrawResponse(LnurlResponseModel):
currentBalance: Optional[MilliSatoshi] = None
# LUD-19: Pay link discoverable from withdraw link.
payLink: Lud17PayLink | None = None
# LUD-25 (draft): `LNURLcash` bearer note extension.
mintPubkey: Optional[str] = Field(
default=None, description="LUD-25: 33-byte compressed secp256k1 pubkey (hex) for offline note verification."
)

@model_validator(mode="after")
def max_less_than_min(self):
Expand Down Expand Up @@ -294,6 +312,13 @@ def from_dict(data: dict) -> LnurlResponseModel:
status = status.upper()

if status == "OK":
# LUD-25 (draft): rotate/split/merge responses carry a new `k1` (and possibly `change`).
cash_fields = {k: data[k] for k in ("k1", "signature", "change", "changeSignature") if k in data}
if cash_fields:
try:
return LnurlWithdrawSuccessResponse(status=LnurlStatus.ok, **cash_fields)
except ValidationError as exc:
raise LnurlResponseException(str(exc)) from exc
return LnurlSuccessResponse(status=LnurlStatus.ok)

if status == "ERROR":
Expand Down
13 changes: 13 additions & 0 deletions lnurl/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,16 @@ def validate_paylink_is_lud17(value: Optional[str] = None) -> str | None:


Lud17PayLink = Annotated[str, AfterValidator(validate_paylink_is_lud17)]


# LUD-25 (draft): `LNURLcash` bearer note extension.
def validate_withdraw_link_is_lud17(value: Optional[str] = None) -> str | None:
if not value:
return None
lnurl = Lnurl(value)
if lnurl.is_lud17 and lnurl.lud17_prefix == "lnurlw":
return value
raise ValueError("`withdrawLink` must be a valid LUD17 URL (lnurlw://).")


Lud25WithdrawLink = Annotated[str, AfterValidator(validate_withdraw_link_is_lud17)]
77 changes: 76 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
from unittest.mock import AsyncMock, patch

import httpx
import pytest

from lnurl.core import decode, encode, execute_address_request, execute_login, execute_pay_request, get, handle
from lnurl.core import (
decode,
encode,
execute_address_request,
execute_cash_action,
execute_login,
execute_pay_request,
get,
handle,
)
from lnurl.exceptions import InvalidLnurl, InvalidUrl, LnurlResponseException
from lnurl.models import (
LnurlAddressRequestResponse,
Expand All @@ -11,6 +22,7 @@
LnurlPaySuccessAction,
LnurlSuccessResponse,
LnurlWithdrawResponse,
LnurlWithdrawSuccessResponse,
)
from lnurl.types import Lnurl

Expand Down Expand Up @@ -171,3 +183,66 @@ async def test_execute_address_request_invalid_address(self):
)
with pytest.raises(LnurlResponseException):
await execute_address_request(res, "not-a-valid-address")


class TestCashActionFlow:
"""LUD-25 (draft): `LNURLcash` rotate/split/merge callback."""

@pytest.mark.asyncio
async def test_execute_cash_action_rotate(self):
res = LnurlWithdrawResponse(
callback="https://service.io/w/cb",
k1="a",
minWithdrawable=1000,
maxWithdrawable=2000,
)
mock_response = httpx.Response(
200,
json={"status": "OK", "k1": "b"},
request=httpx.Request("GET", "https://service.io/w/cb"),
)
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=mock_response)) as mock_get:
cash_res = await execute_cash_action(res)
assert isinstance(cash_res, LnurlWithdrawSuccessResponse)
assert cash_res.k1 == "b"
assert mock_get.call_args.kwargs["params"] == [("k1", "a")]

@pytest.mark.asyncio
async def test_execute_cash_action_split_with_multiple_k1s(self):
# merging several k1s and splitting off `amount` is valid, not just for a single k1.
res = LnurlWithdrawResponse(
callback="https://service.io/w/cb",
k1="a",
minWithdrawable=1000,
maxWithdrawable=2000,
)
mock_response = httpx.Response(
200,
json={"status": "OK", "k1": "new-k1", "change": "change-k1"},
request=httpx.Request("GET", "https://service.io/w/cb"),
)
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=mock_response)) as mock_get:
cash_res = await execute_cash_action(res, k1s=["a", "b"], amount=500)
assert isinstance(cash_res, LnurlWithdrawSuccessResponse)
assert cash_res.k1 == "new-k1"
assert cash_res.change == "change-k1"
assert mock_get.call_args.kwargs["params"] == [("k1", "a"), ("k1", "b"), ("amount", 500)]

@pytest.mark.asyncio
async def test_execute_cash_action_merge_multiple_k1s(self):
res = LnurlWithdrawResponse(
callback="https://service.io/w/cb",
k1="a",
minWithdrawable=1000,
maxWithdrawable=2000,
)
mock_response = httpx.Response(
200,
json={"status": "OK", "k1": "merged-k1"},
request=httpx.Request("GET", "https://service.io/w/cb"),
)
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=mock_response)) as mock_get:
cash_res = await execute_cash_action(res, k1s=["a", "b"])
assert isinstance(cash_res, LnurlWithdrawSuccessResponse)
assert cash_res.k1 == "merged-k1"
assert mock_get.call_args.kwargs["params"] == [("k1", "a"), ("k1", "b")]
68 changes: 68 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
LnurlPayResponsePayerDataOption,
LnurlSuccessResponse,
LnurlWithdrawResponse,
LnurlWithdrawSuccessResponse,
)


Expand Down Expand Up @@ -150,6 +151,38 @@ def test_invalid_data(self, d):
with pytest.raises(ValidationError):
LnurlPayResponse(**d)

def test_withdraw_link(self):
# LUD-25 (draft): `LNURLcash` bearer note extension.
data = TypeAdapter(LnurlPayMetadata).validate_python(metadata)
res = LnurlPayResponse(
callback=TypeAdapter(CallbackUrl).validate_python("https://service.io/pay"),
minSendable=MilliSatoshi(1000),
maxSendable=MilliSatoshi(2000),
metadata=data,
withdrawLink="lnurlw://service.io/w",
)
assert res.withdrawLink == "lnurlw://service.io/w"
assert res.dict()["withdrawLink"] == "lnurlw://service.io/w"

@pytest.mark.parametrize(
"withdrawLink",
[
"https://service.io/w", # not a raw LUD17 URL
"lnurlp://service.io/w", # wrong LUD17 scheme (payRequest, not withdrawRequest)
str(encode("https://service.io/w").bech32), # bech32
],
)
def test_invalid_withdraw_link(self, withdrawLink: str):
data = TypeAdapter(LnurlPayMetadata).validate_python(metadata)
with pytest.raises(ValidationError):
LnurlPayResponse(
callback=TypeAdapter(CallbackUrl).validate_python("https://service.io/pay"),
minSendable=MilliSatoshi(1000),
maxSendable=MilliSatoshi(2000),
metadata=data,
withdrawLink=withdrawLink,
)


class TestLnurlPayResponseComment:
@pytest.mark.parametrize(
Expand Down Expand Up @@ -291,6 +324,41 @@ def test_valid_pay_link(self):
payLink=payLink.lud17,
)

def test_mint_pubkey(self):
# LUD-25 (draft): `LNURLcash` bearer note extension.
res = LnurlWithdrawResponse(
callback=TypeAdapter(CallbackUrl).validate_python("https://service.io/withdraw"),
k1="c3RyaW5n",
minWithdrawable=MilliSatoshi(100),
maxWithdrawable=MilliSatoshi(200),
mintPubkey="02" + "ab" * 32,
)
assert res.mintPubkey == "02" + "ab" * 32
assert res.dict()["mintPubkey"] == "02" + "ab" * 32


class TestLnurlWithdrawSuccessResponse:
"""LUD-25 (draft): `LNURLcash` withdrawSuccessResponse extension."""

def test_rotate_response(self):
res = LnurlWithdrawSuccessResponse(k1="new-k1", signature="deadbeef")
assert res.ok
assert res.dict() == {"status": "OK", "k1": "new-k1", "signature": "deadbeef"}

def test_split_response(self):
res = LnurlWithdrawSuccessResponse(k1="new-k1", change="change-k1", changeSignature="beefdead")
assert res.ok
assert res.dict() == {
"status": "OK",
"k1": "new-k1",
"change": "change-k1",
"changeSignature": "beefdead",
}

def test_plain_success_response_has_no_cash_fields(self):
res = LnurlWithdrawSuccessResponse()
assert res.dict() == {"status": "OK"}


class TestLnurlBaseModelCompatibility:
def test_nested_models_support_dict_and_json(self):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_models_from_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
LnurlResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
LnurlWithdrawSuccessResponse,
)
from lnurl.exceptions import LnurlResponseException

Expand Down Expand Up @@ -111,3 +112,27 @@ def test_address_request(self):
assert res.tag == "addressRequest"
assert res.callback.host == "lnurl.bigsun.xyz"
assert res.description == "Share your Lightning address with SERVICE"

# LUD-25 (draft): `LNURLcash` bearer note extension.
def test_cash_rotate_response(self):
res = LnurlResponse.from_dict({"status": "OK", "k1": "new-bearer-k1"})
assert isinstance(res, LnurlWithdrawSuccessResponse)
assert res.ok
assert res.k1 == "new-bearer-k1"
assert res.signature is None

def test_cash_split_response(self):
res = LnurlResponse.from_dict(
{"status": "OK", "k1": "new-k1", "signature": "aa", "change": "change-k1", "changeSignature": "bb"}
)
assert isinstance(res, LnurlWithdrawSuccessResponse)
assert res.ok
assert res.k1 == "new-k1"
assert res.change == "change-k1"
assert res.changeSignature == "bb"

def test_cash_melt_response_is_plain_success(self):
# a melt (k1 + pr) response has no k1 in the withdrawSuccessResponse, still a plain LnurlSuccessResponse
res = LnurlResponse.from_dict({"status": "OK"})
assert isinstance(res, LnurlSuccessResponse)
assert not isinstance(res, LnurlWithdrawSuccessResponse)
Loading