diff --git a/__init__.py b/__init__.py index 39de3d8..d40a411 100644 --- a/__init__.py +++ b/__init__.py @@ -4,6 +4,7 @@ from fastapi import APIRouter from lnbits.tasks import create_permanent_unique_task from loguru import logger +from .cashin_transport import register_create_withdraw_rpc from .crud import db from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices @@ -13,9 +14,7 @@ from .views_api import spirekeeper_api_router logger.info("spirekeeper v2 loaded") -spirekeeper_ext: APIRouter = APIRouter( - prefix="/spirekeeper", tags=["DCA Admin"] -) +spirekeeper_ext: APIRouter = APIRouter(prefix="/spirekeeper", tags=["DCA Admin"]) spirekeeper_ext.include_router(spirekeeper_generic_router) spirekeeper_ext.include_router(spirekeeper_api_router) @@ -57,6 +56,12 @@ def spirekeeper_start(): # wallet, not an auto-created machine wallet. Soft-fails on lnbits # versions that don't yet expose `register_roster_resolver`. register_roster_with_lnbits() + # Secure cash-in (#31): register the `create_withdraw` nostr-transport RPC + # so the ATM requests a server-priced, server-stamped cash-in withdraw link + # over the bunker-signed transport — amount/fee/attribution derived + # server-side, never client-supplied. Soft-fails if `register_rpc` isn't + # exposed by this lnbits. + register_create_withdraw_rpc() __all__ = [ diff --git a/cashin_transport.py b/cashin_transport.py new file mode 100644 index 0000000..a912180 --- /dev/null +++ b/cashin_transport.py @@ -0,0 +1,132 @@ +""" +Secure cash-in: a `create_withdraw` nostr-transport RPC (aiolabs/spirekeeper#31). + +Mirrors withdraw's `lnurlw_create_link`, but cash-in-semantic. The ATM sends the +gross `principal_sats` (the hardware-attested fiat value) over the bunker-signed +kind-21000 transport; the operator side derives the fee, the NET withdraw +amount, and the attribution **server-side** — none of it client-supplied. The +customer claims the NET link; the payout carries the stamped `extra` +(aiolabs/withdraw#3) so `_handle_payment` records the `cash_in` settlement with +cryptographic attribution (the verified transport sender), exactly like the +cash-out path. + +Why this exists: `lnurlw_create_link` takes `min/max_withdrawable` and `extra` +straight from the client body, so an authenticated-but-malicious/buggy ATM could +set the gross amount (no fee), forge `nostr_sender_pubkey`, or request an +arbitrary amount. This RPC closes that by computing everything server-side. +""" + +from __future__ import annotations + +from loguru import logger + +from .crud import get_machine_by_atm_pubkey_hex, get_super_config + +_RPC_NAME = "create_withdraw" + + +async def handle_create_withdraw(auth, request) -> dict: + """nostr-transport RPC handler. `auth` is a WalletTypeInfo (the operator + wallet, roster-resolved from the verified sender); `request` is a + NostrRpcRequest with `body`, `sender_pubkey` (verified), and `event_id`.""" + # Import withdraw lazily so registration never hard-depends on the withdraw + # extension being importable at startup; a missing dep fails the request, + # not the daemon. + try: + from withdraw.crud import create_withdraw_link + from withdraw.helpers import create_lnurl_from_baseurl + from withdraw.models import CreateWithdrawData + except ImportError as exc: + raise ValueError( + "withdraw extension unavailable; cannot mint a cash-in link" + ) from exc + + body = request.body or {} + + principal_sats = body.get("principal_sats") + if not isinstance(principal_sats, int) or principal_sats <= 0: + raise ValueError("principal_sats must be a positive integer") + + # Attribution is the VERIFIED transport sender — never read it from the body. + sender = (request.sender_pubkey or "").lower() + if not sender: + raise ValueError("missing verified sender_pubkey") + + machine = await get_machine_by_atm_pubkey_hex(sender) + if machine is None: + raise ValueError("no active machine for this signer") + # Defence in depth: the roster already resolved `auth.wallet` from `sender`; + # confirm it's the machine's own wallet before minting a payout link on it. + if machine.wallet_id != auth.wallet.id: + raise ValueError("machine wallet does not match the authenticated wallet") + + super_config = await get_super_config() + if super_config is None: + raise ValueError("super_config not initialised") + + # Per-tx cap (server-side; the bunker ACL / usage caps cannot see sats). + cap = getattr(super_config, "max_cash_in_sats", None) + if cap is not None and principal_sats > cap: + raise ValueError( + f"principal_sats {principal_sats} exceeds max_cash_in_sats {cap}" + ) + + # Round each leg independently so the split matches parse_settlement exactly + # (platform_fee + operator_fee == fee_sats → fee_mismatch_sats = 0). + platform_fee = round( + principal_sats * float(super_config.super_cash_in_fee_fraction) + ) + operator_fee = round(principal_sats * float(machine.operator_cash_in_fee_fraction)) + fee_sats = platform_fee + operator_fee + net_sats = principal_sats - fee_sats + if net_sats <= 0: + raise ValueError("fee >= principal; nothing to withdraw") + + data = CreateWithdrawData( + title=body.get("title") or f"bitSpire Cash-In {machine.id}", + min_withdrawable=net_sats, + max_withdrawable=net_sats, + uses=1, + wait_time=int(body.get("wait_time") or 1), + is_unique=False, + extra={ + "source": "bitspire", + "type": "cash_in", + "principal_sats": principal_sats, + "fee_sats": fee_sats, + "nostr_sender_pubkey": sender, + "nostr_event_id": body.get("client_ref") or request.event_id, + }, + ) + link = await create_withdraw_link(data, auth.wallet.id) + lnurl = create_lnurl_from_baseurl(link) + logger.info( + f"spirekeeper: create_withdraw machine={machine.id} " + f"principal={principal_sats} fee={fee_sats} (super={platform_fee} " + f"op={operator_fee}) net={net_sats} link={link.id}" + ) + return { + "link_id": link.id, + "lnurl": str(lnurl), + "net_sats": net_sats, + "principal_sats": principal_sats, + "fee_sats": fee_sats, + } + + +def register_create_withdraw_rpc() -> None: + """Register `create_withdraw` with the lnbits nostr transport. Soft-fails if + the transport doesn't expose `register_rpc` (older lnbits).""" + try: + from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore + AUTH_WALLET, + register_rpc, + ) + except ImportError: + logger.warning( + "spirekeeper: nostr-transport register_rpc unavailable; " + "'create_withdraw' not registered (secure cash-in over RPC disabled)" + ) + return + register_rpc(_RPC_NAME, handle_create_withdraw, AUTH_WALLET) + logger.info("spirekeeper: registered nostr-transport RPC 'create_withdraw'")