fix: tie LNURL session lifecycle to state machine instead of fixed timer

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-30 17:31:45 -04:00
commit 23f8ed3398
2 changed files with 57 additions and 25 deletions

View file

@ -153,8 +153,10 @@ const approvedInvoices = new Set<string>()
/** Set of processed event IDs (to prevent replay) */ /** Set of processed event IDs (to prevent replay) */
const processedEventIds = new Set<string>() const processedEventIds = new Set<string>()
/** Session timeout in ms (5 minutes) */ /** Safety timeout in ms (15 minutes) — absolute maximum session lifetime.
const SESSION_TIMEOUT_MS = 5 * 60 * 1000 * 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 * Register a new active session for cash-in
@ -169,16 +171,15 @@ function registerActiveSession(sessionId: string, satsAmount: number): void {
status: 'active', status: 'active',
}) })
// Auto-expire after timeout // Safety timeout — normally cleaned up by state machine on idle transition
setTimeout(() => { setTimeout(() => {
const session = activeSessions.get(sessionId) const session = activeSessions.get(sessionId)
if (session && session.status === 'active') { if (session && session.status === 'active') {
console.log('[Session] Expiring session:', sessionId) console.warn('[Session] Safety timeout reached, expiring:', sessionId)
session.status = 'expired' session.status = 'expired'
// Clean up after another minute
setTimeout(() => activeSessions.delete(sessionId), 60000) setTimeout(() => activeSessions.delete(sessionId), 60000)
} }
}, SESSION_TIMEOUT_MS) }, SESSION_SAFETY_TIMEOUT_MS)
} }
/** /**
@ -274,21 +275,15 @@ function registerLnurlSession(
createdAt: Date.now(), 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(() => { setTimeout(() => {
const session = lnurlSessions.get(uniqueHash) const session = lnurlSessions.get(uniqueHash)
if (session && session.status === 'active') { if (session && session.status === 'active') {
console.log('[LNURL Session] Expiring:', uniqueHash) console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
session.status = 'expired' expireLnurlSession(uniqueHash, lightningPub)
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)
} }
}, SESSION_TIMEOUT_MS) }, SESSION_SAFETY_TIMEOUT_MS)
} }
/** /**
@ -353,14 +348,33 @@ function invalidateLnurlSessionBySessionId(
for (const [hash, session] of lnurlSessions.entries()) { for (const [hash, 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:', 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' session.status = 'expired'
if (session.cleanup) session.cleanup() if (session.cleanup) session.cleanup()
if (session.linkId) {
lightningPub.deleteWithdrawLink(session.linkId).catch((err) => { lightningPub.deleteWithdrawLink(session.linkId).catch((err) => {
console.warn('[LNURL Session] Failed to delete old link:', err) console.warn('[LNURL Session] Failed to delete link:', err)
}) })
} setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
lnurlSessions.delete(hash) }
/** 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 onDebitPaymentApproved: (callback: DebitPaymentCallback) => void
/** Stop the debit approval service */ /** Stop the debit approval service */
stopDebitApproval: () => void stopDebitApproval: () => void
/** Clean up all active LNURL sessions (call on idle transition) */
cleanupLnurlSessions: () => void
/** Set callback for operator management commands (Kind 21003) */ /** Set callback for operator management commands (Kind 21003) */
onManagement: ( onManagement: (
callback: ( callback: (
@ -911,6 +927,7 @@ export async function initializeLightningServices(options?: {
}) })
lightningPub.initialize(nostrClient, identity) lightningPub.initialize(nostrClient, identity)
_lightningPubRef = lightningPub
console.log('[Lightning] Lightning.Pub client initialized') console.log('[Lightning] Lightning.Pub client initialized')
// Create CLINK client // Create CLINK client
@ -1027,6 +1044,7 @@ export async function initializeLightningServices(options?: {
debitPaymentCallback = callback debitPaymentCallback = callback
}, },
stopDebitApproval, stopDebitApproval,
cleanupLnurlSessions: cleanupAllLnurlSessions,
onManagement: ( onManagement: (
callback: ( callback: (
request: ManagementRequest, request: ManagementRequest,

View file

@ -283,6 +283,8 @@ export const useAtmStore = defineStore('atm', () => {
// Store reference to ATM services for direct calls // Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null 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) */ /** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
function detectNetworkFromInvoice(invoice: string) { function detectNetworkFromInvoice(invoice: string) {
@ -364,6 +366,11 @@ export const useAtmStore = defineStore('atm', () => {
if ('cashOut' in state) currentNested = state.cashOut as string 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 // Detect network from first invoice we see
if (newSnapshot.context.invoice) { if (newSnapshot.context.invoice) {
detectNetworkFromInvoice(newSnapshot.context.invoice) detectNetworkFromInvoice(newSnapshot.context.invoice)
@ -538,6 +545,9 @@ export const useAtmStore = defineStore('atm', () => {
services.stopDebitApproval() services.stopDebitApproval()
} }
// Wire LNURL session cleanup (called when state machine goes idle)
lnurlCleanupFn = services.cleanupLnurlSessions
// Wire operator management commands (Lightning-only mode, mock dispense) // Wire operator management commands (Lightning-only mode, mock dispense)
services.onManagement(async (request) => { services.onManagement(async (request) => {
return handleManagementCommand( return handleManagementCommand(
@ -778,6 +788,8 @@ export const useAtmStore = defineStore('atm', () => {
lightning.stopDebitApproval() lightning.stopDebitApproval()
} }
lnurlCleanupFn = lightning.cleanupLnurlSessions
// Wire operator management commands (manual dispense via direct HAL) // Wire operator management commands (manual dispense via direct HAL)
lightning.onManagement(async (request) => { lightning.onManagement(async (request) => {
return handleManagementCommand( return handleManagementCommand(
@ -1032,6 +1044,8 @@ export const useAtmStore = defineStore('atm', () => {
lightning.stopDebitApproval() lightning.stopDebitApproval()
} }
lnurlCleanupFn = lightning.cleanupLnurlSessions
// Wire operator management commands (manual dispense via IPC HAL) // Wire operator management commands (manual dispense via IPC HAL)
lightning.onManagement(async (request) => { lightning.onManagement(async (request) => {
return handleManagementCommand( return handleManagementCommand(