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(