feat(lightning): add withdraw link lifecycle management (delete/update/invalidate)

- Add deleteWithdrawLink and updateWithdrawLink RPC methods to LightningPubClient
- Extract shared WithdrawLink type, add Delete/Update request/response types
- Track linkId in LNURL sessions for server-side cleanup
- Invalidate previous LNURL session on new link creation
- Auto-delete expired links on the server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-07 13:01:40 -05:00
commit 99ae5ab3de
4 changed files with 132 additions and 37 deletions

View file

@ -227,6 +227,8 @@ export { validateDebitSession, findActiveSessionByAmount, markSessionPaid, getSe
/** Active LNURL-withdraw session */
interface LnurlSession {
sessionId: string
/** Link ID for management operations (delete/update) */
linkId: string
uniqueHash: string
satsAmount: number
status: 'active' | 'claimed' | 'expired'
@ -240,11 +242,17 @@ const lnurlSessions = new Map<string, LnurlSession>()
/**
* Register a new LNURL-withdraw session
*/
function registerLnurlSession(sessionId: string, uniqueHash: string, satsAmount: number): void {
function registerLnurlSession(
sessionId: string,
linkId: string,
uniqueHash: string,
satsAmount: number
): void {
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
lnurlSessions.set(uniqueHash, {
sessionId,
linkId,
uniqueHash,
satsAmount,
status: 'active',
@ -258,6 +266,10 @@ function registerLnurlSession(sessionId: string, uniqueHash: string, satsAmount:
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)
}
@ -315,6 +327,26 @@ function startLnurlCompletionPolling(
}
}
/**
* Invalidate an active LNURL session by cash-in sessionId.
* Stops polling and deletes the link on the server.
*/
function invalidateLnurlSessionBySessionId(sessionId: string): void {
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)
}
}
}
// ============================================================================
// Debit Approval Service
// ============================================================================
@ -1059,6 +1091,12 @@ function createATMServices(
console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)')
try {
// Invalidate any previous LNURL session for this cash-in session
// (shouldn't happen — LNURL is generated once — but guard against it)
if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId)
}
// Call the withdraw extension via Nostr RPC (encrypted)
const response = await lightningPub.createWithdrawLink({
title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
@ -1074,10 +1112,17 @@ function createATMServices(
throw new Error('RPC did not return LNURL in response')
}
if (!response.link?.id) {
console.warn(
'[ATM Service] Withdraw link response missing id — delete/update unavailable'
)
}
// Register session for tracking completion
if (context.cashInSessionId) {
registerLnurlSession(
context.cashInSessionId,
response.link.id || '',
response.link.unique_hash,
context.satsAmount
)