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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ It will return the appropriate response for a LNURL.
LnurlPayResponse(tag='payRequest', callback=WebUrl('https://lnurl.bigsun.xyz/lnurl-pay/callback/2169831', scheme='https', host='lnurl.bigsun.xyz', tld='xyz', host_type='domain', path='/lnurl-pay/callback/2169831'), minSendable=10000, maxSendable=10000, metadata=LnurlPayMetadata('[["text/plain","NgHaEyaZNDnW iI DsFYdkI"],["image/png;base64","iVBOR...uQmCC"]]'))
```

All network helpers accept an optional `httpx.AsyncClient` through the `client` argument. The caller owns a provided
client and the library does not close it. The `user_agent`, `timeout`, and `tor_socks` arguments configure only clients
created by the library.

```python
async with httpx.AsyncClient(follow_redirects=False) as client:
response = await lnurl.handle(value, client=client)
```

You can execute and LNURL with either payRequest, withdrawRequest, addressRequest or login tag using the `execute` function.
```python
>>> import lnurl
Expand Down
125 changes: 99 additions & 26 deletions lnurl/core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from json import JSONDecodeError
from typing import Any, Optional

Expand Down Expand Up @@ -31,6 +33,28 @@
TIMEOUT = 5


@asynccontextmanager
async def _http_client(
client: Optional[httpx.AsyncClient],
*,
user_agent: Optional[str],
proxy: Optional[str],
timeout: Optional[int],
) -> AsyncIterator[httpx.AsyncClient]:
if client is not None:
yield client
return

headers = {"User-Agent": user_agent or USER_AGENT}
async with httpx.AsyncClient(
headers=headers,
follow_redirects=True,
proxy=proxy,
timeout=timeout or TIMEOUT,
) as default_client:
yield default_client


