diff --git a/__init__.py b/__init__.py index 28b04b9..159e280 100644 --- a/__init__.py +++ b/__init__.py @@ -17,4 +17,51 @@ 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_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_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( + "withdraw", resolve_withdraw_owner, link_extra_key="withdrawal_link_id" + ) + + +__all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"] 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/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/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/transport_rpcs.py b/transport_rpcs.py new file mode 100644 index 0000000..193d7c3 --- /dev/null +++ b/transport_rpcs.py @@ -0,0 +1,225 @@ +""" +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.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, + delete_withdraw_link, + get_withdraw_link, + get_withdraw_links, + update_withdraw_link, +) +from .helpers import create_lnurl_from_baseurl +from .models import CreateWithdrawData, WithdrawLink + + +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(_populate_lnurl(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(_populate_lnurl(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(_populate_lnurl(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 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(_populate_lnurl(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) + 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 _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()) 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.