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) */
const processedEventIds = new Set<string>()
/** 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)
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()
if (session.linkId) {
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
/** 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,

View file

@ -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(