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:
parent
b0ec3ab7b3
commit
23f8ed3398
2 changed files with 57 additions and 25 deletions
|
|
@ -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)
|
||||||
session.status = 'expired'
|
expireLnurlSession(hash, lightningPub)
|
||||||
if (session.cleanup) session.cleanup()
|
}
|
||||||
if (session.linkId) {
|
}
|
||||||
lightningPub.deleteWithdrawLink(session.linkId).catch((err) => {
|
}
|
||||||
console.warn('[LNURL Session] Failed to delete old link:', err)
|
|
||||||
})
|
/** Expire a single LNURL session and delete its link from Lightning.Pub */
|
||||||
}
|
function expireLnurlSession(uniqueHash: string, lightningPub: LightningPubClient): void {
|
||||||
lnurlSessions.delete(hash)
|
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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue