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:
parent
e48073397f
commit
99ae5ab3de
4 changed files with 132 additions and 37 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ import {
|
|||
type CreateWithdrawLinkParams,
|
||||
type CreateWithdrawLinkResponse,
|
||||
type GetWithdrawLinkResponse,
|
||||
type DeleteWithdrawLinkResponse,
|
||||
type UpdateWithdrawLinkParams,
|
||||
type UpdateWithdrawLinkResponse,
|
||||
isRPCError,
|
||||
} from './types.js'
|
||||
|
||||
|
|
@ -320,6 +323,33 @@ export class LightningPubClient {
|
|||
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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -71,9 +71,13 @@ export {
|
|||
type PayInvoiceResponse,
|
||||
// LNURL types
|
||||
type LnurlLinkResponse,
|
||||
type WithdrawLink,
|
||||
type CreateWithdrawLinkParams,
|
||||
type CreateWithdrawLinkResponse,
|
||||
type GetWithdrawLinkResponse,
|
||||
type DeleteWithdrawLinkResponse,
|
||||
type UpdateWithdrawLinkParams,
|
||||
type UpdateWithdrawLinkResponse,
|
||||
// Exchange types
|
||||
type ExchangeRate,
|
||||
// Config
|
||||
|
|
|
|||
|
|
@ -203,11 +203,11 @@ export interface CreateWithdrawLinkParams {
|
|||
wait_time?: number
|
||||
}
|
||||
|
||||
/** Response from withdraw.createLink RPC */
|
||||
export interface CreateWithdrawLinkResponse {
|
||||
/** The created withdraw link */
|
||||
link: {
|
||||
/** Unique hash identifier for the link */
|
||||
/** Withdraw link fields shared across responses */
|
||||
export interface WithdrawLink {
|
||||
/** Link ID (used for update/delete operations) */
|
||||
id: string
|
||||
/** Unique hash identifier (used in LNURL URLs) */
|
||||
unique_hash: string
|
||||
/** Bech32-encoded LNURL string */
|
||||
lnurl: string
|
||||
|
|
@ -217,34 +217,50 @@ export interface CreateWithdrawLinkResponse {
|
|||
min_withdrawable: number
|
||||
/** Maximum withdrawable in sats */
|
||||
max_withdrawable: number
|
||||
/** Number of uses remaining */
|
||||
/** Number of uses allowed */
|
||||
uses: number
|
||||
/** Number of times used */
|
||||
used?: number
|
||||
/** Wait time between uses */
|
||||
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 */
|
||||
export interface GetWithdrawLinkResponse {
|
||||
/** The withdraw link details */
|
||||
link: {
|
||||
/** Unique hash identifier */
|
||||
unique_hash: string
|
||||
/** Bech32-encoded LNURL string */
|
||||
lnurl: string
|
||||
/** Title of the link */
|
||||
title: string
|
||||
/** Minimum withdrawable in sats */
|
||||
min_withdrawable: number
|
||||
/** Maximum withdrawable in sats */
|
||||
max_withdrawable: number
|
||||
/** Number of uses remaining (0 = spent) */
|
||||
uses: number
|
||||
/** Wait time between uses */
|
||||
wait_time: number
|
||||
/** Whether the link has been used/spent */
|
||||
is_spent?: boolean
|
||||
link: WithdrawLink
|
||||
}
|
||||
|
||||
/** Response from withdraw.deleteLink RPC */
|
||||
export interface DeleteWithdrawLinkResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/** Parameters for withdraw.updateLink RPC */
|
||||
export interface UpdateWithdrawLinkParams {
|
||||
/** Link ID to update */
|
||||
id: string
|
||||
/** New title */
|
||||
title?: string
|
||||
/** New minimum withdrawable in sats */
|
||||
min_withdrawable?: number
|
||||
/** New maximum withdrawable in sats */
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue