diff --git a/__init__.py b/__init__.py index 159e280..28b04b9 100644 --- a/__init__.py +++ b/__init__.py @@ -17,51 +17,4 @@ withdraw_ext.include_router(withdraw_ext_generic) withdraw_ext.include_router(withdraw_ext_api) withdraw_ext.include_router(withdraw_ext_lnurl) - -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"] +__all__ = ["db", "withdraw_ext", "withdraw_static_files"] diff --git a/config.json b/config.json index e54edf5..de2cba5 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-aio.2", + "version": "1.2.2", "min_lnbits_version": "1.3.0", "contributors": [ { diff --git a/crud.py b/crud.py index 73966a5..b914ae5 100644 --- a/crud.py +++ b/crud.py @@ -32,7 +32,6 @@ 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 31efcff..51eb948 100644 --- a/helpers.py +++ b/helpers.py @@ -1,5 +1,4 @@ from fastapi import Request -from lnbits.settings import settings from lnurl import Lnurl from lnurl import encode as lnurl_encode from shortuuid import uuid @@ -27,28 +26,3 @@ 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 deleted file mode 100644 index 1caf27c..0000000 --- a/migrations_fork.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -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 2f8ae48..e888cdf 100644 --- a/models.py +++ b/models.py @@ -16,12 +16,6 @@ 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): @@ -43,10 +37,6 @@ 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 deleted file mode 100644 index 193d7c3..0000000 --- a/transport_rpcs.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -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 3be8182..f893427 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -141,16 +141,7 @@ async def api_lnurl_callback( wallet_id=link.wallet, payment_request=pr, max_sat=link.max_withdrawable, - # 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, - }, + extra={"tag": "withdraw", "withdrawal_link_id": link.id}, ) await increment_withdraw_link(link) # If the payment succeeds, delete the record with the unique_hash.