diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 8c32c01..181c5dd 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -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() /** * 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 ) diff --git a/packages/lightning/src/client.ts b/packages/lightning/src/client.ts index 832d62a..3178f90 100644 --- a/packages/lightning/src/client.ts +++ b/packages/lightning/src/client.ts @@ -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 { + console.log('[LightningPub] Deleting withdraw link:', linkId) + const response = await this.sendRPC('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 { + console.log('[LightningPub] Updating withdraw link:', params.id) + const response = await this.sendRPC('withdraw.updateLink', params) + return response + } + /** * Get payment state for an invoice * diff --git a/packages/lightning/src/index.ts b/packages/lightning/src/index.ts index 6916463..1f56645 100644 --- a/packages/lightning/src/index.ts +++ b/packages/lightning/src/index.ts @@ -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 diff --git a/packages/lightning/src/types.ts b/packages/lightning/src/types.ts index 192b88a..fa6b846 100644 --- a/packages/lightning/src/types.ts +++ b/packages/lightning/src/types.ts @@ -203,48 +203,64 @@ export interface CreateWithdrawLinkParams { wait_time?: number } +/** 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 + /** Title of the link */ + title: string + /** Minimum withdrawable in sats */ + min_withdrawable: number + /** Maximum withdrawable in sats */ + max_withdrawable: number + /** 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 { - /** The created withdraw link */ - link: { - /** Unique hash identifier for the link */ - 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 */ - uses: number - /** Wait time between uses */ - wait_time: number - } + 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 } // ============================================================================