diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index e975277..5b60b2f 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -109,33 +109,27 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000 /** Active LNURL-withdraw session */ interface LnurlSession { sessionId: string - /** Link ID for management operations (delete/update) */ + /** Link ID — the management + settlement-watch key (delete/subscribe). */ linkId: string - uniqueHash: string satsAmount: number status: 'active' | 'claimed' | 'expired' createdAt: number 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() /** - * Register a new LNURL-withdraw session + * Register a new LNURL-withdraw session, keyed by linkId. */ -function registerLnurlSession( - sessionId: string, - linkId: string, - uniqueHash: string, - satsAmount: number, -): void { - console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats') +function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void { + console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats') - lnurlSessions.set(uniqueHash, { + lnurlSessions.set(linkId, { sessionId, linkId, - uniqueHash, satsAmount, status: 'active', createdAt: Date.now(), @@ -143,10 +137,10 @@ function registerLnurlSession( // Safety timeout — normally cleaned up by state machine on idle transition. setTimeout(() => { - const session = lnurlSessions.get(uniqueHash) + const session = lnurlSessions.get(linkId) if (session && session.status === 'active') { - console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash) - expireLnurlSession(uniqueHash) + console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId) + expireLnurlSession(linkId) } }, SESSION_SAFETY_TIMEOUT_MS) } @@ -154,23 +148,23 @@ function registerLnurlSession( /** Invalidate an active LNURL session by cash-in sessionId. The session's * cleanup closure unsubscribes from LNbits and deletes the link. */ 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') { - console.log('[LNURL Session] Invalidating previous session:', hash) - expireLnurlSession(hash) + console.log('[LNURL Session] Invalidating previous session:', linkId) + expireLnurlSession(linkId) } } } /** Expire a single LNURL session via its cleanup closure. */ -function expireLnurlSession(uniqueHash: string): void { - const session = lnurlSessions.get(uniqueHash) +function expireLnurlSession(linkId: string): void { + const session = lnurlSessions.get(linkId) if (!session || session.status !== 'active') return - console.log('[LNURL Session] Expiring:', uniqueHash) + console.log('[LNURL Session] Expiring:', linkId) session.status = 'expired' if (session.cleanup) session.cleanup() - setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) + setTimeout(() => lnurlSessions.delete(linkId), 60000) } let _lnbitsRef: LnbitsClient | null = null @@ -651,50 +645,53 @@ function createATMServices( 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'}`, - min_withdrawable: context.satsAmount, - max_withdrawable: context.satsAmount, - uses: 1, - wait_time: 1, - is_unique: false, + client_ref: context.txid ?? context.cashInSessionId ?? undefined, }) if (!link.lnurl) { 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() + 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) { - registerLnurlSession( - context.cashInSessionId, - link.id, - link.unique_hash, - context.satsAmount, - ) + // Track the NET (what the customer withdraws); keyed by link_id. + registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats) const subId = await lnbits.subscribePayments( lnbitsWalletId, - { tag: 'withdraw', link_id: link.id, max_seconds: 600 }, + { tag: 'withdraw', link_id: link.link_id, max_seconds: 600 }, (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) { session.status = 'claimed' - lnurlSessions.delete(link.unique_hash) + lnurlSessions.delete(link.link_id) } 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. - const session = lnurlSessions.get(link.unique_hash) + const session = lnurlSessions.get(link.link_id) if (session) { session.cleanup = () => { void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {}) - void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {}) + void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {}) } } } diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index de408a1..f75db3f 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -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 { + return this.sendRpc('create_withdraw', { walletId, body }) + } + async getWithdrawLink(walletId: string, id: string): Promise { return this.idempotent(() => this.sendRpc('lnurlw_get_link', { walletId, body: { id } }), diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index 2085593..9618a2f 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -73,6 +73,8 @@ export type { PaymentPushCallback, SubscriptionCloseCallback, CreateWithdrawLinkBody, + CreateWithdrawBody, + CreateWithdrawResult, LnbitsWithdrawLink, UniqueHashEntry, UniqueHashesResponse, diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index c040b3c..17af86f 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -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