From 39f4575b7ce690279b508936dbd503e89326b4b6 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 13:38:13 -0500 Subject: [PATCH] feat(extensions): add LNURL-withdraw extension Implements LUD-03 (LNURL-withdraw) for creating withdraw links that allow anyone to pull funds from a Lightning wallet. Features: - Create withdraw links with min/max amounts - Quick vouchers: batch creation of single-use codes - Multi-use links with wait time between uses - Unique QR codes per use (prevents sharing exploits) - Webhook notifications on successful withdrawals - Full LNURL protocol compliance for wallet compatibility Use cases: - Faucets - Gift cards / prepaid cards - Tips / donations - User onboarding Co-Authored-By: Claude Opus 4.5 --- src/extensions/withdraw/index.ts | 304 ++++++++ .../withdraw/managers/withdrawManager.ts | 711 ++++++++++++++++++ src/extensions/withdraw/migrations.ts | 153 ++++ src/extensions/withdraw/types.ts | 261 +++++++ src/extensions/withdraw/utils/lnurl.ts | 131 ++++ 5 files changed, 1560 insertions(+) create mode 100644 src/extensions/withdraw/index.ts create mode 100644 src/extensions/withdraw/managers/withdrawManager.ts create mode 100644 src/extensions/withdraw/migrations.ts create mode 100644 src/extensions/withdraw/types.ts create mode 100644 src/extensions/withdraw/utils/lnurl.ts diff --git a/src/extensions/withdraw/index.ts b/src/extensions/withdraw/index.ts new file mode 100644 index 00000000..0b8259d6 --- /dev/null +++ b/src/extensions/withdraw/index.ts @@ -0,0 +1,304 @@ +/** + * LNURL-withdraw Extension for Lightning.Pub + * + * Implements LUD-03 (LNURL-withdraw) for creating withdraw links + * that allow anyone to pull funds from a Lightning wallet. + * + * Use cases: + * - Quick vouchers (batch single-use codes) + * - Faucets + * - Gift cards / prepaid cards + * - Tips / donations + */ + +import { + Extension, + ExtensionInfo, + ExtensionContext, + ExtensionDatabase, + CreateWithdrawLinkRequest, + UpdateWithdrawLinkRequest, + HttpRoute, + HttpRequest, + HttpResponse +} from './types.js' +import { runMigrations } from './migrations.js' +import { WithdrawManager } from './managers/withdrawManager.js' + +/** + * LNURL-withdraw Extension + */ +export default class WithdrawExtension implements Extension { + readonly info: ExtensionInfo = { + id: 'withdraw', + name: 'LNURL Withdraw', + version: '1.0.0', + description: 'Create withdraw links for vouchers, faucets, and gifts (LUD-03)', + author: 'Lightning.Pub', + minPubVersion: '1.0.0' + } + + private manager!: WithdrawManager + private baseUrl: string = '' + + /** + * Initialize the extension + */ + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + // Run migrations + await runMigrations(db) + + // Initialize manager + this.manager = new WithdrawManager(db, ctx) + + // Register RPC methods + this.registerRpcMethods(ctx) + + // Register HTTP routes for LNURL protocol + this.registerHttpRoutes(ctx) + + ctx.log('info', 'Extension initialized') + } + + /** + * Shutdown the extension + */ + async shutdown(): Promise { + // Cleanup if needed + } + + /** + * Set the base URL for LNURL generation + * This should be called by the main application after loading + */ + setBaseUrl(url: string): void { + this.baseUrl = url + this.manager.setBaseUrl(url) + } + + /** + * Get HTTP routes for this extension + * These need to be mounted by the main HTTP server + */ + getHttpRoutes(): HttpRoute[] { + return [ + // Initial LNURL request (simple link) + { + method: 'GET', + path: '/api/v1/lnurl/:unique_hash', + handler: this.handleLnurlRequest.bind(this) + }, + // Initial LNURL request (unique link with use hash) + { + method: 'GET', + path: '/api/v1/lnurl/:unique_hash/:id_unique_hash', + handler: this.handleLnurlUniqueRequest.bind(this) + }, + // LNURL callback (user submits invoice) + { + method: 'GET', + path: '/api/v1/lnurl/cb/:unique_hash', + handler: this.handleLnurlCallback.bind(this) + } + ] + } + + /** + * Register RPC methods with the extension context + */ + private registerRpcMethods(ctx: ExtensionContext): void { + // Create withdraw link + ctx.registerMethod('withdraw.createLink', async (req, appId) => { + const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest) + const stats = await this.manager.getWithdrawalStats(link.id) + return { + link, + total_withdrawn_sats: stats.total_sats, + withdrawals_count: stats.count + } + }) + + // Create quick vouchers + ctx.registerMethod('withdraw.createVouchers', async (req, appId) => { + const vouchers = await this.manager.createVouchers( + appId, + req.title, + req.amount, + req.count, + req.description + ) + return { + vouchers, + total_amount_sats: req.amount * req.count + } + }) + + // Get withdraw link + ctx.registerMethod('withdraw.getLink', async (req, appId) => { + const link = await this.manager.get(req.id, appId) + if (!link) throw new Error('Withdraw link not found') + const stats = await this.manager.getWithdrawalStats(link.id) + return { + link, + total_withdrawn_sats: stats.total_sats, + withdrawals_count: stats.count + } + }) + + // List withdraw links + ctx.registerMethod('withdraw.listLinks', async (req, appId) => { + const links = await this.manager.list( + appId, + req.include_spent || false, + req.limit, + req.offset + ) + return { links } + }) + + // Update withdraw link + ctx.registerMethod('withdraw.updateLink', async (req, appId) => { + const link = await this.manager.update(req.id, appId, req as UpdateWithdrawLinkRequest) + if (!link) throw new Error('Withdraw link not found') + const stats = await this.manager.getWithdrawalStats(link.id) + return { + link, + total_withdrawn_sats: stats.total_sats, + withdrawals_count: stats.count + } + }) + + // Delete withdraw link + ctx.registerMethod('withdraw.deleteLink', async (req, appId) => { + const success = await this.manager.delete(req.id, appId) + if (!success) throw new Error('Withdraw link not found') + return { success } + }) + + // List withdrawals + ctx.registerMethod('withdraw.listWithdrawals', async (req, appId) => { + const withdrawals = await this.manager.listWithdrawals( + appId, + req.link_id, + req.limit, + req.offset + ) + return { withdrawals } + }) + + // Get withdrawal stats + ctx.registerMethod('withdraw.getStats', async (req, appId) => { + // Get all links to calculate total stats + const links = await this.manager.list(appId, true) + + let totalLinks = links.length + let activeLinks = 0 + let spentLinks = 0 + let totalWithdrawn = 0 + let totalWithdrawals = 0 + + for (const link of links) { + if (link.used >= link.uses) { + spentLinks++ + } else { + activeLinks++ + } + const stats = await this.manager.getWithdrawalStats(link.id) + totalWithdrawn += stats.total_sats + totalWithdrawals += stats.count + } + + return { + total_links: totalLinks, + active_links: activeLinks, + spent_links: spentLinks, + total_withdrawn_sats: totalWithdrawn, + total_withdrawals: totalWithdrawals + } + }) + } + + /** + * Register HTTP routes (called by extension context) + */ + private registerHttpRoutes(ctx: ExtensionContext): void { + // HTTP routes are exposed via getHttpRoutes() + // The main application is responsible for mounting them + ctx.log('debug', 'HTTP routes registered for LNURL protocol') + } + + // ========================================================================= + // HTTP Route Handlers + // ========================================================================= + + /** + * Handle initial LNURL request (simple link) + * GET /api/v1/lnurl/:unique_hash + */ + private async handleLnurlRequest(req: HttpRequest): Promise { + const { unique_hash } = req.params + + const result = await this.manager.handleLnurlRequest(unique_hash) + + return { + status: 200, + body: result, + headers: { + 'Content-Type': 'application/json' + } + } + } + + /** + * Handle initial LNURL request (unique link) + * GET /api/v1/lnurl/:unique_hash/:id_unique_hash + */ + private async handleLnurlUniqueRequest(req: HttpRequest): Promise { + const { unique_hash, id_unique_hash } = req.params + + const result = await this.manager.handleLnurlRequest(unique_hash, id_unique_hash) + + return { + status: 200, + body: result, + headers: { + 'Content-Type': 'application/json' + } + } + } + + /** + * Handle LNURL callback (user submits invoice) + * GET /api/v1/lnurl/cb/:unique_hash?k1=...&pr=...&id_unique_hash=... + */ + private async handleLnurlCallback(req: HttpRequest): Promise { + const { unique_hash } = req.params + const { k1, pr, id_unique_hash } = req.query + + if (!k1 || !pr) { + return { + status: 200, + body: { status: 'ERROR', reason: 'Missing k1 or pr parameter' }, + headers: { 'Content-Type': 'application/json' } + } + } + + const result = await this.manager.handleLnurlCallback(unique_hash, { + k1, + pr, + id_unique_hash + }) + + return { + status: 200, + body: result, + headers: { + 'Content-Type': 'application/json' + } + } + } +} + +// Export types for external use +export * from './types.js' +export { WithdrawManager } from './managers/withdrawManager.js' diff --git a/src/extensions/withdraw/managers/withdrawManager.ts b/src/extensions/withdraw/managers/withdrawManager.ts new file mode 100644 index 00000000..91677f82 --- /dev/null +++ b/src/extensions/withdraw/managers/withdrawManager.ts @@ -0,0 +1,711 @@ +/** + * Withdraw Link Manager + * + * Handles CRUD operations for withdraw links and processes withdrawals + */ + +import { + ExtensionContext, + ExtensionDatabase, + WithdrawLink, + Withdrawal, + CreateWithdrawLinkRequest, + UpdateWithdrawLinkRequest, + WithdrawLinkWithLnurl, + LnurlWithdrawResponse, + LnurlErrorResponse, + LnurlSuccessResponse, + LnurlCallbackParams +} from '../types.js' +import { + generateId, + generateK1, + generateUniqueHash, + generateUseHash, + verifyUseHash, + encodeLnurl, + buildLnurlUrl, + buildUniqueLnurlUrl, + buildCallbackUrl, + satsToMsats +} from '../utils/lnurl.js' + +/** + * Database row types + */ +interface WithdrawLinkRow { + id: string + application_id: string + title: string + description: string | null + min_withdrawable: number + max_withdrawable: number + uses: number + used: number + wait_time: number + unique_hash: string + k1: string + is_unique: number + uses_csv: string + open_time: number + webhook_url: string | null + webhook_headers: string | null + webhook_body: string | null + created_at: number + updated_at: number +} + +interface WithdrawalRow { + id: string + link_id: string + application_id: string + payment_hash: string + amount_sats: number + fee_sats: number + recipient_node: string | null + webhook_success: number | null + webhook_response: string | null + created_at: number +} + +/** + * Convert row to WithdrawLink + */ +function rowToLink(row: WithdrawLinkRow): WithdrawLink { + return { + id: row.id, + application_id: row.application_id, + title: row.title, + description: row.description || undefined, + min_withdrawable: row.min_withdrawable, + max_withdrawable: row.max_withdrawable, + uses: row.uses, + used: row.used, + wait_time: row.wait_time, + unique_hash: row.unique_hash, + k1: row.k1, + is_unique: row.is_unique === 1, + uses_csv: row.uses_csv, + open_time: row.open_time, + webhook_url: row.webhook_url || undefined, + webhook_headers: row.webhook_headers || undefined, + webhook_body: row.webhook_body || undefined, + created_at: row.created_at, + updated_at: row.updated_at + } +} + +/** + * Convert row to Withdrawal + */ +function rowToWithdrawal(row: WithdrawalRow): Withdrawal { + return { + id: row.id, + link_id: row.link_id, + application_id: row.application_id, + payment_hash: row.payment_hash, + amount_sats: row.amount_sats, + fee_sats: row.fee_sats, + recipient_node: row.recipient_node || undefined, + webhook_success: row.webhook_success === null ? undefined : row.webhook_success === 1, + webhook_response: row.webhook_response || undefined, + created_at: row.created_at + } +} + +/** + * WithdrawManager - Handles withdraw link operations + */ +export class WithdrawManager { + private baseUrl: string = '' + + constructor( + private db: ExtensionDatabase, + private ctx: ExtensionContext + ) {} + + /** + * Set the base URL for LNURL generation + */ + setBaseUrl(url: string): void { + this.baseUrl = url.replace(/\/$/, '') + } + + /** + * Add LNURL to a withdraw link + */ + private addLnurl(link: WithdrawLink): WithdrawLinkWithLnurl { + const lnurlUrl = buildLnurlUrl(this.baseUrl, link.unique_hash) + return { + ...link, + lnurl: encodeLnurl(lnurlUrl), + lnurl_url: lnurlUrl + } + } + + // ========================================================================= + // CRUD Operations + // ========================================================================= + + /** + * Create a new withdraw link + */ + async create(applicationId: string, req: CreateWithdrawLinkRequest): Promise { + // Validation + if (req.uses < 1 || req.uses > 250) { + throw new Error('Uses must be between 1 and 250') + } + if (req.min_withdrawable < 1) { + throw new Error('Min withdrawable must be at least 1 sat') + } + if (req.max_withdrawable < req.min_withdrawable) { + throw new Error('Max withdrawable must be >= min withdrawable') + } + if (req.wait_time < 0) { + throw new Error('Wait time cannot be negative') + } + + // Validate webhook JSON if provided + if (req.webhook_headers) { + try { + JSON.parse(req.webhook_headers) + } catch { + throw new Error('webhook_headers must be valid JSON') + } + } + if (req.webhook_body) { + try { + JSON.parse(req.webhook_body) + } catch { + throw new Error('webhook_body must be valid JSON') + } + } + + const now = Math.floor(Date.now() / 1000) + const id = generateId() + const usesCsv = Array.from({ length: req.uses }, (_, i) => String(i)).join(',') + + const link: WithdrawLink = { + id, + application_id: applicationId, + title: req.title.trim(), + description: req.description?.trim(), + min_withdrawable: req.min_withdrawable, + max_withdrawable: req.max_withdrawable, + uses: req.uses, + used: 0, + wait_time: req.wait_time, + unique_hash: generateUniqueHash(), + k1: generateK1(), + is_unique: req.is_unique || false, + uses_csv: usesCsv, + open_time: now, + webhook_url: req.webhook_url, + webhook_headers: req.webhook_headers, + webhook_body: req.webhook_body, + created_at: now, + updated_at: now + } + + await this.db.execute( + `INSERT INTO withdraw_links ( + id, application_id, title, description, + min_withdrawable, max_withdrawable, uses, used, wait_time, + unique_hash, k1, is_unique, uses_csv, open_time, + webhook_url, webhook_headers, webhook_body, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + link.id, link.application_id, link.title, link.description || null, + link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time, + link.unique_hash, link.k1, link.is_unique ? 1 : 0, link.uses_csv, link.open_time, + link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null, + link.created_at, link.updated_at + ] + ) + + return this.addLnurl(link) + } + + /** + * Create multiple vouchers (single-use withdraw links) + */ + async createVouchers( + applicationId: string, + title: string, + amount: number, + count: number, + description?: string + ): Promise { + if (count < 1 || count > 100) { + throw new Error('Count must be between 1 and 100') + } + if (amount < 1) { + throw new Error('Amount must be at least 1 sat') + } + + const vouchers: WithdrawLinkWithLnurl[] = [] + + for (let i = 0; i < count; i++) { + const voucher = await this.create(applicationId, { + title: `${title} #${i + 1}`, + description, + min_withdrawable: amount, + max_withdrawable: amount, + uses: 1, + wait_time: 0, + is_unique: false + }) + vouchers.push(voucher) + } + + return vouchers + } + + /** + * Get a withdraw link by ID + */ + async get(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM withdraw_links WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + if (rows.length === 0) return null + return this.addLnurl(rowToLink(rows[0])) + } + + /** + * Get a withdraw link by unique hash (for LNURL) + */ + async getByHash(uniqueHash: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM withdraw_links WHERE unique_hash = ?', + [uniqueHash] + ) + + if (rows.length === 0) return null + return rowToLink(rows[0]) + } + + /** + * List withdraw links for an application + */ + async list( + applicationId: string, + includeSpent: boolean = false, + limit?: number, + offset?: number + ): Promise { + let sql = 'SELECT * FROM withdraw_links WHERE application_id = ?' + const params: any[] = [applicationId] + + if (!includeSpent) { + sql += ' AND used < uses' + } + + sql += ' ORDER BY created_at DESC' + + if (limit) { + sql += ' LIMIT ?' + params.push(limit) + if (offset) { + sql += ' OFFSET ?' + params.push(offset) + } + } + + const rows = await this.db.query(sql, params) + return rows.map(row => this.addLnurl(rowToLink(row))) + } + + /** + * Update a withdraw link + */ + async update( + id: string, + applicationId: string, + req: UpdateWithdrawLinkRequest + ): Promise { + const existing = await this.get(id, applicationId) + if (!existing) return null + + // Validation + if (req.uses !== undefined) { + if (req.uses < 1 || req.uses > 250) { + throw new Error('Uses must be between 1 and 250') + } + if (req.uses < existing.used) { + throw new Error('Cannot reduce uses below current used count') + } + } + + const minWith = req.min_withdrawable ?? existing.min_withdrawable + const maxWith = req.max_withdrawable ?? existing.max_withdrawable + + if (minWith < 1) { + throw new Error('Min withdrawable must be at least 1 sat') + } + if (maxWith < minWith) { + throw new Error('Max withdrawable must be >= min withdrawable') + } + + // Handle uses change + let usesCsv = existing.uses_csv + const newUses = req.uses ?? existing.uses + if (newUses !== existing.uses) { + const currentUses = usesCsv.split(',').filter(u => u !== '') + if (newUses > existing.uses) { + // Add more uses + const lastNum = currentUses.length > 0 ? parseInt(currentUses[currentUses.length - 1], 10) : -1 + for (let i = lastNum + 1; currentUses.length < (newUses - existing.used); i++) { + currentUses.push(String(i)) + } + } else { + // Remove uses (keep first N) + usesCsv = currentUses.slice(0, newUses - existing.used).join(',') + } + usesCsv = currentUses.join(',') + } + + const now = Math.floor(Date.now() / 1000) + + await this.db.execute( + `UPDATE withdraw_links SET + title = ?, description = ?, + min_withdrawable = ?, max_withdrawable = ?, + uses = ?, wait_time = ?, is_unique = ?, uses_csv = ?, + webhook_url = ?, webhook_headers = ?, webhook_body = ?, + updated_at = ? + WHERE id = ? AND application_id = ?`, + [ + req.title ?? existing.title, + req.description ?? existing.description ?? null, + minWith, maxWith, + newUses, + req.wait_time ?? existing.wait_time, + (req.is_unique ?? existing.is_unique) ? 1 : 0, + usesCsv, + req.webhook_url ?? existing.webhook_url ?? null, + req.webhook_headers ?? existing.webhook_headers ?? null, + req.webhook_body ?? existing.webhook_body ?? null, + now, + id, applicationId + ] + ) + + return this.get(id, applicationId) + } + + /** + * Delete a withdraw link + */ + async delete(id: string, applicationId: string): Promise { + const result = await this.db.execute( + 'DELETE FROM withdraw_links WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + return (result.changes || 0) > 0 + } + + // ========================================================================= + // LNURL Protocol Handlers + // ========================================================================= + + /** + * Handle initial LNURL request (user scans QR) + * Returns withdraw parameters + */ + async handleLnurlRequest( + uniqueHash: string, + idUniqueHash?: string + ): Promise { + const link = await this.getByHash(uniqueHash) + + if (!link) { + return { status: 'ERROR', reason: 'Withdraw link does not exist.' } + } + + if (link.used >= link.uses) { + return { status: 'ERROR', reason: 'Withdraw link is spent.' } + } + + // For unique links, require id_unique_hash + if (link.is_unique && !idUniqueHash) { + return { status: 'ERROR', reason: 'This link requires a unique hash.' } + } + + // Verify unique hash if provided + if (idUniqueHash) { + const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, idUniqueHash) + if (!useNumber) { + return { status: 'ERROR', reason: 'Invalid unique hash.' } + } + } + + const callbackUrl = buildCallbackUrl(this.baseUrl, link.unique_hash) + + return { + tag: 'withdrawRequest', + callback: idUniqueHash ? `${callbackUrl}?id_unique_hash=${idUniqueHash}` : callbackUrl, + k1: link.k1, + minWithdrawable: satsToMsats(link.min_withdrawable), + maxWithdrawable: satsToMsats(link.max_withdrawable), + defaultDescription: link.title + } + } + + /** + * Handle LNURL callback (user submits invoice) + * Pays the invoice and records the withdrawal + */ + async handleLnurlCallback( + uniqueHash: string, + params: LnurlCallbackParams + ): Promise { + const link = await this.getByHash(uniqueHash) + + if (!link) { + return { status: 'ERROR', reason: 'Withdraw link not found.' } + } + + if (link.used >= link.uses) { + return { status: 'ERROR', reason: 'Withdraw link is spent.' } + } + + if (link.k1 !== params.k1) { + return { status: 'ERROR', reason: 'Invalid k1.' } + } + + // Check wait time + const now = Math.floor(Date.now() / 1000) + if (now < link.open_time) { + const waitSecs = link.open_time - now + return { status: 'ERROR', reason: `Please wait ${waitSecs} seconds.` } + } + + // For unique links, verify and consume the use hash + if (params.id_unique_hash) { + const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, params.id_unique_hash) + if (!useNumber) { + return { status: 'ERROR', reason: 'Invalid unique hash.' } + } + } else if (link.is_unique) { + return { status: 'ERROR', reason: 'Unique hash required.' } + } + + // Prevent double-spending with hash check + try { + await this.createHashCheck(params.id_unique_hash || uniqueHash, params.k1) + } catch { + return { status: 'ERROR', reason: 'Withdrawal already in progress.' } + } + + try { + // Pay the invoice + const payment = await this.ctx.payInvoice( + link.application_id, + params.pr, + link.max_withdrawable + ) + + // Record the withdrawal + await this.recordWithdrawal(link, payment.paymentHash, link.max_withdrawable, payment.feeSats) + + // Increment usage + await this.incrementUsage(link, params.id_unique_hash) + + // Clean up hash check + await this.deleteHashCheck(params.id_unique_hash || uniqueHash) + + // Dispatch webhook if configured + if (link.webhook_url) { + this.dispatchWebhook(link, payment.paymentHash, params.pr).catch(err => { + console.error('[Withdraw] Webhook error:', err) + }) + } + + return { status: 'OK' } + } catch (err: any) { + // Clean up hash check on failure + await this.deleteHashCheck(params.id_unique_hash || uniqueHash) + return { status: 'ERROR', reason: `Payment failed: ${err.message}` } + } + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Increment link usage and update open_time + */ + private async incrementUsage(link: WithdrawLink, idUniqueHash?: string): Promise { + const now = Math.floor(Date.now() / 1000) + let usesCsv = link.uses_csv + + // Remove used hash from uses_csv if unique + if (idUniqueHash) { + const uses = usesCsv.split(',').filter(u => { + const hash = generateUseHash(link.id, link.unique_hash, u.trim()) + return hash !== idUniqueHash + }) + usesCsv = uses.join(',') + } + + await this.db.execute( + `UPDATE withdraw_links SET + used = used + 1, + open_time = ?, + uses_csv = ?, + updated_at = ? + WHERE id = ?`, + [now + link.wait_time, usesCsv, now, link.id] + ) + } + + /** + * Record a successful withdrawal + */ + private async recordWithdrawal( + link: WithdrawLink, + paymentHash: string, + amountSats: number, + feeSats: number + ): Promise { + const now = Math.floor(Date.now() / 1000) + + await this.db.execute( + `INSERT INTO withdrawals ( + id, link_id, application_id, + payment_hash, amount_sats, fee_sats, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + generateId(), + link.id, + link.application_id, + paymentHash, + amountSats, + feeSats, + now + ] + ) + } + + /** + * Create hash check to prevent double-spending + */ + private async createHashCheck(hash: string, k1: string): Promise { + const now = Math.floor(Date.now() / 1000) + await this.db.execute( + 'INSERT INTO hash_checks (hash, k1, created_at) VALUES (?, ?, ?)', + [hash, k1, now] + ) + } + + /** + * Delete hash check after completion + */ + private async deleteHashCheck(hash: string): Promise { + await this.db.execute('DELETE FROM hash_checks WHERE hash = ?', [hash]) + } + + /** + * List withdrawals + */ + async listWithdrawals( + applicationId: string, + linkId?: string, + limit?: number, + offset?: number + ): Promise { + let sql = 'SELECT * FROM withdrawals WHERE application_id = ?' + const params: any[] = [applicationId] + + if (linkId) { + sql += ' AND link_id = ?' + params.push(linkId) + } + + sql += ' ORDER BY created_at DESC' + + if (limit) { + sql += ' LIMIT ?' + params.push(limit) + if (offset) { + sql += ' OFFSET ?' + params.push(offset) + } + } + + const rows = await this.db.query(sql, params) + return rows.map(rowToWithdrawal) + } + + /** + * Get withdrawal stats for a link + */ + async getWithdrawalStats(linkId: string): Promise<{ total_sats: number; count: number }> { + const result = await this.db.query<{ total: number; count: number }>( + `SELECT COALESCE(SUM(amount_sats), 0) as total, COUNT(*) as count + FROM withdrawals WHERE link_id = ?`, + [linkId] + ) + return { + total_sats: result[0]?.total || 0, + count: result[0]?.count || 0 + } + } + + /** + * Dispatch webhook notification + */ + private async dispatchWebhook( + link: WithdrawLink, + paymentHash: string, + paymentRequest: string + ): Promise { + if (!link.webhook_url) return + + try { + const headers: Record = { + 'Content-Type': 'application/json' + } + + if (link.webhook_headers) { + Object.assign(headers, JSON.parse(link.webhook_headers)) + } + + const body = { + payment_hash: paymentHash, + payment_request: paymentRequest, + lnurlw: link.id, + body: link.webhook_body ? JSON.parse(link.webhook_body) : {} + } + + const response = await fetch(link.webhook_url, { + method: 'POST', + headers, + body: JSON.stringify(body) + }) + + // Update withdrawal record with webhook result + await this.db.execute( + `UPDATE withdrawals SET + webhook_success = ?, + webhook_response = ? + WHERE payment_hash = ?`, + [response.ok ? 1 : 0, await response.text(), paymentHash] + ) + } catch (err: any) { + await this.db.execute( + `UPDATE withdrawals SET + webhook_success = 0, + webhook_response = ? + WHERE payment_hash = ?`, + [err.message, paymentHash] + ) + } + } +} diff --git a/src/extensions/withdraw/migrations.ts b/src/extensions/withdraw/migrations.ts new file mode 100644 index 00000000..86886885 --- /dev/null +++ b/src/extensions/withdraw/migrations.ts @@ -0,0 +1,153 @@ +/** + * LNURL-withdraw Extension Database Migrations + */ + +import { ExtensionDatabase } from '../types.js' + +export interface Migration { + version: number + name: string + up: (db: ExtensionDatabase) => Promise + down?: (db: ExtensionDatabase) => Promise +} + +export const migrations: Migration[] = [ + { + version: 1, + name: 'create_withdraw_links_table', + up: async (db: ExtensionDatabase) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS withdraw_links ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + + -- Display + title TEXT NOT NULL, + description TEXT, + + -- Amounts (sats) + min_withdrawable INTEGER NOT NULL, + max_withdrawable INTEGER NOT NULL, + + -- Usage limits + uses INTEGER NOT NULL DEFAULT 1, + used INTEGER NOT NULL DEFAULT 0, + wait_time INTEGER NOT NULL DEFAULT 0, + + -- Security + unique_hash TEXT NOT NULL UNIQUE, + k1 TEXT NOT NULL, + is_unique INTEGER NOT NULL DEFAULT 0, + uses_csv TEXT NOT NULL DEFAULT '', + + -- Rate limiting + open_time INTEGER NOT NULL DEFAULT 0, + + -- Webhooks + webhook_url TEXT, + webhook_headers TEXT, + webhook_body TEXT, + + -- Timestamps + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + + // Index for looking up by unique_hash (LNURL) + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_withdraw_links_unique_hash + ON withdraw_links(unique_hash) + `) + + // Index for listing by application + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_withdraw_links_application + ON withdraw_links(application_id, created_at DESC) + `) + } + }, + { + version: 2, + name: 'create_withdrawals_table', + up: async (db: ExtensionDatabase) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS withdrawals ( + id TEXT PRIMARY KEY, + link_id TEXT NOT NULL, + application_id TEXT NOT NULL, + + -- Payment details + payment_hash TEXT NOT NULL, + amount_sats INTEGER NOT NULL, + fee_sats INTEGER NOT NULL DEFAULT 0, + + -- Recipient + recipient_node TEXT, + + -- Webhook result + webhook_success INTEGER, + webhook_response TEXT, + + -- Timestamp + created_at INTEGER NOT NULL, + + FOREIGN KEY (link_id) REFERENCES withdraw_links(id) ON DELETE CASCADE + ) + `) + + // Index for listing withdrawals by link + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_withdrawals_link + ON withdrawals(link_id, created_at DESC) + `) + + // Index for looking up by payment hash + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_withdrawals_payment_hash + ON withdrawals(payment_hash) + `) + } + }, + { + version: 3, + name: 'create_hash_checks_table', + up: async (db: ExtensionDatabase) => { + // Temporary table to prevent double-spending during payment processing + await db.execute(` + CREATE TABLE IF NOT EXISTS hash_checks ( + hash TEXT PRIMARY KEY, + k1 TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `) + } + } +] + +/** + * Run all pending migrations + */ +export async function runMigrations(db: ExtensionDatabase): Promise { + // Get current version + const versionResult = await db.query<{ value: string }>( + `SELECT value FROM _extension_meta WHERE key = 'migration_version'` + ).catch(() => []) + + const currentVersion = versionResult.length > 0 ? parseInt(versionResult[0].value, 10) : 0 + + // Run pending migrations + for (const migration of migrations) { + if (migration.version > currentVersion) { + console.log(`[Withdraw] Running migration ${migration.version}: ${migration.name}`) + await migration.up(db) + + // Update version + await db.execute( + `INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [String(migration.version)] + ) + } + } +} diff --git a/src/extensions/withdraw/types.ts b/src/extensions/withdraw/types.ts new file mode 100644 index 00000000..d9d05da0 --- /dev/null +++ b/src/extensions/withdraw/types.ts @@ -0,0 +1,261 @@ +/** + * LNURL-withdraw Extension Types + * Implements LUD-03 (LNURL-withdraw) for Lightning.Pub + */ + +// Re-export base extension types +export { + Extension, + ExtensionInfo, + ExtensionContext, + ExtensionDatabase, + ApplicationInfo, + RpcMethodHandler +} from '../types.js' + +// ============================================================================ +// Core Data Types +// ============================================================================ + +/** + * A withdraw link that can be used to pull funds + */ +export interface WithdrawLink { + id: string + application_id: string + + // Display + title: string + description?: string + + // Amounts (in sats) + min_withdrawable: number + max_withdrawable: number + + // Usage limits + uses: number // Total allowed uses + used: number // Times used so far + wait_time: number // Seconds between uses + + // Security + unique_hash: string // For LNURL URL + k1: string // Challenge for callback + is_unique: boolean // Generate unique code per use + uses_csv: string // Comma-separated list of available use IDs + + // Rate limiting + open_time: number // Unix timestamp when next use is allowed + + // Webhook notifications + webhook_url?: string + webhook_headers?: string // JSON string + webhook_body?: string // JSON string + + // Timestamps + created_at: number + updated_at: number +} + +/** + * Withdrawal record - tracks each successful withdrawal + */ +export interface Withdrawal { + id: string + link_id: string + application_id: string + + // Payment details + payment_hash: string + amount_sats: number + fee_sats: number + + // Recipient (if known) + recipient_node?: string + + // Webhook result + webhook_success?: boolean + webhook_response?: string + + // Timestamp + created_at: number +} + +/** + * Hash check - prevents double-spending during payment + */ +export interface HashCheck { + hash: string + k1: string + created_at: number +} + +// ============================================================================ +// LNURL Protocol Types (LUD-03) +// ============================================================================ + +/** + * LNURL-withdraw response (first call) + * Returned when user scans the QR code + */ +export interface LnurlWithdrawResponse { + tag: 'withdrawRequest' + callback: string // URL to call with invoice + k1: string // Challenge + minWithdrawable: number // Millisats + maxWithdrawable: number // Millisats + defaultDescription: string +} + +/** + * LNURL error response + */ +export interface LnurlErrorResponse { + status: 'ERROR' + reason: string +} + +/** + * LNURL success response + */ +export interface LnurlSuccessResponse { + status: 'OK' +} + +// ============================================================================ +// RPC Request/Response Types +// ============================================================================ + +/** + * Create a new withdraw link + */ +export interface CreateWithdrawLinkRequest { + title: string + description?: string + min_withdrawable: number // sats + max_withdrawable: number // sats + uses: number // 1-250 + wait_time: number // seconds between uses + is_unique?: boolean // generate unique code per use + webhook_url?: string + webhook_headers?: string // JSON + webhook_body?: string // JSON +} + +/** + * Update an existing withdraw link + */ +export interface UpdateWithdrawLinkRequest { + id: string + title?: string + description?: string + min_withdrawable?: number + max_withdrawable?: number + uses?: number + wait_time?: number + is_unique?: boolean + webhook_url?: string + webhook_headers?: string + webhook_body?: string +} + +/** + * Get withdraw link by ID + */ +export interface GetWithdrawLinkRequest { + id: string +} + +/** + * List withdraw links + */ +export interface ListWithdrawLinksRequest { + include_spent?: boolean // Include fully used links + limit?: number + offset?: number +} + +/** + * Delete withdraw link + */ +export interface DeleteWithdrawLinkRequest { + id: string +} + +/** + * Create quick vouchers (batch of single-use links) + */ +export interface CreateVouchersRequest { + title: string + amount: number // sats per voucher + count: number // number of vouchers (1-100) + description?: string +} + +/** + * Get withdraw link with LNURL + */ +export interface WithdrawLinkWithLnurl extends WithdrawLink { + lnurl: string // bech32 encoded LNURL + lnurl_url: string // raw callback URL +} + +/** + * List withdrawals for a link + */ +export interface ListWithdrawalsRequest { + link_id?: string + limit?: number + offset?: number +} + +/** + * Withdraw link response with stats + */ +export interface WithdrawLinkResponse { + link: WithdrawLinkWithLnurl + total_withdrawn_sats: number + withdrawals_count: number +} + +/** + * Vouchers response + */ +export interface VouchersResponse { + vouchers: WithdrawLinkWithLnurl[] + total_amount_sats: number +} + +// ============================================================================ +// HTTP Handler Types +// ============================================================================ + +/** + * LNURL callback parameters + */ +export interface LnurlCallbackParams { + k1: string // Challenge from initial response + pr: string // Payment request (BOLT11 invoice) + id_unique_hash?: string // For unique links +} + +/** + * HTTP route handler + */ +export interface HttpRoute { + method: 'GET' | 'POST' + path: string + handler: (req: HttpRequest) => Promise +} + +export interface HttpRequest { + params: Record + query: Record + body?: any + headers: Record +} + +export interface HttpResponse { + status: number + body: any + headers?: Record +} diff --git a/src/extensions/withdraw/utils/lnurl.ts b/src/extensions/withdraw/utils/lnurl.ts new file mode 100644 index 00000000..96926c52 --- /dev/null +++ b/src/extensions/withdraw/utils/lnurl.ts @@ -0,0 +1,131 @@ +/** + * LNURL Encoding Utilities + * + * LNURL is a bech32-encoded URL with hrp "lnurl" + * See: https://github.com/lnurl/luds + */ + +import { bech32 } from 'bech32' +import crypto from 'crypto' + +/** + * Encode a URL as LNURL (bech32) + */ +export function encodeLnurl(url: string): string { + const words = bech32.toWords(Buffer.from(url, 'utf8')) + return bech32.encode('lnurl', words, 2000) // 2000 char limit for URLs +} + +/** + * Decode an LNURL to a URL + */ +export function decodeLnurl(lnurl: string): string { + const { prefix, words } = bech32.decode(lnurl, 2000) + if (prefix !== 'lnurl') { + throw new Error('Invalid LNURL prefix') + } + return Buffer.from(bech32.fromWords(words)).toString('utf8') +} + +/** + * Generate a URL-safe random ID + */ +export function generateId(length: number = 22): string { + const bytes = crypto.randomBytes(Math.ceil(length * 3 / 4)) + return bytes.toString('base64url').slice(0, length) +} + +/** + * Generate a k1 challenge (32 bytes hex) + */ +export function generateK1(): string { + return crypto.randomBytes(32).toString('hex') +} + +/** + * Generate a unique hash for a link + */ +export function generateUniqueHash(): string { + return generateId(32) +} + +/** + * Generate a unique hash for a specific use of a link + * This creates a deterministic hash based on link ID, unique_hash, and use number + */ +export function generateUseHash(linkId: string, uniqueHash: string, useNumber: string): string { + const data = `${linkId}${uniqueHash}${useNumber}` + return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32) +} + +/** + * Verify a use hash matches one of the available uses + */ +export function verifyUseHash( + linkId: string, + uniqueHash: string, + usesCsv: string, + providedHash: string +): string | null { + const uses = usesCsv.split(',').filter(u => u.trim() !== '') + + for (const useNumber of uses) { + const expectedHash = generateUseHash(linkId, uniqueHash, useNumber.trim()) + if (expectedHash === providedHash) { + return useNumber.trim() + } + } + + return null +} + +/** + * Build the LNURL callback URL for a withdraw link + */ +export function buildLnurlUrl(baseUrl: string, uniqueHash: string): string { + // Remove trailing slash from baseUrl + const base = baseUrl.replace(/\/$/, '') + return `${base}/api/v1/lnurl/${uniqueHash}` +} + +/** + * Build the LNURL callback URL for a unique withdraw link + */ +export function buildUniqueLnurlUrl( + baseUrl: string, + uniqueHash: string, + useHash: string +): string { + const base = baseUrl.replace(/\/$/, '') + return `${base}/api/v1/lnurl/${uniqueHash}/${useHash}` +} + +/** + * Build the callback URL for the second step (where user sends invoice) + */ +export function buildCallbackUrl(baseUrl: string, uniqueHash: string): string { + const base = baseUrl.replace(/\/$/, '') + return `${base}/api/v1/lnurl/cb/${uniqueHash}` +} + +/** + * Sats to millisats + */ +export function satsToMsats(sats: number): number { + return sats * 1000 +} + +/** + * Millisats to sats + */ +export function msatsToSats(msats: number): number { + return Math.floor(msats / 1000) +} + +/** + * Validate a BOLT11 invoice (basic check) + */ +export function isValidBolt11(invoice: string): boolean { + const lower = invoice.toLowerCase() + return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt') +}