From 23f8ed339865a9f1cd6280198cbbab490e077bcc Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 30 Mar 2026 17:31:45 -0400 Subject: [PATCH] fix: tie LNURL session lifecycle to state machine instead of fixed timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LNURL-withdraw session had a fixed 5-minute expiry timer that raced with the state machine's displayingQR timeout (also 5 min). If the session timer fired first, the withdraw link was deleted while the customer could still retry from confirmAbandon. Now LNURL sessions are cleaned up by the state machine on idle transition instead of a fixed timer. A 15-minute safety timeout remains as a fallback in case the state machine doesn't clean up. Flow: displayingQR (5min) → confirmAbandon (60s) → idle → cleanup. The withdraw link stays alive the entire time the customer can interact with it. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/machine/src/services/lightning.ts | 68 ++++++++++++++++---------- apps/machine/src/stores/atm.ts | 14 ++++++ 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index b47b3b0..f876227 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -153,8 +153,10 @@ const approvedInvoices = new Set() /** Set of processed event IDs (to prevent replay) */ const processedEventIds = new Set() -/** Session timeout in ms (5 minutes) */ -const SESSION_TIMEOUT_MS = 5 * 60 * 1000 +/** Safety timeout in ms (15 minutes) — absolute maximum session lifetime. + * Sessions are normally cleaned up by the state machine on idle transition. + * This is a safety net in case the state machine doesn't clean up properly. */ +const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000 /** * Register a new active session for cash-in @@ -169,16 +171,15 @@ function registerActiveSession(sessionId: string, satsAmount: number): void { status: 'active', }) - // Auto-expire after timeout + // Safety timeout — normally cleaned up by state machine on idle transition setTimeout(() => { const session = activeSessions.get(sessionId) if (session && session.status === 'active') { - console.log('[Session] Expiring session:', sessionId) + console.warn('[Session] Safety timeout reached, expiring:', sessionId) session.status = 'expired' - // Clean up after another minute setTimeout(() => activeSessions.delete(sessionId), 60000) } - }, SESSION_TIMEOUT_MS) + }, SESSION_SAFETY_TIMEOUT_MS) } /** @@ -274,21 +275,15 @@ function registerLnurlSession( createdAt: Date.now(), }) - // Auto-expire after timeout + // Safety timeout — normally cleaned up by state machine on idle transition. + // This only fires if the state machine fails to clean up. setTimeout(() => { const session = lnurlSessions.get(uniqueHash) if (session && session.status === 'active') { - console.log('[LNURL Session] Expiring:', uniqueHash) - session.status = 'expired' - if (session.cleanup) session.cleanup() - // Delete the link on the server so it can't be claimed - lightningPub.deleteWithdrawLink(session.linkId).catch((err) => { - console.warn('[LNURL Session] Failed to delete expired link:', err) - }) - // Clean up after another minute - setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) + console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash) + expireLnurlSession(uniqueHash, lightningPub) } - }, SESSION_TIMEOUT_MS) + }, SESSION_SAFETY_TIMEOUT_MS) } /** @@ -353,14 +348,33 @@ function invalidateLnurlSessionBySessionId( for (const [hash, session] of lnurlSessions.entries()) { if (session.sessionId === sessionId && session.status === 'active') { console.log('[LNURL Session] Invalidating previous session:', hash) - session.status = 'expired' - if (session.cleanup) session.cleanup() - if (session.linkId) { - lightningPub.deleteWithdrawLink(session.linkId).catch((err) => { - console.warn('[LNURL Session] Failed to delete old link:', err) - }) - } - lnurlSessions.delete(hash) + expireLnurlSession(hash, lightningPub) + } + } +} + +/** Expire a single LNURL session and delete its link from Lightning.Pub */ +function expireLnurlSession(uniqueHash: string, lightningPub: LightningPubClient): void { + const session = lnurlSessions.get(uniqueHash) + if (!session || session.status !== 'active') return + + console.log('[LNURL Session] Expiring:', uniqueHash) + session.status = 'expired' + if (session.cleanup) session.cleanup() + lightningPub.deleteWithdrawLink(session.linkId).catch((err) => { + console.warn('[LNURL Session] Failed to delete link:', err) + }) + setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) +} + +/** Clean up all active LNURL sessions. Called when state machine returns to idle. */ +let _lightningPubRef: LightningPubClient | null = null + +function cleanupAllLnurlSessions(): void { + if (!_lightningPubRef) return + for (const [hash, session] of lnurlSessions.entries()) { + if (session.status === 'active') { + expireLnurlSession(hash, _lightningPubRef) } } } @@ -683,6 +697,8 @@ interface LightningServices { onDebitPaymentApproved: (callback: DebitPaymentCallback) => void /** Stop the debit approval service */ stopDebitApproval: () => void + /** Clean up all active LNURL sessions (call on idle transition) */ + cleanupLnurlSessions: () => void /** Set callback for operator management commands (Kind 21003) */ onManagement: ( callback: ( @@ -911,6 +927,7 @@ export async function initializeLightningServices(options?: { }) lightningPub.initialize(nostrClient, identity) + _lightningPubRef = lightningPub console.log('[Lightning] Lightning.Pub client initialized') // Create CLINK client @@ -1027,6 +1044,7 @@ export async function initializeLightningServices(options?: { debitPaymentCallback = callback }, stopDebitApproval, + cleanupLnurlSessions: cleanupAllLnurlSessions, onManagement: ( callback: ( request: ManagementRequest, diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index a8e1319..33da582 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -283,6 +283,8 @@ export const useAtmStore = defineStore('atm', () => { // Store reference to ATM services for direct calls let atmServicesRef: ATMServices | null = null + // Cleanup function for LNURL sessions (set after Lightning init) + let lnurlCleanupFn: (() => void) | null = null /** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */ function detectNetworkFromInvoice(invoice: string) { @@ -364,6 +366,11 @@ export const useAtmStore = defineStore('atm', () => { if ('cashOut' in state) currentNested = state.cashOut as string } + // Clean up LNURL sessions when machine returns to idle + if (state === 'idle' && lnurlCleanupFn) { + lnurlCleanupFn() + } + // Detect network from first invoice we see if (newSnapshot.context.invoice) { detectNetworkFromInvoice(newSnapshot.context.invoice) @@ -538,6 +545,9 @@ export const useAtmStore = defineStore('atm', () => { services.stopDebitApproval() } + // Wire LNURL session cleanup (called when state machine goes idle) + lnurlCleanupFn = services.cleanupLnurlSessions + // Wire operator management commands (Lightning-only mode, mock dispense) services.onManagement(async (request) => { return handleManagementCommand( @@ -778,6 +788,8 @@ export const useAtmStore = defineStore('atm', () => { lightning.stopDebitApproval() } + lnurlCleanupFn = lightning.cleanupLnurlSessions + // Wire operator management commands (manual dispense via direct HAL) lightning.onManagement(async (request) => { return handleManagementCommand( @@ -1032,6 +1044,8 @@ export const useAtmStore = defineStore('atm', () => { lightning.stopDebitApproval() } + lnurlCleanupFn = lightning.cleanupLnurlSessions + // Wire operator management commands (manual dispense via IPC HAL) lightning.onManagement(async (request) => { return handleManagementCommand(