feat(machine): secure cash-in via server-stamped create_withdraw RPC

Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw
RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested
gross principal_sats; the operator side verifies the signer, derives fee + NET,
and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED
sender. Closes the dev-stack weakness where the ATM set the withdraw amount +
extra itself (could understate the fee / forge attribution).

- LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?,
  title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats,
  fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped.
- lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the
  ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id
  (the secure response carries no unique_hash); settlement-watch half unchanged
  (subscribe_payments tag:'withdraw', link_id).

Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw),
so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod
build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-22 12:31:24 +02:00
commit 9c74a28a06
4 changed files with 96 additions and 43 deletions

View file

@ -42,6 +42,8 @@ import type {
PaymentPushCallback,
SubscriptionCloseCallback,
CreateWithdrawLinkBody,
CreateWithdrawBody,
CreateWithdrawResult,
LnbitsWithdrawLink,
UniqueHashesResponse,
} from './types.js'
@ -388,6 +390,19 @@ export class LnbitsClient {
return data
}
/**
* Cash-in: create a SERVER-STAMPED LNURL-withdraw via the secure
* `create_withdraw` RPC (aiolabs/spirekeeper#31 / #32). The ATM sends only the
* hardware-attested `principal_sats`; the operator side verifies the signer,
* derives fee + NET, and stamps the link's attribution from the verified
* sender — the machine cannot understate the fee or forge attribution. NOT
* idempotent (mints a link) → not retry-wrapped; supersedes the direct,
* client-amount `createWithdrawLink` for cash-in.
*/
async createWithdraw(walletId: string, body: CreateWithdrawBody): Promise<CreateWithdrawResult> {
return this.sendRpc<CreateWithdrawResult>('create_withdraw', { walletId, body })
}
async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
return this.idempotent(() =>
this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }),

View file

@ -73,6 +73,8 @@ export type {
PaymentPushCallback,
SubscriptionCloseCallback,
CreateWithdrawLinkBody,
CreateWithdrawBody,
CreateWithdrawResult,
LnbitsWithdrawLink,
UniqueHashEntry,
UniqueHashesResponse,

View file

@ -146,6 +146,45 @@ export interface SubscribeClose {
// LNURL-withdraw (the `withdraw` extension's transport surface)
// ============================================================================
/**
* Cash-in request for the SECURE `create_withdraw` RPC (aiolabs/spirekeeper#31).
* The ATM supplies only the hardware-attested gross principal; the operator
* side derives fee + NET and stamps attribution from the *verified* signer, so
* the machine cannot understate the fee or forge attribution. Contrast with
* `CreateWithdrawLinkBody`, where the amount + extra were client-supplied.
*/
export interface CreateWithdrawBody {
/** Gross principal in sats — the fiat value the ATM measured. REQUIRED. */
principal_sats: number
/** Fiat amount for the settlement row + display. */
fiat_amount?: number
/** Fiat code; defaults to the machine's configured currency server-side. */
fiat_code?: string
/** Link display title. */
title?: string
/** Seconds between withdraws (default 1). */
wait_time?: number
/** Audit ref → settlement.nostr_event_id (use the ATM tx id). */
client_ref?: string
}
/** Response from `create_withdraw` — server-derived amounts + the LNURL to show. */
export interface CreateWithdrawResult {
/** Settlement-watch key — `subscribe_payments { tag:'withdraw', link_id }`. */
link_id: string
/** bech32 LNURL — the QR the ATM displays. */
lnurl: string
/** Raw callback URL (alternative for QR generation). */
lnurl_url?: string
/** NET sats the customer receives (principal − fee). */
net_sats: number
/** Gross principal echoed back. */
principal_sats: number
/** Fee withheld (server-computed). */
fee_sats: number
k1?: string
}
export interface CreateWithdrawLinkBody {
title: string
min_withdrawable: number