From 82a6d4a89487eeddf31de3377c4b3af44bbc3747 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 09:45:40 +0200 Subject: [PATCH] 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)