diff --git a/README.md b/README.md index 772f9cf..5dfc625 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/lnurl/__init__.py b/lnurl/__init__.py index 17f7f9b..c35575b 100644 --- a/lnurl/__init__.py +++ b/lnurl/__init__.py @@ -3,6 +3,7 @@ encode, execute, execute_address_request, + execute_cash_action, execute_login, execute_pay_request, execute_withdraw, @@ -53,6 +54,7 @@ LnurlResponseModel, LnurlSuccessResponse, LnurlWithdrawResponse, + LnurlWithdrawSuccessResponse, MessageAction, UrlAction, ) @@ -84,6 +86,7 @@ "encode", "execute", "execute_address_request", + "execute_cash_action", "execute_login", "execute_pay_request", "execute_withdraw", @@ -108,6 +111,7 @@ "LnurlStatus", "LnurlSuccessResponse", "LnurlWithdrawResponse", + "LnurlWithdrawSuccessResponse", "MilliSatoshi", "CallbackUrl", "Url", diff --git a/lnurl/core.py b/lnurl/core.py index f921b1e..4262a00 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -23,6 +23,7 @@ LnurlResponseModel, LnurlSuccessResponse, LnurlWithdrawResponse, + LnurlWithdrawSuccessResponse, ) from .types import CallbackUrl, LnAddress, Lnurl, Url @@ -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, diff --git a/lnurl/models.py b/lnurl/models.py index 33e76dc..09ead90 100644 --- a/lnurl/models.py +++ b/lnurl/models.py @@ -19,6 +19,7 @@ LnurlResponseTag, LnurlStatus, Lud17PayLink, + Lud25WithdrawLink, Max144Str, MilliSatoshi, Url, @@ -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") @@ -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: @@ -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): @@ -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": diff --git a/lnurl/types.py b/lnurl/types.py index 1f6d1b6..cc94754 100644 --- a/lnurl/types.py +++ b/lnurl/types.py @@ -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)] diff --git a/tests/test_core.py b/tests/test_core.py index 38ef78a..e059b5d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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, @@ -11,6 +22,7 @@ LnurlPaySuccessAction, LnurlSuccessResponse, LnurlWithdrawResponse, + LnurlWithdrawSuccessResponse, ) from lnurl.types import Lnurl @@ -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")] diff --git a/tests/test_models.py b/tests/test_models.py index 2ca21c1..0130f4c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -13,6 +13,7 @@ LnurlPayResponsePayerDataOption, LnurlSuccessResponse, LnurlWithdrawResponse, + LnurlWithdrawSuccessResponse, ) @@ -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( @@ -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): diff --git a/tests/test_models_from_dict.py b/tests/test_models_from_dict.py index e849e48..f02189c 100644 --- a/tests/test_models_from_dict.py +++ b/tests/test_models_from_dict.py @@ -14,6 +14,7 @@ LnurlResponse, LnurlSuccessResponse, LnurlWithdrawResponse, + LnurlWithdrawSuccessResponse, ) from lnurl.exceptions import LnurlResponseException @@ -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)