From 95ed17754d291c23b5b029534b1f784d1ed408f5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 07:34:25 +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 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())