feat: register transport RPCs over LNbits nostr transport
Some checks failed
lint.yml / feat: register transport RPCs over LNbits nostr transport (push) Failing after 0s
Some checks failed
lint.yml / feat: register transport RPCs over LNbits nostr transport (push) Failing after 0s
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) <noreply@anthropic.com>
This commit is contained in:
parent
d299e15c2f
commit
31cf2eb164
2 changed files with 187 additions and 0 deletions
151
transport_rpcs.py
Normal file
151
transport_rpcs.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue