From 31cf2eb164c3513fc49511efb3c54b366701e37a Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 08:46:17 +0200 Subject: [PATCH] feat: register transport RPCs over LNbits nostr transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors what aiolabs/withdraw did — hooks lnurlp's existing CRUD into the LNbits nostr transport layer so an HTTP-allergic client (e.g. lamassu-next ATM) can manage PayLinks over kind-21000 encrypted events instead of HTTP. Extends the existing `lnurlp_start()` lifecycle hook (auto-invoked by the LNbits extension manager) to import the transport's `register_rpc` and register five RPCs: lnurlp_create AUTH_WALLET lnurlp_get AUTH_WALLET lnurlp_list AUTH_ACCOUNT lnurlp_update AUTH_WALLET lnurlp_delete 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 (tag "lnurlp", extras-key "link" — the default, matching where views_lnurl.py:86 stamps the link id on settlement). That lets clients call `subscribe_payments({tag:"lnurlp", link_id:...})` and stream real-time pay 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 | 36 +++++++++++ transport_rpcs.py | 151 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 transport_rpcs.py diff --git a/__init__.py b/__init__.py index 27ca96e..782315b 100644 --- a/__init__.py +++ b/__init__.py @@ -42,6 +42,42 @@ def lnurlp_start(): task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices) scheduled_tasks.append(task) + # Expose lnurlp's CRUD over the LNbits nostr transport so an HTTP- + # allergic client (e.g. lamassu-next ATM) can manage PayLinks over + # kind-21000 encrypted events. Also wires the link-owner resolver so + # `subscribe_payments({tag:"lnurlp", link_id:...})` can verify + # ownership of the underlying wallet. No-op if the core transport + # module isn't present in the LNbits build. + try: + from lnbits.core.services.nostr_transport.dispatcher import ( + AUTH_ACCOUNT, + AUTH_WALLET, + register_rpc, + ) + from lnbits.core.services.nostr_transport.subscriptions import ( + register_link_owner_resolver, + ) + except ImportError: + return + + from .transport_rpcs import ( + handle_lnurlp_create, + handle_lnurlp_delete, + handle_lnurlp_get, + handle_lnurlp_list, + handle_lnurlp_update, + resolve_lnurlp_owner, + ) + + register_rpc("lnurlp_create", handle_lnurlp_create, AUTH_WALLET) + register_rpc("lnurlp_get", handle_lnurlp_get, AUTH_WALLET) + register_rpc("lnurlp_list", handle_lnurlp_list, AUTH_ACCOUNT) + register_rpc("lnurlp_update", handle_lnurlp_update, AUTH_WALLET) + register_rpc("lnurlp_delete", handle_lnurlp_delete, AUTH_WALLET) + # lnurlp stamps `extra["link"] = link.id` on settlement + # (views_lnurl.py:86), which is the default extras-key, so no override. + register_link_owner_resolver("lnurlp", resolve_lnurlp_owner) + __all__ = [ "db", diff --git a/transport_rpcs.py b/transport_rpcs.py new file mode 100644 index 0000000..87f3f59 --- /dev/null +++ b/transport_rpcs.py @@ -0,0 +1,151 @@ +""" +Nostr-transport RPC handlers for the lnurlp (LNURL-pay) extension. + +Exposes the same CRUD surface that `views_api.py` exposes via HTTP, but +encrypted over kind-21000 events through the LNbits nostr transport. +Mirrors the withdraw extension's `transport_rpcs.py`; both extensions +hang their handlers off the core dispatcher via their `*_start()` hook. + +Auth model (set by the registrations in `__init__.py:lnurlp_start`): +- *_create / *_get / *_update / *_delete → AUTH_WALLET. The transport + resolves the caller's pubkey to a wallet (admin access) before + invoking the handler, so we know `auth.wallet.id` and `auth.wallet.user`. +- *_list → AUTH_ACCOUNT. The caller may list links across all wallets + they own, optionally narrowed by `request.wallet_id`. + +Ownership: *_get / *_update / *_delete also verify the link's stored +`wallet` field matches the caller's wallet id — defense in depth, since +a malicious client could otherwise probe link metadata they don't own. + +`resolve_lnurlp_owner` is registered with the core subscription module +under tag `"lnurlp"` (default link_extra_key `"link"` — that's where +`views_lnurl.py:86` stamps the link id on settlement). That lets clients +call `subscribe_payments({tag:"lnurlp", link_id:...})` and stream real- +time pay events without polling, with ownership enforced server-side. +""" + +from __future__ import annotations + +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 .crud import ( + create_pay_link, + delete_pay_link, + get_pay_link, + get_pay_links, + update_pay_link, +) +from .models import CreatePayLinkData + + +async def handle_lnurlp_create( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + body = request.body or {} + body["wallet"] = auth.wallet.id # always create under the calling wallet + data = CreatePayLinkData(**body) + link = await create_pay_link(data) + return _to_dict(link) + + +async def handle_lnurlp_get( + 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_lnurlp_list( + auth: Account, request: NostrRpcRequest +) -> list[dict]: + """List PayLinks across all wallets owned by the calling account. + If `request.wallet_id` is set and is one of those wallets, narrow to + just that wallet.""" + wallets = await get_wallets(auth.id) + wallet_ids = [w.id for w in wallets] + if not wallet_ids: + return [] + if request.wallet_id and request.wallet_id in wallet_ids: + wallet_ids = [request.wallet_id] + links = await get_pay_links(wallet_ids) + return [_to_dict(link) for link in links] + + +async def handle_lnurlp_update( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + link = await _require_owned_link(link_id, auth.wallet.id) + body = request.body or {} + # Only patchable fields. Identity / counter fields (id, wallet, + # served_meta, served_pr, created_at) are not client-mutable. + _MUTABLE = { + "description", + "min", + "max", + "comment_chars", + "currency", + "webhook_url", + "webhook_headers", + "webhook_body", + "success_text", + "success_url", + "fiat_base_multiplier", + "username", + "zaps", + "disposable", + "domain", + } + for k, v in body.items(): + if k in _MUTABLE: + setattr(link, k, v) + updated = await update_pay_link(link) + return _to_dict(updated) + + +async def handle_lnurlp_delete( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + await _require_owned_link(link_id, auth.wallet.id) + await delete_pay_link(link_id) + return {"ok": True} + + +async def resolve_lnurlp_owner(link_id: str) -> str | None: + """For the core subscription module: link_id -> wallet_id (or None).""" + link = await get_pay_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("lnurlp: body.id is required") + return str(link_id) + + +async def _require_owned_link(link_id: str, wallet_id: str): + link = await get_pay_link(link_id) + if link is None: + raise ValueError(f"lnurlp: link not found: {link_id}") + if link.wallet != wallet_id: + raise PermissionError( + "lnurlp: link does not belong to caller's wallet" + ) + return link + + +def _to_dict(link) -> dict: + import json + return json.loads(link.json())