From 95ed17754d291c23b5b029534b1f784d1ed408f5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 07:34:25 +0200 Subject: [PATCH 1/8] feat: register transport RPCs over LNbits nostr transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks the existing withdraw CRUD into the LNbits nostr transport layer so an HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL- withdraw links over kind-21000 encrypted events instead of HTTP. New `withdraw_start()` lifecycle hook (auto-invoked by the LNbits extension manager) imports the transport's `register_rpc` and registers four RPCs mirroring the Lightning.Pub `withdraw.*` contract exactly so lamassu-next's adapter can be a pure name-translation layer: lnurlw_create_link AUTH_WALLET lnurlw_get_link AUTH_WALLET lnurlw_update_link AUTH_WALLET lnurlw_delete_link AUTH_WALLET All handlers are thin shims around the existing crud.py functions — no business logic duplication. *_get / *_update / *_delete verify that the link's stored wallet matches the caller's wallet id. Also registers a link-owner resolver with the core subscriptions module (under tag "withdraw", extras-key "withdrawal_link_id" — the exact field name views_lnurl.py:144 stamps on payment.extra when a withdraw settles). That lets clients call `subscribe_payments({tag:"withdraw", link_id:...})` and stream real- time claim events without polling, with ownership enforced server-side. The transport import is guarded by try/except ImportError so this extension still loads cleanly against an LNbits build that doesn't have nostr_transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- __init__.py | 44 ++++++++++++++++- transport_rpcs.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 transport_rpcs.py diff --git a/__init__.py b/__init__.py index 28b04b9..93fc780 100644 --- a/__init__.py +++ b/__init__.py @@ -17,4 +17,46 @@ withdraw_ext.include_router(withdraw_ext_generic) withdraw_ext.include_router(withdraw_ext_api) withdraw_ext.include_router(withdraw_ext_lnurl) -__all__ = ["db", "withdraw_ext", "withdraw_static_files"] + +def withdraw_start() -> None: + """ + Register this extension's RPCs with the LNbits nostr transport so an + HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL-withdraw + links without touching the HTTP API. Also wires the link-owner + resolver so subscribe_payments({tag:"withdraw", link_id:...}) can + verify ownership. + + No-op if the core transport module isn't present in the LNbits build. + No runtime `if nostr_transport_enabled` guard is needed — when + disabled, the relay pool never publishes, so registered RPCs are + simply unreachable. + """ + try: + from lnbits.core.services.nostr_transport.dispatcher import ( + AUTH_WALLET, + register_rpc, + ) + from lnbits.core.services.nostr_transport.subscriptions import ( + register_link_owner_resolver, + ) + except ImportError: + return + + from .transport_rpcs import ( + handle_lnurlw_create_link, + handle_lnurlw_delete_link, + handle_lnurlw_get_link, + handle_lnurlw_update_link, + resolve_withdraw_owner, + ) + + register_rpc("lnurlw_create_link", handle_lnurlw_create_link, AUTH_WALLET) + register_rpc("lnurlw_get_link", handle_lnurlw_get_link, AUTH_WALLET) + register_rpc("lnurlw_update_link", handle_lnurlw_update_link, AUTH_WALLET) + register_rpc("lnurlw_delete_link", handle_lnurlw_delete_link, AUTH_WALLET) + register_link_owner_resolver( + "withdraw", resolve_withdraw_owner, link_extra_key="withdrawal_link_id" + ) + + +__all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"] diff --git a/transport_rpcs.py b/transport_rpcs.py new file mode 100644 index 0000000..e3bd212 --- /dev/null +++ b/transport_rpcs.py @@ -0,0 +1,119 @@ +""" +Nostr-transport RPC handlers for the withdraw (LNURL-withdraw) extension. + +Names mirror the Lightning.Pub `withdraw.*` contract that the lamassu-next +ATM consumes (see ~/dev/shocknet/lamassu-next/packages/lightning/src/client.ts +lines ~301–351). That keeps the lamassu-next-side adapter a pure name +translation — no semantic reshaping. + +Auth model (set in `__init__.py:withdraw_start`): +- create / get / update / delete → AUTH_WALLET; the calling pubkey must + own the wallet the link is scoped to. *_get / *_update / *_delete also + verify the link's stored `wallet` matches the caller's wallet id. + +`resolve_withdraw_owner` is registered with the core subscription module +under tag `"withdraw"` and extras-key `"withdrawal_link_id"` (matching +where the extension stamps the link id on settlement — see +`views_lnurl.py:144`). That lets `subscribe_payments({tag:"withdraw", +link_id:...})` enforce ownership without core importing this module. +""" + +from __future__ import annotations + +from lnbits.core.models.wallets import WalletTypeInfo +from lnbits.core.services.nostr_transport.models import NostrRpcRequest + +from .crud import ( + create_withdraw_link, + delete_withdraw_link, + get_withdraw_link, + update_withdraw_link, +) +from .models import CreateWithdrawData + + +async def handle_lnurlw_create_link( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + body = request.body or {} + data = CreateWithdrawData(**body) + link = await create_withdraw_link(data, auth.wallet.id) + return _to_dict(link) + + +async def handle_lnurlw_get_link( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + link = await _require_owned_link(link_id, auth.wallet.id) + return _to_dict(link) + + +async def handle_lnurlw_update_link( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + link = await _require_owned_link(link_id, auth.wallet.id) + body = request.body or {} + _MUTABLE = { + "title", + "min_withdrawable", + "max_withdrawable", + "uses", + "wait_time", + "is_unique", + "webhook_url", + "webhook_headers", + "webhook_body", + "custom_url", + "enabled", + } + for k, v in body.items(): + if k in _MUTABLE: + setattr(link, k, v) + updated = await update_withdraw_link(link) + return _to_dict(updated) + + +async def handle_lnurlw_delete_link( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + await _require_owned_link(link_id, auth.wallet.id) + await delete_withdraw_link(link_id) + return {"ok": True} + + +async def resolve_withdraw_owner(link_id: str) -> str | None: + """For the core subscription module: link_id -> wallet_id (or None).""" + link = await get_withdraw_link(link_id) + return link.wallet if link else None + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _require_id(request: NostrRpcRequest) -> str: + body = request.body or {} + link_id = body.get("id") + if not link_id: + raise ValueError("withdraw: body.id is required") + return str(link_id) + + +async def _require_owned_link(link_id: str, wallet_id: str): + link = await get_withdraw_link(link_id) + if link is None: + raise ValueError(f"withdraw: link not found: {link_id}") + if link.wallet != wallet_id: + raise PermissionError( + "withdraw: link does not belong to caller's wallet" + ) + return link + + +def _to_dict(link) -> dict: + import json + return json.loads(link.json()) From 82a6d4a89487eeddf31de3377c4b3af44bbc3747 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 09:45:40 +0200 Subject: [PATCH 2/8] feat: lnurlw_list_links + lnurlw_unique_hashes transport RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions surface withdraw-extension capabilities the ATM use case in aiolabs/lamassu-next (issues #24, #25) needs but couldn't reach over the nostr transport before: ## lnurlw_list_links (AUTH_ACCOUNT) Enumerate withdraw links across all wallets owned by the calling account, with `limit`/`offset` pagination matching the existing HTTP `/api/v1/links`. Lets an ATM (or any client) re-discover its links after a reconnect without having to keep its own index. If `request.wallet_id` is supplied and matches one of the account's wallets, narrows the listing to just that wallet — mirrors lnurlp's list semantics. Returns `{data: [...links], total: }`. ## lnurlw_unique_hashes (AUTH_WALLET) For an `is_unique=True` link, return the per-use `id_unique_hash` values derived from each unredeemed slot in `link.usescsv`. Mirrors the formula in `helpers.py:create_lnurl:13`: id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index) Without this RPC an ATM that wants to generate distinct QR codes per use (lamassu-next #25) had to reimplement the derivation client-side — fragile if the extension's hash format ever changes upstream. With this RPC the ATM asks the server for the canonical list of unredeemed hashes; each one becomes the trailing path component of `/withdraw/api/v1/lnurl//`. `is_unique=False` links return an empty `unredeemed_hashes` list; the base `unique_hash` alone identifies the callback path. Co-Authored-By: Claude Opus 4.7 (1M context) --- __init__.py | 5 +++ transport_rpcs.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/__init__.py b/__init__.py index 93fc780..159e280 100644 --- a/__init__.py +++ b/__init__.py @@ -33,6 +33,7 @@ def withdraw_start() -> None: """ try: from lnbits.core.services.nostr_transport.dispatcher import ( + AUTH_ACCOUNT, AUTH_WALLET, register_rpc, ) @@ -46,12 +47,16 @@ def withdraw_start() -> None: handle_lnurlw_create_link, handle_lnurlw_delete_link, handle_lnurlw_get_link, + handle_lnurlw_list_links, + handle_lnurlw_unique_hashes, handle_lnurlw_update_link, resolve_withdraw_owner, ) register_rpc("lnurlw_create_link", handle_lnurlw_create_link, AUTH_WALLET) register_rpc("lnurlw_get_link", handle_lnurlw_get_link, AUTH_WALLET) + register_rpc("lnurlw_list_links", handle_lnurlw_list_links, AUTH_ACCOUNT) + register_rpc("lnurlw_unique_hashes", handle_lnurlw_unique_hashes, AUTH_WALLET) register_rpc("lnurlw_update_link", handle_lnurlw_update_link, AUTH_WALLET) register_rpc("lnurlw_delete_link", handle_lnurlw_delete_link, AUTH_WALLET) register_link_owner_resolver( diff --git a/transport_rpcs.py b/transport_rpcs.py index e3bd212..cfd8f63 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -20,6 +20,10 @@ link_id:...})` enforce ownership without core importing this module. from __future__ import annotations +from shortuuid import uuid + +from lnbits.core.crud.wallets import get_wallets +from lnbits.core.models import Account from lnbits.core.models.wallets import WalletTypeInfo from lnbits.core.services.nostr_transport.models import NostrRpcRequest @@ -27,6 +31,7 @@ from .crud import ( create_withdraw_link, delete_withdraw_link, get_withdraw_link, + get_withdraw_links, update_withdraw_link, ) from .models import CreateWithdrawData @@ -84,6 +89,92 @@ async def handle_lnurlw_delete_link( return {"ok": True} +async def handle_lnurlw_list_links( + auth: Account, request: NostrRpcRequest +) -> dict: + """List withdraw links across all wallets owned by the calling account. + Useful for ATMs to re-discover their links after a reconnect. + + Body fields: + - limit: int (0 means no limit; default 0) + - offset: int (default 0) + If `request.wallet_id` is set and is one of the caller's wallets, + narrow to just that wallet. + """ + body = request.body or {} + limit = int(body.get("limit") or 0) + offset = int(body.get("offset") or 0) + + wallets = await get_wallets(auth.id) + wallet_ids = [w.id for w in wallets] + if not wallet_ids: + return {"data": [], "total": 0} + if request.wallet_id and request.wallet_id in wallet_ids: + wallet_ids = [request.wallet_id] + + page = await get_withdraw_links(wallet_ids, limit, offset) + return { + "data": [_to_dict(link) for link in page.data], + "total": page.total, + } + + +async def handle_lnurlw_unique_hashes( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + """ + For a `is_unique=True` link, return the per-use `id_unique_hash` + values that the ATM uses to generate distinct QR codes — one per + unredeemed slot. Mirrors the formula in `helpers.py:create_lnurl` + exactly so an ATM never has to re-implement the derivation: + + id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index) + + `link.usescsv` is the canonical list of *unredeemed* slot indexes; + after a customer claims a slot it gets removed there (see + `crud.remove_unique_withdraw_link`). The hashes returned here are + therefore exactly the ones still claimable. + + Response: + { + "link_id": str, + "unique_hash": str, # base hash + "is_unique": bool, + "unredeemed_hashes": [ # one entry per remaining slot + {"index": str, "id_unique_hash": str}, ... + ] + } + + For `is_unique=False` links the list is empty and `unique_hash` + alone identifies the callback path + (`/withdraw/api/v1/lnurl/`). For `is_unique=True` + each callback path is + `/withdraw/api/v1/lnurl//`. + """ + link_id = _require_id(request) + link = await _require_owned_link(link_id, auth.wallet.id) + + unredeemed = [] + if link.is_unique: + # usescsv is comma-separated; split and skip empties (after the + # last slot is consumed it becomes the empty string). + for index_str in [s for s in link.usescsv.split(",") if s.strip()]: + tohash = link.id + link.unique_hash + index_str + unredeemed.append( + { + "index": index_str.strip(), + "id_unique_hash": uuid(name=tohash), + } + ) + + return { + "link_id": link.id, + "unique_hash": link.unique_hash, + "is_unique": link.is_unique, + "unredeemed_hashes": unredeemed, + } + + async def resolve_withdraw_owner(link_id: str) -> str | None: """For the core subscription module: link_id -> wallet_id (or None).""" link = await get_withdraw_link(link_id) From e9d911e5934d31e9e1436d1e6adb7df5f33a73fe Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 20:01:09 +0200 Subject: [PATCH 3/8] fix: populate lnurl/lnurl_url in nostr-transport handlers (#1) The HTTP views populate `link.lnurl` and `link.lnurl_url` from `request.url_for(...)`; the nostr-transport RPC handlers had no `Request` and so left both fields as `None`. Consumers (ATMs over nostr) were forced to provision a separate `LNBITS_HTTP_URL` env var and compose the LNURL callback themselves. Add `helpers.create_lnurl_from_baseurl(link)` that mirrors `create_lnurl` but composes the callback URL from `settings.lnbits_baseurl` instead, and thread it through the create/get/update/list RPC handlers via a `_populate_lnurl` shim so the response shape matches the HTTP path. Encoding errors are swallowed (fields stay `None`) so a misconfigured baseurl falls back to current behavior rather than failing the RPC. Closes #1. --- helpers.py | 26 ++++++++++++++++++++++++++ transport_rpcs.py | 41 ++++++++++++++++++++++++++++------------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/helpers.py b/helpers.py index 51eb948..31efcff 100644 --- a/helpers.py +++ b/helpers.py @@ -1,4 +1,5 @@ from fastapi import Request +from lnbits.settings import settings from lnurl import Lnurl from lnurl import encode as lnurl_encode from shortuuid import uuid @@ -26,3 +27,28 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: f"Error creating LNURL with url: `{url!s}`, " "check your webserver proxy configuration." ) from e + + +def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: + """ + Same shape as `create_lnurl`, but composes the callback URL from + `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by + the nostr-transport RPC handlers, which have no HTTP request to + derive a base URL from. + """ + base = settings.lnbits_baseurl.rstrip("/") + if link.is_unique: + usescssv = link.usescsv.split(",") + tohash = link.id + link.unique_hash + usescssv[link.number] + multihash = uuid(name=tohash) + url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}" + else: + url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" + + try: + return lnurl_encode(url) + except Exception as e: + raise ValueError( + f"Error creating LNURL with url: `{url!s}`, " + "check your `LNBITS_BASEURL` configuration." + ) from e diff --git a/transport_rpcs.py b/transport_rpcs.py index cfd8f63..193d7c3 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -20,12 +20,11 @@ link_id:...})` enforce ownership without core importing this module. from __future__ import annotations -from shortuuid import uuid - from lnbits.core.crud.wallets import get_wallets from lnbits.core.models import Account from lnbits.core.models.wallets import WalletTypeInfo from lnbits.core.services.nostr_transport.models import NostrRpcRequest +from shortuuid import uuid from .crud import ( create_withdraw_link, @@ -34,7 +33,8 @@ from .crud import ( get_withdraw_links, update_withdraw_link, ) -from .models import CreateWithdrawData +from .helpers import create_lnurl_from_baseurl +from .models import CreateWithdrawData, WithdrawLink async def handle_lnurlw_create_link( @@ -43,7 +43,7 @@ async def handle_lnurlw_create_link( body = request.body or {} data = CreateWithdrawData(**body) link = await create_withdraw_link(data, auth.wallet.id) - return _to_dict(link) + return _to_dict(_populate_lnurl(link)) async def handle_lnurlw_get_link( @@ -51,7 +51,7 @@ async def handle_lnurlw_get_link( ) -> dict: link_id = _require_id(request) link = await _require_owned_link(link_id, auth.wallet.id) - return _to_dict(link) + return _to_dict(_populate_lnurl(link)) async def handle_lnurlw_update_link( @@ -77,7 +77,7 @@ async def handle_lnurlw_update_link( if k in _MUTABLE: setattr(link, k, v) updated = await update_withdraw_link(link) - return _to_dict(updated) + return _to_dict(_populate_lnurl(updated)) async def handle_lnurlw_delete_link( @@ -89,9 +89,7 @@ async def handle_lnurlw_delete_link( return {"ok": True} -async def handle_lnurlw_list_links( - auth: Account, request: NostrRpcRequest -) -> dict: +async def handle_lnurlw_list_links(auth: Account, request: NostrRpcRequest) -> dict: """List withdraw links across all wallets owned by the calling account. Useful for ATMs to re-discover their links after a reconnect. @@ -114,7 +112,7 @@ async def handle_lnurlw_list_links( page = await get_withdraw_links(wallet_ids, limit, offset) return { - "data": [_to_dict(link) for link in page.data], + "data": [_to_dict(_populate_lnurl(link)) for link in page.data], "total": page.total, } @@ -199,12 +197,29 @@ async def _require_owned_link(link_id: str, wallet_id: str): if link is None: raise ValueError(f"withdraw: link not found: {link_id}") if link.wallet != wallet_id: - raise PermissionError( - "withdraw: link does not belong to caller's wallet" - ) + raise PermissionError("withdraw: link does not belong to caller's wallet") + return link + + +def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: + """ + Compose `lnurl` / `lnurl_url` from `settings.lnbits_baseurl` so + nostr-transport responses match the HTTP `views_api` shape, where + these fields are populated from `request.url_for(...)`. Without + this, consumers (ATMs, etc.) would have to re-derive the callback + URL themselves from a separately-provisioned LNbits HTTPS URL — + duplicating state LNbits already knows. See aiolabs/withdraw#1. + """ + try: + encoded = create_lnurl_from_baseurl(link) + link.lnurl = str(encoded.bech32) + link.lnurl_url = str(encoded.url) + except ValueError: + pass return link def _to_dict(link) -> dict: import json + return json.loads(link.json()) From 66026abe962ea329ba778f35f7093ce70c9c4ec3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 21:14:48 +0200 Subject: [PATCH 4/8] fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `python-lnurl`'s `lnurl_encode` rejects HTTP URLs whose host isn't `localhost`/`127.0.0.1`/`.onion`, so a regtest LNbits on a LAN IP (e.g. `http://192.168.0.32:5001`) made `_populate_lnurl` swallow `InvalidUrl` and leave `link.lnurl=None` — breaking the LAN-local cross-device smoke flow. Extend the existing localhost carve-out to the full RFC1918 set: loopback, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. These are intrinsically unreachable from the public internet, so producing an HTTP LNURL pointing at one is unambiguously a dev/internal scenario. For matching URLs, skip `lnurl_encode`'s host validation by calling the public `lnurl.helpers.url_encode` directly (which bech32-encodes without URL validation). Everything else still goes through the validated path — production with HTTP + public IP/hostname stays rejected. `create_lnurl_from_baseurl` now returns `(bech32, url)` directly rather than a `Lnurl` instance, since the private-network branch can't construct a real `Lnurl` (its `__new__` re-runs the same host validation on bech32-decode). The caller `_populate_lnurl` was the only consumer. Test coverage on `_is_private_network_http` covers the carve-out boundary (loopback, RFC1918, the just-outside-RFC1918 ranges, public hosts, and the `https://` case). The full encode path is exercised via regtest smoke. Closes #2. --- helpers.py | 41 ++++++++++++++++++++++++-- tests/test_helpers.py | 67 +++++++++++++++++++++++++++++++++++++++++++ transport_rpcs.py | 4 +-- 3 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 tests/test_helpers.py diff --git a/helpers.py b/helpers.py index 31efcff..7a2bba5 100644 --- a/helpers.py +++ b/helpers.py @@ -1,7 +1,11 @@ +import ipaddress +from urllib.parse import urlparse + from fastapi import Request from lnbits.settings import settings from lnurl import Lnurl from lnurl import encode as lnurl_encode +from lnurl.helpers import url_encode from shortuuid import uuid from .models import WithdrawLink @@ -29,12 +33,41 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: ) from e -def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: +def _is_private_network_http(url: str) -> bool: + """True iff `url` is `http://` AND the host is loopback, `localhost`, + or in an RFC1918 private range. These are intrinsically unreachable + from the public internet, so producing an HTTP LNURL pointing at one + is a dev/internal-test scenario, not a production misconfig. + + Extends python-lnurl's existing `localhost` / `127.0.0.1` carve-out + (see `lnurl.types.INSECURE_HOSTS`) to the full RFC1918 set so + cross-device regtest smoke against a LAN-IP LNbits works without + standing up TLS termination. + """ + parsed = urlparse(url) + if parsed.scheme != "http": + return False + host = (parsed.hostname or "").lower() + if host == "localhost": + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + return ip.is_loopback or ip.is_private + + +def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: """ Same shape as `create_lnurl`, but composes the callback URL from `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by the nostr-transport RPC handlers, which have no HTTP request to derive a base URL from. + + Returns `(bech32, url)` — the two fields `_populate_lnurl` writes + onto `WithdrawLink.lnurl` / `lnurl_url`. LAN-local HTTP URLs + (loopback / RFC1918) skip python-lnurl's HTTPS-required check via + the public `lnurl.helpers.url_encode` helper; see #2. """ base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: @@ -45,10 +78,14 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" + if _is_private_network_http(url): + return url_encode(url), url + try: - return lnurl_encode(url) + encoded = lnurl_encode(url) except Exception as e: raise ValueError( f"Error creating LNURL with url: `{url!s}`, " "check your `LNBITS_BASEURL` configuration." ) from e + return str(encoded.bech32), str(encoded.url) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..01e1e69 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,67 @@ +""" +Unit tests for the private-network HTTP detector that gates the +plain-HTTP carve-out in `create_lnurl_from_baseurl`. See #2. + +The full encode path is exercised end-to-end during regtest smoke; +these tests cover the pure host-classification logic so changes to +the carve-out boundary (e.g., adding `.onion`) can be regression-tested +without spinning up a wallet/settings fixture. +""" + +import pytest + +from ..helpers import _is_private_network_http + + +@pytest.mark.parametrize( + "url", + [ + "http://localhost/withdraw/api/v1/lnurl/abc", + "http://localhost:5000/withdraw/api/v1/lnurl/abc", + "http://127.0.0.1/withdraw/api/v1/lnurl/abc", + "http://127.0.0.1:5000/withdraw/api/v1/lnurl/abc", + "http://10.0.0.5:5000/withdraw/api/v1/lnurl/abc", + "http://172.16.0.5:5000/withdraw/api/v1/lnurl/abc", + "http://172.31.255.255:5000/withdraw/api/v1/lnurl/abc", + "http://192.168.0.32:5001/withdraw/api/v1/lnurl/abc", + "http://192.168.255.255/withdraw/api/v1/lnurl/abc", + ], +) +def test_is_private_network_http_accepts_loopback_and_rfc1918(url): + assert _is_private_network_http(url) is True + + +@pytest.mark.parametrize( + "url", + [ + # Public IPv4 + "http://8.8.8.8/withdraw/api/v1/lnurl/abc", + "http://1.1.1.1/withdraw/api/v1/lnurl/abc", + # Just-outside RFC1918 + "http://11.0.0.1/withdraw/api/v1/lnurl/abc", + "http://172.15.0.1/withdraw/api/v1/lnurl/abc", + "http://172.32.0.1/withdraw/api/v1/lnurl/abc", + "http://192.167.0.1/withdraw/api/v1/lnurl/abc", + "http://192.169.0.1/withdraw/api/v1/lnurl/abc", + # Public hostnames (not an IP literal, not localhost) + "http://example.com/withdraw/api/v1/lnurl/abc", + "http://lnbits.example.com/withdraw/api/v1/lnurl/abc", + ], +) +def test_is_private_network_http_rejects_public_hosts(url): + assert _is_private_network_http(url) is False + + +@pytest.mark.parametrize( + "url", + [ + "https://localhost/withdraw/api/v1/lnurl/abc", + "https://127.0.0.1/withdraw/api/v1/lnurl/abc", + "https://192.168.0.32/withdraw/api/v1/lnurl/abc", + "https://example.com/withdraw/api/v1/lnurl/abc", + ], +) +def test_is_private_network_http_rejects_https_scheme(url): + """Detector only fires for `http://`. `https://` always takes the + validated `lnurl_encode` path (which accepts any host).""" + assert _is_private_network_http(url) is False diff --git a/transport_rpcs.py b/transport_rpcs.py index 193d7c3..0fac4d6 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -211,9 +211,7 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: duplicating state LNbits already knows. See aiolabs/withdraw#1. """ try: - encoded = create_lnurl_from_baseurl(link) - link.lnurl = str(encoded.bech32) - link.lnurl_url = str(encoded.url) + link.lnurl, link.lnurl_url = create_lnurl_from_baseurl(link) except ValueError: pass return link From 40dce4d88c317a75068d0b8d89e16917b2a27baa Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 21:35:04 +0200 Subject: [PATCH 5/8] fix: extend RFC1918 LNURL carve-out to the HTTP-views path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 added the loopback/RFC1918 carve-out to the nostr-transport helper (`create_lnurl_from_baseurl`) but `views.py` / `views_api.py` still call `create_lnurl`, which went straight through `lnurl_encode` and got the same `InvalidUrl` rejection. Visible as a 500 "Error creating LNURL … check your webserver proxy configuration." on the admin UI when LNbits itself is on `http://192.168.x.x:port`. Extract the encode + carve-out logic into `_encode_lnurl(url, hint)` and route both `create_lnurl` and `create_lnurl_from_baseurl` through it. Both now return the same `_EncodedLnurl` dataclass (a minimal duck for `.bech32`/`.url`) — `Lnurl` itself can't be returned in the LAN-local case because its `__new__` re-runs python-lnurl's host validation on bech32-decode. Call sites in views.py / views_api.py unchanged: they already access `.bech32` and `.url`, which the dataclass exposes. `_populate_lnurl` back to attribute access too. --- helpers.py | 85 ++++++++++++++++++++++++++--------------------- transport_rpcs.py | 4 ++- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/helpers.py b/helpers.py index 7a2bba5..b975f22 100644 --- a/helpers.py +++ b/helpers.py @@ -1,9 +1,9 @@ import ipaddress +from dataclasses import dataclass from urllib.parse import urlparse from fastapi import Request from lnbits.settings import settings -from lnurl import Lnurl from lnurl import encode as lnurl_encode from lnurl.helpers import url_encode from shortuuid import uuid @@ -11,26 +11,18 @@ from shortuuid import uuid from .models import WithdrawLink -def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: - if link.is_unique: - usescssv = link.usescsv.split(",") - tohash = link.id + link.unique_hash + usescssv[link.number] - multihash = uuid(name=tohash) - url = req.url_for( - "withdraw.api_lnurl_multi_response", - unique_hash=link.unique_hash, - id_unique_hash=multihash, - ) - else: - url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) +@dataclass(frozen=True) +class _EncodedLnurl: + """Minimal duck-typed stand-in for `lnurl.Lnurl` exposing just the + `.bech32` and `.url` attributes the callers (views.py, views_api.py, + transport_rpcs.py) read. We can't always return a real `Lnurl` — + its `__new__` re-runs python-lnurl's HTTPS-required host check on + bech32-decode, which rejects RFC1918/loopback HTTP URLs even after + we've intentionally encoded one. See #2. + """ - try: - return lnurl_encode(str(url)) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, " - "check your webserver proxy configuration." - ) from e + bech32: str + url: str def _is_private_network_http(url: str) -> bool: @@ -57,17 +49,46 @@ def _is_private_network_http(url: str) -> bool: return ip.is_loopback or ip.is_private -def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: +def _encode_lnurl(url: str, error_hint: str) -> _EncodedLnurl: + """Bech32-encode `url` as an LNURL. LAN-local HTTP URLs (loopback / + RFC1918) skip python-lnurl's HTTPS-required host validation via the + public `lnurl.helpers.url_encode` helper. Everything else goes + through the validated `lnurl_encode` path. See #2. + """ + if _is_private_network_http(url): + return _EncodedLnurl(bech32=url_encode(url), url=url) + + try: + encoded = lnurl_encode(url) + except Exception as e: + raise ValueError( + f"Error creating LNURL with url: `{url!s}`, {error_hint}" + ) from e + return _EncodedLnurl(bech32=str(encoded.bech32), url=str(encoded.url)) + + +def create_lnurl(link: WithdrawLink, req: Request) -> _EncodedLnurl: + if link.is_unique: + usescssv = link.usescsv.split(",") + tohash = link.id + link.unique_hash + usescssv[link.number] + multihash = uuid(name=tohash) + url = req.url_for( + "withdraw.api_lnurl_multi_response", + unique_hash=link.unique_hash, + id_unique_hash=multihash, + ) + else: + url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) + + return _encode_lnurl(str(url), "check your webserver proxy configuration.") + + +def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl: """ Same shape as `create_lnurl`, but composes the callback URL from `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by the nostr-transport RPC handlers, which have no HTTP request to derive a base URL from. - - Returns `(bech32, url)` — the two fields `_populate_lnurl` writes - onto `WithdrawLink.lnurl` / `lnurl_url`. LAN-local HTTP URLs - (loopback / RFC1918) skip python-lnurl's HTTPS-required check via - the public `lnurl.helpers.url_encode` helper; see #2. """ base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: @@ -78,14 +99,4 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" - if _is_private_network_http(url): - return url_encode(url), url - - try: - encoded = lnurl_encode(url) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, " - "check your `LNBITS_BASEURL` configuration." - ) from e - return str(encoded.bech32), str(encoded.url) + return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.") diff --git a/transport_rpcs.py b/transport_rpcs.py index 0fac4d6..63db744 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -211,7 +211,9 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: duplicating state LNbits already knows. See aiolabs/withdraw#1. """ try: - link.lnurl, link.lnurl_url = create_lnurl_from_baseurl(link) + encoded = create_lnurl_from_baseurl(link) + link.lnurl = encoded.bech32 + link.lnurl_url = encoded.url except ValueError: pass return link From 0e06ab2087474c6b08b9ffb18f33be4f19f34532 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 21:43:37 +0200 Subject: [PATCH 6/8] Revert "fix: extend RFC1918 LNURL carve-out to the HTTP-views path" This reverts commit 40dce41. Going with TLS termination on the dev LNbits instead, so the RFC1918 carve-out becomes unnecessary. The lnbits-core `/api/v1/lnurlscan` consumer-side validator applies the same HTTPS-required rule python-lnurl enforces; carving the producer side out only got greg's LNURL generated, not redeemed. --- helpers.py | 85 +++++++++++++++++++++-------------------------- transport_rpcs.py | 4 +-- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/helpers.py b/helpers.py index b975f22..7a2bba5 100644 --- a/helpers.py +++ b/helpers.py @@ -1,9 +1,9 @@ import ipaddress -from dataclasses import dataclass from urllib.parse import urlparse from fastapi import Request from lnbits.settings import settings +from lnurl import Lnurl from lnurl import encode as lnurl_encode from lnurl.helpers import url_encode from shortuuid import uuid @@ -11,18 +11,26 @@ from shortuuid import uuid from .models import WithdrawLink -@dataclass(frozen=True) -class _EncodedLnurl: - """Minimal duck-typed stand-in for `lnurl.Lnurl` exposing just the - `.bech32` and `.url` attributes the callers (views.py, views_api.py, - transport_rpcs.py) read. We can't always return a real `Lnurl` — - its `__new__` re-runs python-lnurl's HTTPS-required host check on - bech32-decode, which rejects RFC1918/loopback HTTP URLs even after - we've intentionally encoded one. See #2. - """ +def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: + if link.is_unique: + usescssv = link.usescsv.split(",") + tohash = link.id + link.unique_hash + usescssv[link.number] + multihash = uuid(name=tohash) + url = req.url_for( + "withdraw.api_lnurl_multi_response", + unique_hash=link.unique_hash, + id_unique_hash=multihash, + ) + else: + url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) - bech32: str - url: str + try: + return lnurl_encode(str(url)) + except Exception as e: + raise ValueError( + f"Error creating LNURL with url: `{url!s}`, " + "check your webserver proxy configuration." + ) from e def _is_private_network_http(url: str) -> bool: @@ -49,46 +57,17 @@ def _is_private_network_http(url: str) -> bool: return ip.is_loopback or ip.is_private -def _encode_lnurl(url: str, error_hint: str) -> _EncodedLnurl: - """Bech32-encode `url` as an LNURL. LAN-local HTTP URLs (loopback / - RFC1918) skip python-lnurl's HTTPS-required host validation via the - public `lnurl.helpers.url_encode` helper. Everything else goes - through the validated `lnurl_encode` path. See #2. - """ - if _is_private_network_http(url): - return _EncodedLnurl(bech32=url_encode(url), url=url) - - try: - encoded = lnurl_encode(url) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, {error_hint}" - ) from e - return _EncodedLnurl(bech32=str(encoded.bech32), url=str(encoded.url)) - - -def create_lnurl(link: WithdrawLink, req: Request) -> _EncodedLnurl: - if link.is_unique: - usescssv = link.usescsv.split(",") - tohash = link.id + link.unique_hash + usescssv[link.number] - multihash = uuid(name=tohash) - url = req.url_for( - "withdraw.api_lnurl_multi_response", - unique_hash=link.unique_hash, - id_unique_hash=multihash, - ) - else: - url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) - - return _encode_lnurl(str(url), "check your webserver proxy configuration.") - - -def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl: +def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: """ Same shape as `create_lnurl`, but composes the callback URL from `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by the nostr-transport RPC handlers, which have no HTTP request to derive a base URL from. + + Returns `(bech32, url)` — the two fields `_populate_lnurl` writes + onto `WithdrawLink.lnurl` / `lnurl_url`. LAN-local HTTP URLs + (loopback / RFC1918) skip python-lnurl's HTTPS-required check via + the public `lnurl.helpers.url_encode` helper; see #2. """ base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: @@ -99,4 +78,14 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl: else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" - return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.") + if _is_private_network_http(url): + return url_encode(url), url + + try: + encoded = lnurl_encode(url) + except Exception as e: + raise ValueError( + f"Error creating LNURL with url: `{url!s}`, " + "check your `LNBITS_BASEURL` configuration." + ) from e + return str(encoded.bech32), str(encoded.url) diff --git a/transport_rpcs.py b/transport_rpcs.py index 63db744..0fac4d6 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -211,9 +211,7 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: duplicating state LNbits already knows. See aiolabs/withdraw#1. """ try: - encoded = create_lnurl_from_baseurl(link) - link.lnurl = encoded.bech32 - link.lnurl_url = encoded.url + link.lnurl, link.lnurl_url = create_lnurl_from_baseurl(link) except ValueError: pass return link From 2877cf6b20dcea1f2f79d2ccffb7419ea9a6a8b7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 21:44:57 +0200 Subject: [PATCH 7/8] Revert "fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 66026ab. Closes #2 as resolved by switching the dev LNbits to TLS (self-signed cert) instead of carving out plain HTTP for RFC1918 hosts. With HTTPS the producer-side python-lnurl validation accepts any host, AND the lnbits-core consumer-side `lnurlscan` accepts it too — the symmetric problem the carve-out couldn't solve on its own. `create_lnurl_from_baseurl` (#1, `e9d911e`) is kept — it's orthogonal to the transport scheme and still wanted for the nostr-transport `lnurl=null` fix. --- helpers.py | 41 ++------------------------ tests/test_helpers.py | 67 ------------------------------------------- transport_rpcs.py | 4 ++- 3 files changed, 5 insertions(+), 107 deletions(-) delete mode 100644 tests/test_helpers.py diff --git a/helpers.py b/helpers.py index 7a2bba5..31efcff 100644 --- a/helpers.py +++ b/helpers.py @@ -1,11 +1,7 @@ -import ipaddress -from urllib.parse import urlparse - from fastapi import Request from lnbits.settings import settings from lnurl import Lnurl from lnurl import encode as lnurl_encode -from lnurl.helpers import url_encode from shortuuid import uuid from .models import WithdrawLink @@ -33,41 +29,12 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: ) from e -def _is_private_network_http(url: str) -> bool: - """True iff `url` is `http://` AND the host is loopback, `localhost`, - or in an RFC1918 private range. These are intrinsically unreachable - from the public internet, so producing an HTTP LNURL pointing at one - is a dev/internal-test scenario, not a production misconfig. - - Extends python-lnurl's existing `localhost` / `127.0.0.1` carve-out - (see `lnurl.types.INSECURE_HOSTS`) to the full RFC1918 set so - cross-device regtest smoke against a LAN-IP LNbits works without - standing up TLS termination. - """ - parsed = urlparse(url) - if parsed.scheme != "http": - return False - host = (parsed.hostname or "").lower() - if host == "localhost": - return True - try: - ip = ipaddress.ip_address(host) - except ValueError: - return False - return ip.is_loopback or ip.is_private - - -def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: +def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: """ Same shape as `create_lnurl`, but composes the callback URL from `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by the nostr-transport RPC handlers, which have no HTTP request to derive a base URL from. - - Returns `(bech32, url)` — the two fields `_populate_lnurl` writes - onto `WithdrawLink.lnurl` / `lnurl_url`. LAN-local HTTP URLs - (loopback / RFC1918) skip python-lnurl's HTTPS-required check via - the public `lnurl.helpers.url_encode` helper; see #2. """ base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: @@ -78,14 +45,10 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" - if _is_private_network_http(url): - return url_encode(url), url - try: - encoded = lnurl_encode(url) + return lnurl_encode(url) except Exception as e: raise ValueError( f"Error creating LNURL with url: `{url!s}`, " "check your `LNBITS_BASEURL` configuration." ) from e - return str(encoded.bech32), str(encoded.url) diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index 01e1e69..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Unit tests for the private-network HTTP detector that gates the -plain-HTTP carve-out in `create_lnurl_from_baseurl`. See #2. - -The full encode path is exercised end-to-end during regtest smoke; -these tests cover the pure host-classification logic so changes to -the carve-out boundary (e.g., adding `.onion`) can be regression-tested -without spinning up a wallet/settings fixture. -""" - -import pytest - -from ..helpers import _is_private_network_http - - -@pytest.mark.parametrize( - "url", - [ - "http://localhost/withdraw/api/v1/lnurl/abc", - "http://localhost:5000/withdraw/api/v1/lnurl/abc", - "http://127.0.0.1/withdraw/api/v1/lnurl/abc", - "http://127.0.0.1:5000/withdraw/api/v1/lnurl/abc", - "http://10.0.0.5:5000/withdraw/api/v1/lnurl/abc", - "http://172.16.0.5:5000/withdraw/api/v1/lnurl/abc", - "http://172.31.255.255:5000/withdraw/api/v1/lnurl/abc", - "http://192.168.0.32:5001/withdraw/api/v1/lnurl/abc", - "http://192.168.255.255/withdraw/api/v1/lnurl/abc", - ], -) -def test_is_private_network_http_accepts_loopback_and_rfc1918(url): - assert _is_private_network_http(url) is True - - -@pytest.mark.parametrize( - "url", - [ - # Public IPv4 - "http://8.8.8.8/withdraw/api/v1/lnurl/abc", - "http://1.1.1.1/withdraw/api/v1/lnurl/abc", - # Just-outside RFC1918 - "http://11.0.0.1/withdraw/api/v1/lnurl/abc", - "http://172.15.0.1/withdraw/api/v1/lnurl/abc", - "http://172.32.0.1/withdraw/api/v1/lnurl/abc", - "http://192.167.0.1/withdraw/api/v1/lnurl/abc", - "http://192.169.0.1/withdraw/api/v1/lnurl/abc", - # Public hostnames (not an IP literal, not localhost) - "http://example.com/withdraw/api/v1/lnurl/abc", - "http://lnbits.example.com/withdraw/api/v1/lnurl/abc", - ], -) -def test_is_private_network_http_rejects_public_hosts(url): - assert _is_private_network_http(url) is False - - -@pytest.mark.parametrize( - "url", - [ - "https://localhost/withdraw/api/v1/lnurl/abc", - "https://127.0.0.1/withdraw/api/v1/lnurl/abc", - "https://192.168.0.32/withdraw/api/v1/lnurl/abc", - "https://example.com/withdraw/api/v1/lnurl/abc", - ], -) -def test_is_private_network_http_rejects_https_scheme(url): - """Detector only fires for `http://`. `https://` always takes the - validated `lnurl_encode` path (which accepts any host).""" - assert _is_private_network_http(url) is False diff --git a/transport_rpcs.py b/transport_rpcs.py index 0fac4d6..193d7c3 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -211,7 +211,9 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: duplicating state LNbits already knows. See aiolabs/withdraw#1. """ try: - link.lnurl, link.lnurl_url = create_lnurl_from_baseurl(link) + encoded = create_lnurl_from_baseurl(link) + link.lnurl = str(encoded.bech32) + link.lnurl_url = str(encoded.url) except ValueError: pass return link From 9c0e58a87ccb125234b8d77911af2e97eeb8fc59 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 17:26:14 +0200 Subject: [PATCH 8/8] feat: merge a link's `extra` into the payout payment (v1.2.2-aio.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `extra` (JSON) field to a withdraw link. When the link is claimed, that `extra` is merged onto the payout payment's `extra`, so a caller can tag the resulting payment with metadata an external listener keys on — the link is the only place to attach it (the customer-facing LNURL-withdraw payout otherwise carries just `{tag, withdrawal_link_id}`). Motivating use: bitSpire cash-in settlements. The operator's spirekeeper listener fires a `cash_in` settlement (fee split to the platform) only on an outbound payment stamped `source=bitspire`; before this there was no way to stamp an LNURL-withdraw payout, so cash-ins never settled. bitSpire now creates the cash-in link for the NET amount with `extra={source, type:cash_in, principal_sats, fee_sats, ...}` and the settlement fires on claim. - models: `extra: dict | None` on CreateWithdrawData + WithdrawLink. LNbits' db layer (de)serializes dict columns to/from JSON natively (same as Payment.extra) — no per-field validator needed. - migrations_fork.py: `withdraw_link.extra TEXT` under `withdraw_fork`, keeping the upstream-tracked migrations.py byte-identical for clean rebases (aiolabs/lnbits#8 pattern). - views_lnurl: `extra={**(link.extra or {}), "tag": ..., "withdrawal_link_id": ...}` — the withdraw extension's own keys are written last so a caller cannot clobber them. Verified end-to-end on the dev stack: a stamped link's payout carries the merged extra and drives a spirekeeper cash_in settlement + super-fee payout. --- config.json | 2 +- crud.py | 1 + migrations_fork.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ models.py | 10 ++++++++++ views_lnurl.py | 11 ++++++++++- 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 migrations_fork.py diff --git a/config.json b/config.json index de2cba5..e54edf5 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "name": "Withdraw Links", "short_description": "Make LNURL withdraw links", "tile": "/withdraw/static/image/lnurl-withdraw.png", - "version": "1.2.2", + "version": "1.2.2-aio.2", "min_lnbits_version": "1.3.0", "contributors": [ { diff --git a/crud.py b/crud.py index b914ae5..73966a5 100644 --- a/crud.py +++ b/crud.py @@ -32,6 +32,7 @@ async def create_withdraw_link( webhook_headers=data.webhook_headers, webhook_body=data.webhook_body, custom_url=data.custom_url, + extra=data.extra, number=0, ) await db.insert("withdraw.withdraw_link", withdraw_link) diff --git a/migrations_fork.py b/migrations_fork.py new file mode 100644 index 0000000..1caf27c --- /dev/null +++ b/migrations_fork.py @@ -0,0 +1,44 @@ +""" +Fork-specific database migrations for the aiolabs withdraw extension. + +These migrations are tracked separately under `withdraw_fork` in the +`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`), +so they do not collide with upstream's `m{NNN}_*` numbering in +`migrations.py`. Keeping the upstream-tracked file untouched means +`git pull upstream` stays rebase-clean for schema changes. + +Conventions: + - Sequential numbering starting from m001. + - Each migration is `async def m{NNN}_(db)`. + - DDL must be idempotent: a fresh install runs every migration; an + install that already carries the column must not crash. Use + `_alter_add_column_safe` so re-runs are no-ops. +""" + + +async def _alter_add_column_safe(db, sql: str) -> None: + """ALTER TABLE ADD COLUMN that swallows duplicate-column errors, so a + re-run on a DB that already has the column is a silent no-op.""" + try: + await db.execute(sql) + except Exception as exc: + msg = str(exc).lower() + if "duplicate column" in msg or "already exists" in msg: + return + raise + + +async def m001_aio_withdraw_schema(db): + """ + Apply every aiolabs schema delta on top of upstream withdraw. + + `withdraw_link.extra` — arbitrary JSON merged into the payout payment's + `extra` when the link is claimed (see views_lnurl). Lets a caller tag the + resulting payment with settlement/attribution metadata an external listener + can key on — e.g. bitSpire stamps {source, type, principal_sats, fee_sats, + ...} so the spirekeeper cash-in settlement fires off an LNURL-withdraw + payout. Stored as TEXT; (de)serialized to a dict by the WithdrawLink model. + """ + await _alter_add_column_safe( + db, "ALTER TABLE withdraw.withdraw_link ADD COLUMN extra TEXT" + ) diff --git a/models.py b/models.py index e888cdf..2f8ae48 100644 --- a/models.py +++ b/models.py @@ -16,6 +16,12 @@ class CreateWithdrawData(BaseModel): webhook_body: str = Query(None) custom_url: str = Query(None) enabled: bool = Query(True) + # Arbitrary JSON merged into the payout payment's `extra` when this link is + # claimed (see views_lnurl). Lets a caller tag the resulting payment with + # settlement/attribution metadata an external listener can key on — e.g. + # bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the + # spirekeeper cash-in settlement fires off an LNURL-withdraw payout. + extra: dict | None = None class WithdrawLink(BaseModel): @@ -37,6 +43,10 @@ class WithdrawLink(BaseModel): webhook_headers: str = Query(None) webhook_body: str = Query(None) custom_url: str = Query(None) + # Persisted as TEXT (JSON); merged into the payout payment's `extra` on + # claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON + # natively (same as Payment.extra) — no per-field validator needed. + extra: dict | None = None created_at: datetime enabled: bool = Query(True) lnurl: str | None = Field( diff --git a/views_lnurl.py b/views_lnurl.py index f893427..3be8182 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -141,7 +141,16 @@ async def api_lnurl_callback( wallet_id=link.wallet, payment_request=pr, max_sat=link.max_withdrawable, - extra={"tag": "withdraw", "withdrawal_link_id": link.id}, + # Merge the link's caller-supplied `extra` onto the payout so an + # external listener can key on it (e.g. bitSpire cash-in + # settlements via spirekeeper). The withdraw extension's own + # `tag`/`withdrawal_link_id` are written last so a caller cannot + # clobber them. + extra={ + **(link.extra or {}), + "tag": "withdraw", + "withdrawal_link_id": link.id, + }, ) await increment_withdraw_link(link) # If the payment succeeds, delete the record with the unique_hash.