From 607b71e79687dfce63b04c4606c5cb0fe78446e8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 12:17:17 +0200 Subject: [PATCH 1/3] feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a server-side cash-in RPC so the ATM no longer supplies the withdraw amount, fee, or attribution. The ATM sends a bunker-signed kind-21000 `create_withdraw` with just the gross `principal_sats` (the hardware- attested fiat value); the handler derives everything else SERVER-SIDE: - attribution = the VERIFIED transport `sender_pubkey` (never read from the body), matched to an active machine on the authenticated wallet; - fee = round(principal × super_cash_in) + round(principal × operator_cash_in), per-leg rounding so it matches parse_settlement exactly (fee_mismatch=0); - net = principal − fee → the withdraw amount the customer receives; - stamps `extra={source:bitspire, type:cash_in, principal_sats, fee_sats, nostr_sender_pubkey:, nostr_event_id}` onto the link. The customer claims the NET link; the payout carries the stamped extra (aiolabs/withdraw#3) and `_handle_payment` records the cash_in settlement (spirekeeper#30) with cryptographic attribution — closing the vector where `lnurlw_create_link` let the ATM set amount/fee/attribution freely. Registered via `register_rpc("create_withdraw", …, AUTH_WALLET)` (extensions register RPCs directly — withdraw already does). Soft-fails on lnbits without `register_rpc`. Per-tx cap reads `super_config.max_cash_in_sats` defensively (getattr) — the config field/UI is a fast-follow. Wire schema pinned in #31. Depends on #30 (consumer-side settlement fix). --- __init__.py | 11 +++- cashin_transport.py | 132 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 cashin_transport.py 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'") -- 2.53.0 From 9abf695fd5f32dcbd8a593f7f2ad9d205fa9fc9a Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 12:51:59 +0200 Subject: [PATCH 2/3] feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the server-side per-transaction cash-in ceiling the `create_withdraw` handler already enforces (it read the value defensively via getattr; this makes it a first-class config field). - migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL = no cap). - models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a >= 0 validator. - super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends null (the PUT skips null, preserving the current value — set 0 to reject every cash-in). crud `update_super_config` and the PUT endpoint flow the field through automatically (dynamic dict update; check_super_user gated). Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call *rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a single in-rate call could request an arbitrarily large payout. This bounds a compromised/buggy machine to one capped transaction. Verified on the dev stack: m012 runs, the model round-trips the column (GET returns the set value), and a negative value is rejected. --- migrations.py | 15 +++++++++++++++ models.py | 13 +++++++++++++ static/js/index.js | 24 +++++++++++++++++++++--- templates/spirekeeper/index.html | 5 +++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/migrations.py b/migrations.py index d3bcece..6ede0eb 100644 --- a/migrations.py +++ b/migrations.py @@ -845,3 +845,18 @@ async def m011_machine_npub_nullable(db): "CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq " "ON dca_machines (wallet_id)" ) + + +async def m012_add_max_cash_in_sats(db): + """Server-side per-transaction cash-in ceiling (aiolabs/spirekeeper#31). + + The secure `create_withdraw` RPC derives fee/net/attribution server-side, + but `principal_sats` is necessarily ATM-attested (only the hardware knows + how much cash went in). The bunker ACL / usage caps gate call *rate*, not + *sats*, so a single in-rate call could request an arbitrarily large payout. + `max_cash_in_sats` bounds that: the handler rejects a cash-in whose + principal exceeds it. NULL = no cap. + """ + await db.execute( + "ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER" + ) diff --git a/models.py b/models.py index d779633..99ee552 100644 --- a/models.py +++ b/models.py @@ -482,6 +482,10 @@ class SuperConfig(BaseModel): super_cash_in_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None + # Per-transaction cash-in ceiling in sats (#31). The bunker ACL gates call + # rate, not sats, so this bounds a single ATM-attested principal. NULL = no + # cap. + max_cash_in_sats: int | None = None updated_at: datetime @@ -489,6 +493,7 @@ class UpdateSuperConfigData(BaseModel): super_cash_in_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None + max_cash_in_sats: int | None = None @validator( "super_cash_in_fee_fraction", @@ -501,6 +506,14 @@ class UpdateSuperConfigData(BaseModel): raise ValueError("super fee fraction must be between 0 and 1") return round(float(v), 4) + @validator("max_cash_in_sats") + def _cap_non_negative(cls, v): + if v is None: + return v + if v < 0: + raise ValueError("max_cash_in_sats must be >= 0") + return int(v) + # ============================================================================= # Operator UX action carriers — partial-tx and balance-settlement features. diff --git a/static/js/index.js b/static/js/index.js index c9878d0..053133d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -103,7 +103,8 @@ window.app = Vue.createApp({ data: { super_cash_in_fee_fraction: 0, super_cash_out_fee_fraction: 0, - super_fee_wallet_id: '' + super_fee_wallet_id: '', + max_cash_in_sats: null } }, @@ -576,7 +577,8 @@ window.app = Vue.createApp({ this.superConfig?.super_cash_in_fee_fraction ?? 0, super_cash_out_fee_fraction: this.superConfig?.super_cash_out_fee_fraction ?? 0, - super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' + super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '', + max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null } this.superFeeDialog.show = true }, @@ -604,6 +606,21 @@ window.app = Vue.createApp({ Number(d.super_cash_in_fee_fraction), Number(d.super_cash_out_fee_fraction) )) return + // Blank cap field -> null (the PUT skips null, so the existing value is + // preserved rather than cleared — set 0 to reject every cash-in). + const cap = + d.max_cash_in_sats === '' || + d.max_cash_in_sats === null || + d.max_cash_in_sats === undefined + ? null + : Number(d.max_cash_in_sats) + if (cap !== null && (!Number.isInteger(cap) || cap < 0)) { + Quasar.Notify.create({ + type: 'negative', + message: 'Max cash-in must be a non-negative whole number of sats' + }) + return + } this.superFeeDialog.saving = true try { const {data} = await LNbits.api.request( @@ -611,7 +628,8 @@ window.app = Vue.createApp({ { super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), - super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null + super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null, + max_cash_in_sats: cap } ) this.superConfig = data diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index 53c30d1..01bb973 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -1381,6 +1381,11 @@ label="Super fee destination wallet_id" hint="LNbits wallet that collects the platform fee" class="q-mb-md" dense outlined> + -- 2.53.0 From f67cb49bc36e78c08f94cc5e1934fb510324c089 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 15:32:44 +0200 Subject: [PATCH 3/3] fix(cash-in): return bech32 LNURL, not the raw URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Lnurl.__str__` is the underlying URL, so `str(lnurl)` returned `http:///withdraw/...` instead of the bech32 `LNURL1…` — wallets need the encoded LNURL-withdraw (lud01). Use `str(lnurl.bech32)` and add `lnurl_url` (the raw URL) alongside, mirroring withdraw's _populate_lnurl field convention. (Note: the encoded URL still derives from LNBITS_BASEURL — that must be an externally reachable https URL for a real wallet to claim.) --- cashin_transport.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cashin_transport.py b/cashin_transport.py index a912180..c041f48 100644 --- a/cashin_transport.py +++ b/cashin_transport.py @@ -107,7 +107,10 @@ async def handle_create_withdraw(auth, request) -> dict: ) return { "link_id": link.id, - "lnurl": str(lnurl), + # `Lnurl.__str__` is the raw URL — wallets need the bech32 LNURL1… + # (lud01). Mirror withdraw's `_populate_lnurl` field convention. + "lnurl": str(lnurl.bech32), + "lnurl_url": str(lnurl.url), "net_sats": net_sats, "principal_sats": principal_sats, "fee_sats": fee_sats, -- 2.53.0