From 74ae59813882dfda6364d3563b9828e23fd94f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 30 Mar 2026 10:48:26 +0200 Subject: [PATCH] feat: add tor support for fetching until now there was no way of handling lnurl from tor. this adds that feature and also adds a custom error when it could not connect to tor --- lnurl/core.py | 104 ++++++++++++++++++++++++++++++++++--------------- pyproject.toml | 2 +- uv.lock | 18 ++++++++- 3 files changed, 90 insertions(+), 34 deletions(-) diff --git a/lnurl/core.py b/lnurl/core.py index 5e15af5..76a80ec 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -25,6 +25,7 @@ ) from .types import CallbackUrl, LnAddress, Lnurl, Url +TOR_SOCKS = "socks5h://127.0.0.1:9050" USER_AGENT = "lnbits/lnurl" TIMEOUT = 5 @@ -49,13 +50,21 @@ async def get( response_class: Optional[Any] = None, user_agent: Optional[str] = None, timeout: Optional[int] = None, + tor_socks: Optional[str] = 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: + proxy = tor_socks or TOR_SOCKS if ".onion" in request_url else None + async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client: try: res = await client.get(request_url, timeout=timeout or TIMEOUT) res.raise_for_status() + except httpx.ConnectError as exc: + if proxy: + raise LnurlResponseException( + f"Failed to connect to {request_url} via Tor proxy {proxy}. Is Tor running?" + ) from exc + raise LnurlResponseException(f"Failed to connect to {request_url}") from exc except Exception as exc: raise LnurlResponseException(str(exc)) from exc @@ -77,6 +86,7 @@ async def handle( response_class: Optional[LnurlResponseModel] = None, user_agent: Optional[str] = None, timeout: Optional[int] = None, + tor_socks: Optional[str] = None, ) -> LnurlResponseModel: try: if "@" in lnurl: @@ -97,7 +107,9 @@ async def handle( 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) + return await get( + lnurl.url, response_class=response_class, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks + ) async def execute( @@ -105,18 +117,19 @@ async def execute( value: str, user_agent: Optional[str] = None, timeout: Optional[int] = None, + tor_socks: Optional[str] = None, ) -> LnurlResponseModel: try: - res = await handle(bech32_or_address, user_agent=user_agent, timeout=timeout) + res = await handle(bech32_or_address, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) 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) + return await execute_pay_request(res, int(value), user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) elif isinstance(res, LnurlAuthResponse) and res.tag == "login": - return await execute_login(res, value, user_agent=user_agent, timeout=timeout) + return await execute_login(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) elif isinstance(res, LnurlWithdrawResponse) and res.tag == "withdrawRequest": - return await execute_withdraw(res, value, user_agent=user_agent, timeout=timeout) + return await execute_withdraw(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) raise LnurlResponseException("tag not implemented") @@ -127,6 +140,7 @@ async def execute_pay_request( comment: Optional[str] = None, user_agent: Optional[str] = None, timeout: Optional[int] = None, + tor_socks: Optional[str] = None, ) -> LnurlPayActionResponse: if not res.minSendable <= MilliSatoshi(msat) <= res.maxSendable: raise LnurlResponseException(f"Amount {msat} not in range {res.minSendable} - {res.maxSendable}") @@ -140,13 +154,24 @@ async def execute_pay_request( try: headers = {"User-Agent": user_agent or USER_AGENT} - async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: - res2 = await client.get( - url=str(res.callback), - params=params, - timeout=timeout or TIMEOUT, - ) - res2.raise_for_status() + 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)) + pay_res = LnurlResponse.from_dict(res2.json()) if isinstance(pay_res, LnurlErrorResponse): raise LnurlResponseException(pay_res.reason) @@ -169,6 +194,7 @@ async def execute_login( signed_message: str | None = None, user_agent: Optional[str] = None, timeout: Optional[int] = None, + tor_socks: Optional[str] = None, ) -> LnurlResponseModel: if not res.callback: raise LnurlResponseException("LNURLauth callback does not exist") @@ -181,10 +207,11 @@ async def execute_login( linking_key, _ = lnurlauth_derive_linking_key_sign_message(domain=host, sig=signed_message.encode()) else: raise LnurlResponseException("Seed or signed_message is required for LNURLauth") - try: - key, sig = lnurlauth_signature(res.k1, linking_key=linking_key) - headers = {"User-Agent": user_agent or USER_AGENT} - async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: + 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: + try: res2 = await client.get( url=str(res.callback), params={ @@ -194,9 +221,16 @@ async def execute_login( timeout=timeout or TIMEOUT, ) res2.raise_for_status() - return LnurlResponse.from_dict(res2.json()) - except Exception as e: - raise LnurlResponseException(str(e)) + 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 e: + raise LnurlResponseException(str(e)) + + return LnurlResponse.from_dict(res2.json()) async def execute_withdraw( @@ -204,6 +238,7 @@ async def execute_withdraw( pr: str, user_agent: Optional[str] = None, timeout: Optional[int] = None, + tor_socks: Optional[str] = None, ) -> LnurlSuccessResponse: try: invoice = bolt11_decode(pr) @@ -213,9 +248,10 @@ 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}") - try: - headers = {"User-Agent": user_agent or USER_AGENT} - async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: + 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={ @@ -225,11 +261,17 @@ async def execute_withdraw( timeout=timeout or TIMEOUT, ) res2.raise_for_status() - withdraw_res = LnurlResponse.from_dict(res2.json()) - if isinstance(withdraw_res, LnurlErrorResponse): - raise LnurlResponseException(withdraw_res.reason) - if not isinstance(withdraw_res, LnurlSuccessResponse): - raise LnurlResponseException(f"Expected LnurlSuccessResponse, got {type(withdraw_res)}") - return withdraw_res - except Exception as exc: - raise LnurlResponseException(str(exc)) + 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)) + withdraw_res = LnurlResponse.from_dict(res2.json()) + if isinstance(withdraw_res, LnurlErrorResponse): + raise LnurlResponseException(withdraw_res.reason) + if not isinstance(withdraw_res, LnurlSuccessResponse): + raise LnurlResponseException(f"Expected LnurlSuccessResponse, got {type(withdraw_res)}") + return withdraw_res diff --git a/pyproject.toml b/pyproject.toml index 87592ea..71b22c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "bip32>=5.0.0", "bech32", "bolt11", - "httpx", + "httpx[socks]", "coincurve>=20.0.0", ] diff --git a/uv.lock b/uv.lock index c0b07b7..4d331f5 100644 --- a/uv.lock +++ b/uv.lock @@ -430,6 +430,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + [[package]] name = "identify" version = "2.6.16" @@ -506,7 +511,7 @@ dependencies = [ { name = "bip32" }, { name = "bolt11" }, { name = "coincurve" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "pycryptodomex" }, { name = "pydantic" }, ] @@ -529,7 +534,7 @@ requires-dist = [ { name = "bip32", specifier = ">=5.0.0" }, { name = "bolt11" }, { name = "coincurve", specifier = ">=20.0.0" }, - { name = "httpx" }, + { name = "httpx", extras = ["socks"] }, { name = "pycryptodomex", specifier = ">=3.21.0" }, { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, ] @@ -915,6 +920,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "tomli" version = "2.4.0"