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 */ /** Active LNURL-withdraw session */
interface LnurlSession { interface LnurlSession {
sessionId: string sessionId: string
/** Link ID for management operations (delete/update) */
linkId: string
uniqueHash: string uniqueHash: string
satsAmount: number satsAmount: number
status: 'active' | 'claimed' | 'expired' status: 'active' | 'claimed' | 'expired'
@ -240,11 +242,17 @@ const lnurlSessions = new Map<string, LnurlSession>()
/** /**
* Register a new LNURL-withdraw session * 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') console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
lnurlSessions.set(uniqueHash, { lnurlSessions.set(uniqueHash, {
sessionId, sessionId,
linkId,
uniqueHash, uniqueHash,
satsAmount, satsAmount,
status: 'active', status: 'active',
@ -258,6 +266,10 @@ function registerLnurlSession(sessionId: string, uniqueHash: string, satsAmount:
console.log('[LNURL Session] Expiring:', uniqueHash) console.log('[LNURL Session] Expiring:', uniqueHash)
session.status = 'expired' session.status = 'expired'
if (session.cleanup) session.cleanup() 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 // Clean up after another minute
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) 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 // Debit Approval Service
// ============================================================================ // ============================================================================
@ -1059,6 +1091,12 @@ function createATMServices(
console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)') console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)')
try { 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) // Call the withdraw extension via Nostr RPC (encrypted)
const response = await lightningPub.createWithdrawLink({ const response = await lightningPub.createWithdrawLink({
title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`, 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') 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 // Register session for tracking completion
if (context.cashInSessionId) { if (context.cashInSessionId) {
registerLnurlSession( registerLnurlSession(
context.cashInSessionId, context.cashInSessionId,
response.link.id || '',
response.link.unique_hash, response.link.unique_hash,
context.satsAmount context.satsAmount
) )

View file

@ -37,6 +37,9 @@ import {
type CreateWithdrawLinkParams, type CreateWithdrawLinkParams,
type CreateWithdrawLinkResponse, type CreateWithdrawLinkResponse,
type GetWithdrawLinkResponse, type GetWithdrawLinkResponse,
type DeleteWithdrawLinkResponse,
type UpdateWithdrawLinkParams,
type UpdateWithdrawLinkResponse,
isRPCError, isRPCError,
} from './types.js' } from './types.js'
@ -320,6 +323,33 @@ export class LightningPubClient {
return response return response
} }
/**
* Delete a withdraw link via the withdraw extension
*
* Permanently removes the link so it can no longer be claimed.
* Uses the link's `id` (not `unique_hash`).
*/
async deleteWithdrawLink(linkId: string): Promise<DeleteWithdrawLinkResponse> {
console.log('[LightningPub] Deleting withdraw link:', linkId)
const response = await this.sendRPC<DeleteWithdrawLinkResponse>('withdraw.deleteLink', {
id: linkId,
})
console.log('[LightningPub] Withdraw link deleted:', linkId)
return response
}
/**
* Update a withdraw link via the withdraw extension
*
* Can modify title, amounts, uses, or wait_time.
* Uses the link's `id` (not `unique_hash`).
*/
async updateWithdrawLink(params: UpdateWithdrawLinkParams): Promise<UpdateWithdrawLinkResponse> {
console.log('[LightningPub] Updating withdraw link:', params.id)
const response = await this.sendRPC<UpdateWithdrawLinkResponse>('withdraw.updateLink', params)
return response
}
/** /**
* Get payment state for an invoice * Get payment state for an invoice
* *

View file

@ -71,9 +71,13 @@ export {
type PayInvoiceResponse, type PayInvoiceResponse,
// LNURL types // LNURL types
type LnurlLinkResponse, type LnurlLinkResponse,
type WithdrawLink,
type CreateWithdrawLinkParams, type CreateWithdrawLinkParams,
type CreateWithdrawLinkResponse, type CreateWithdrawLinkResponse,
type GetWithdrawLinkResponse, type GetWithdrawLinkResponse,
type DeleteWithdrawLinkResponse,
type UpdateWithdrawLinkParams,
type UpdateWithdrawLinkResponse,
// Exchange types // Exchange types
type ExchangeRate, type ExchangeRate,
// Config // Config

View file

@ -203,11 +203,11 @@ export interface CreateWithdrawLinkParams {
wait_time?: number wait_time?: number
} }
/** Response from withdraw.createLink RPC */ /** Withdraw link fields shared across responses */
export interface CreateWithdrawLinkResponse { export interface WithdrawLink {
/** The created withdraw link */ /** Link ID (used for update/delete operations) */
link: { id: string
/** Unique hash identifier for the link */ /** Unique hash identifier (used in LNURL URLs) */
unique_hash: string unique_hash: string
/** Bech32-encoded LNURL string */ /** Bech32-encoded LNURL string */
lnurl: string lnurl: string
@ -217,34 +217,50 @@ export interface CreateWithdrawLinkResponse {
min_withdrawable: number min_withdrawable: number
/** Maximum withdrawable in sats */ /** Maximum withdrawable in sats */
max_withdrawable: number max_withdrawable: number
/** Number of uses remaining */ /** Number of uses allowed */
uses: number uses: number
/** Number of times used */
used?: number
/** Wait time between uses */ /** Wait time between uses */
wait_time: number wait_time: number
} /** Whether the link has been fully used */
is_spent?: boolean
}
/** Response from withdraw.createLink RPC */
export interface CreateWithdrawLinkResponse {
link: WithdrawLink
} }
/** Response from withdraw.getLink RPC */ /** Response from withdraw.getLink RPC */
export interface GetWithdrawLinkResponse { export interface GetWithdrawLinkResponse {
/** The withdraw link details */ link: WithdrawLink
link: { }
/** Unique hash identifier */
unique_hash: string /** Response from withdraw.deleteLink RPC */
/** Bech32-encoded LNURL string */ export interface DeleteWithdrawLinkResponse {
lnurl: string success: boolean
/** Title of the link */ }
title: string
/** Minimum withdrawable in sats */ /** Parameters for withdraw.updateLink RPC */
min_withdrawable: number export interface UpdateWithdrawLinkParams {
/** Maximum withdrawable in sats */ /** Link ID to update */
max_withdrawable: number id: string
/** Number of uses remaining (0 = spent) */ /** New title */
uses: number title?: string
/** Wait time between uses */ /** New minimum withdrawable in sats */
wait_time: number min_withdrawable?: number
/** Whether the link has been used/spent */ /** New maximum withdrawable in sats */
is_spent?: boolean max_withdrawable?: number
} /** New number of uses (cannot reduce below current used count) */
uses?: number
/** New wait time between uses */
wait_time?: number
}
/** Response from withdraw.updateLink RPC */
export interface UpdateWithdrawLinkResponse {
link: WithdrawLink
} }
// ============================================================================ // ============================================================================