def decode(lnurl: str) -> Lnurl:
try:
return Lnurl(lnurl)
Expand All @@ -52,12 +76,12 @@ async def get(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlResponseModel:
headers = {"User-Agent": user_agent or USER_AGENT}
proxy = tor_socks or TOR_SOCKS if ".onion" in url else None
async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client:
async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client:
try:
res = await client.get(url, timeout=timeout or TIMEOUT)
res = await http_client.get(url)
res.raise_for_status()
except httpx.ConnectError as exc:
if proxy:
Expand Down Expand Up @@ -87,11 +111,19 @@ async def handle(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlResponseModel:
try:
if "@" in lnurl:
lnaddress = LnAddress(lnurl)
return await get(lnaddress.url, response_class=response_class, user_agent=user_agent, timeout=timeout)
return await get(
lnaddress.url,
response_class=response_class,
user_agent=user_agent,
timeout=timeout,
client=client,
)
lnurl = Lnurl(lnurl)
except (ValidationError, ValueError):
raise InvalidLnurl
Expand All @@ -101,7 +133,12 @@ async def handle(
return LnurlAuthResponse(callback=callback_url, k1=lnurl.url.query_params["k1"])

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


Expand All @@ -111,20 +148,56 @@ async def execute(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlResponseModel:
try:
res = await handle(bech32_or_address, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks)
res = await handle(
bech32_or_address,
user_agent=user_agent,
timeout=timeout,
tor_socks=tor_socks,
client=client,
)
except Exception as exc:
raise LnurlResponseException(str(exc))

if isinstance(res, LnurlPayResponse) and res.tag == "payRequest":
return await execute_pay_request(res, int(value), user_agent=user_agent, timeout=timeout, tor_socks=tor_socks)
return await execute_pay_request(
res,
int(value),
user_agent=user_agent,
timeout=timeout,
tor_socks=tor_socks,
client=client,
)
elif isinstance(res, LnurlAuthResponse) and res.tag == "login":
return await execute_login(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks)
return await execute_login(
res,
value,
user_agent=user_agent,
timeout=timeout,
tor_socks=tor_socks,
client=client,
)
elif isinstance(res, LnurlWithdrawResponse) and res.tag == "withdrawRequest":
return await execute_withdraw(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks)
return await execute_withdraw(
res,
value,
user_agent=user_agent,
timeout=timeout,
tor_socks=tor_socks,
client=client,
)
elif isinstance(res, LnurlAddressRequestResponse) and res.tag == "addressRequest":
return await execute_address_request(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks)
return await execute_address_request(
res,
value,
user_agent=user_agent,
timeout=timeout,
tor_socks=tor_socks,
client=client,
)

raise LnurlResponseException("tag not implemented")

Expand All @@ -136,6 +209,8 @@ async def execute_pay_request(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlPayActionResponse:
if not res.minSendable <= MilliSatoshi(msat) <= res.maxSendable:
raise LnurlResponseException(f"Amount {msat} not in range {res.minSendable} - {res.maxSendable}")
Expand All @@ -148,14 +223,12 @@ async def execute_pay_request(
params["comment"] = comment

try:
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:
async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client:
try:
res2 = await client.get(
res2 = await http_client.get(
url=str(res.callback),
params=params,
timeout=timeout or TIMEOUT,
)
res2.raise_for_status()
except httpx.ConnectError as exc:
Expand Down Expand Up @@ -190,6 +263,8 @@ async def execute_login(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlResponseModel:
if not res.callback:
raise LnurlResponseException("LNURLauth callback does not exist")
Expand All @@ -203,17 +278,15 @@ async def execute_login(
else:
raise LnurlResponseException("Seed or signed_message is required for LNURLauth")
key, sig = lnurlauth_signature(res.k1, linking_key=linking_key)
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:
async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client:
try:
res2 = await client.get(
res2 = await http_client.get(
url=res.callback,
params={
"key": key,
"sig": sig,
},
timeout=timeout or TIMEOUT,
)
res2.raise_for_status()
except httpx.ConnectError as exc:
Expand All @@ -234,6 +307,8 @@ async def execute_withdraw(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlSuccessResponse:
try:
invoice = bolt11_decode(pr)
Expand All @@ -243,17 +318,15 @@ async def execute_withdraw(
amount = invoice.amount_msat or res.minWithdrawable
if not res.minWithdrawable <= MilliSatoshi(amount) <= res.maxWithdrawable:
raise LnurlResponseException(f"Amount {amount} not in range {res.minWithdrawable} - {res.maxWithdrawable}")
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:
async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client:
try:
res2 = await client.get(
res2 = await http_client.get(
url=res.callback,
params={
"k1": res.k1,
"pr": pr,
},
timeout=timeout or TIMEOUT,
)
res2.raise_for_status()
except httpx.ConnectError as exc:
Expand All @@ -279,23 +352,23 @@ async def execute_address_request(
user_agent: Optional[str] = None,
timeout: Optional[int] = None,
tor_socks: Optional[str] = None,
*,
client: Optional[httpx.AsyncClient] = None,
) -> LnurlResponseModel:
try:
lnaddress = LnAddress(address)
except (ValidationError, ValueError, LnAddressError) as exc:
raise LnurlResponseException("Invalid Lightning address.") from exc

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:
async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client:
try:
res2 = await client.get(
res2 = await http_client.get(
url=str(res.callback),
params={
"k1": res.k1,
"address": lnaddress.address,
},
timeout=timeout or TIMEOUT,
)
res2.raise_for_status()
except httpx.ConnectError as exc:
Expand Down
45 changes: 45 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from unittest.mock import AsyncMock

import httpx
import pytest

import lnurl.core as core
from lnurl.core import decode, encode, execute_address_request, execute_login, execute_pay_request, get, handle
from lnurl.exceptions import InvalidLnurl, InvalidUrl, LnurlResponseException
from lnurl.models import (
Expand Down Expand Up @@ -106,6 +109,48 @@ async def test_get_requests_error(self, url):
with pytest.raises(LnurlResponseException):
await get(url)

@pytest.mark.asyncio
async def test_handle_uses_provided_client(self):
requests = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(302, headers={"Location": "http://127.0.0.1"})

client = httpx.AsyncClient(
transport=httpx.MockTransport(handler),
follow_redirects=False,
timeout=17,
)
try:
with pytest.raises(LnurlResponseException):
await handle("https://example.com", client=client)

assert len(requests) == 1
assert set(requests[0].extensions["timeout"].values()) == {17}
assert not client.is_closed
finally:
await client.aclose()

@pytest.mark.asyncio
async def test_execute_passes_provided_client_to_callback(self, monkeypatch):
class LoginResponse:
tag = "login"

response = LoginResponse()
expected = LnurlSuccessResponse()
handle_mock = AsyncMock(return_value=response)
login_mock = AsyncMock(return_value=expected)

async with httpx.AsyncClient() as client:
monkeypatch.setattr(core, "LnurlAuthResponse", LoginResponse)
monkeypatch.setattr(core, "handle", handle_mock)
monkeypatch.setattr(core, "execute_login", login_mock)

assert await core.execute("lnurl", "secret", client=client) is expected
assert handle_mock.await_args.kwargs["client"] is client
assert login_mock.await_args.kwargs["client"] is client


class TestPayFlow:
"""Full LNURL-pay flow interacting with https://demo.lnbits.com/"""
Expand Down
Loading