feat: secure cash-in via server-stamped create_withdraw RPC (#52) #66

Merged
padreug merged 1 commit from cash-in-create-withdraw into dev 2026-06-22 13:56:00 +00:00
4 changed files with 96 additions and 43 deletions
Showing only changes of commit 9c74a28a06 - Show all commits

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>
Padreug 2026-06-22 12:31:24 +02:00

View file

@ -109,33 +109,27 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
/** Active LNURL-withdraw session */ /** Active LNURL-withdraw session */
interface LnurlSession { interface LnurlSession {
sessionId: string sessionId: string
/** Link ID for management operations (delete/update) */ /** Link ID — the management + settlement-watch key (delete/subscribe). */
linkId: string linkId: string
uniqueHash: string
satsAmount: number satsAmount: number
status: 'active' | 'claimed' | 'expired' status: 'active' | 'claimed' | 'expired'
createdAt: number createdAt: number
cleanup?: () => void cleanup?: () => void
} }
/** Map of uniqueHash -> LNURL session data */ /** Map of linkId -> LNURL session data. Keyed on link_id since the secure
* `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
const lnurlSessions = new Map<string, LnurlSession>() const lnurlSessions = new Map<string, LnurlSession>()
/** /**
* Register a new LNURL-withdraw session * Register a new LNURL-withdraw session, keyed by linkId.
*/ */
function registerLnurlSession( function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
sessionId: string, console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
linkId: string,
uniqueHash: string,
satsAmount: number,
): void {
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
lnurlSessions.set(uniqueHash, { lnurlSessions.set(linkId, {
sessionId, sessionId,
linkId, linkId,
uniqueHash,
satsAmount, satsAmount,
status: 'active', status: 'active',
createdAt: Date.now(), createdAt: Date.now(),
@ -143,10 +137,10 @@ function registerLnurlSession(
// Safety timeout — normally cleaned up by state machine on idle transition. // Safety timeout — normally cleaned up by state machine on idle transition.
setTimeout(() => { setTimeout(() => {
const session = lnurlSessions.get(uniqueHash) const session = lnurlSessions.get(linkId)
if (session && session.status === 'active') { if (session && session.status === 'active') {
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash) console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
expireLnurlSession(uniqueHash) expireLnurlSession(linkId)
} }
}, SESSION_SAFETY_TIMEOUT_MS) }, SESSION_SAFETY_TIMEOUT_MS)
} }
@ -154,23 +148,23 @@ function registerLnurlSession(
/** Invalidate an active LNURL session by cash-in sessionId. The session's /** Invalidate an active LNURL session by cash-in sessionId. The session's
* cleanup closure unsubscribes from LNbits and deletes the link. */ * cleanup closure unsubscribes from LNbits and deletes the link. */
function invalidateLnurlSessionBySessionId(sessionId: string): void { function invalidateLnurlSessionBySessionId(sessionId: string): void {
for (const [hash, session] of lnurlSessions.entries()) { for (const [linkId, session] of lnurlSessions.entries()) {
if (session.sessionId === sessionId && session.status === 'active') { if (session.sessionId === sessionId && session.status === 'active') {
console.log('[LNURL Session] Invalidating previous session:', hash) console.log('[LNURL Session] Invalidating previous session:', linkId)
expireLnurlSession(hash) expireLnurlSession(linkId)
} }
} }
} }
/** Expire a single LNURL session via its cleanup closure. */ /** Expire a single LNURL session via its cleanup closure. */
function expireLnurlSession(uniqueHash: string): void { function expireLnurlSession(linkId: string): void {
const session = lnurlSessions.get(uniqueHash) const session = lnurlSessions.get(linkId)
if (!session || session.status !== 'active') return if (!session || session.status !== 'active') return
console.log('[LNURL Session] Expiring:', uniqueHash) console.log('[LNURL Session] Expiring:', linkId)
session.status = 'expired' session.status = 'expired'
if (session.cleanup) session.cleanup() if (session.cleanup) session.cleanup()
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) setTimeout(() => lnurlSessions.delete(linkId), 60000)
} }
let _lnbitsRef: LnbitsClient | null = null let _lnbitsRef: LnbitsClient | null = null
@ -651,50 +645,53 @@ function createATMServices(
invalidateLnurlSessionBySessionId(context.cashInSessionId) invalidateLnurlSessionBySessionId(context.cashInSessionId)
} }
const link = await lnbits.createWithdrawLink(lnbitsWalletId, { // Secure cash-in: the ATM sends only the hardware-attested gross
// principal; the operator side verifies the signer, derives fee + NET,
// and stamps attribution (spirekeeper#31/#32). The ATM no longer sets
// the amount or extra. We display the returned LNURL (for NET) and
// watch link_id for settlement.
const link = await lnbits.createWithdraw(lnbitsWalletId, {
principal_sats: context.satsAmount,
fiat_amount: context.fiatCents / 100,
fiat_code: context.currency,
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`, title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
min_withdrawable: context.satsAmount, client_ref: context.txid ?? context.cashInSessionId ?? undefined,
max_withdrawable: context.satsAmount,
uses: 1,
wait_time: 1,
is_unique: false,
}) })
if (!link.lnurl) { if (!link.lnurl) {
throw new Error( throw new Error(
'[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)' '[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
) )
} }
const lnurl = link.lnurl.toUpperCase() const lnurl = link.lnurl.toUpperCase()
console.log(
`[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}`
)
if (context.cashInSessionId) { if (context.cashInSessionId) {
registerLnurlSession( // Track the NET (what the customer withdraws); keyed by link_id.
context.cashInSessionId, registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
link.id,
link.unique_hash,
context.satsAmount,
)
const subId = await lnbits.subscribePayments( const subId = await lnbits.subscribePayments(
lnbitsWalletId, lnbitsWalletId,
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 }, { tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
(push) => { (push) => {
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!') console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
const session = lnurlSessions.get(link.unique_hash) const session = lnurlSessions.get(link.link_id)
if (session) { if (session) {
session.status = 'claimed' session.status = 'claimed'
lnurlSessions.delete(link.unique_hash) lnurlSessions.delete(link.link_id)
} }
if (onPaymentCallback) { if (onPaymentCallback) {
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`) onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
} }
}, },
) )
// Wire per-session cleanup so abort/expiry tears it down cleanly. // Wire per-session cleanup so abort/expiry tears it down cleanly.
const session = lnurlSessions.get(link.unique_hash) const session = lnurlSessions.get(link.link_id)
if (session) { if (session) {
session.cleanup = () => { session.cleanup = () => {
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {}) void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {}) void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
} }
} }
} }

View file

@ -42,6 +42,8 @@ import type {
PaymentPushCallback, PaymentPushCallback,
SubscriptionCloseCallback, SubscriptionCloseCallback,
CreateWithdrawLinkBody, CreateWithdrawLinkBody,
CreateWithdrawBody,
CreateWithdrawResult,
LnbitsWithdrawLink, LnbitsWithdrawLink,
UniqueHashesResponse, UniqueHashesResponse,
} from './types.js' } from './types.js'
@ -388,6 +390,19 @@ export class LnbitsClient {
return data 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> { async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
return this.idempotent(() => return this.idempotent(() =>
this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }), this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }),

View file

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

View file

@ -146,6 +146,45 @@ export interface SubscribeClose {
// LNURL-withdraw (the `withdraw` extension's transport surface) // 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 { export interface CreateWithdrawLinkBody {
title: string title: string
min_withdrawable: number min_withdrawable: number