From 8de5e4fd3a707c113633a66326cd96da81894674 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 13:38:13 -0500 Subject: [PATCH 01/13] 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') +} From e998762ca713aee19eff82323c2a0cf23c826299 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 14 Feb 2026 13:12:08 -0500 Subject: [PATCH 02/13] feat: integrate extension system with withdraw extension support - Add extension loader initialization to startup - Create mainHandlerAdapter to bridge mainHandler with extension context - Mount extension HTTP routes on separate port (main port + 1) - Configure EXTENSION_SERVICE_URL for LNURL link generation The withdraw extension provides LUD-03 LNURL-withdraw support for creating withdraw links that allow users to pull funds. Co-Authored-By: Claude Opus 4.5 --- src/extensions/mainHandlerAdapter.ts | 128 +++++++++++++++++++++++++++ src/index.ts | 94 +++++++++++++++++++- 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/extensions/mainHandlerAdapter.ts diff --git a/src/extensions/mainHandlerAdapter.ts b/src/extensions/mainHandlerAdapter.ts new file mode 100644 index 00000000..eecc96b3 --- /dev/null +++ b/src/extensions/mainHandlerAdapter.ts @@ -0,0 +1,128 @@ +/** + * MainHandler Adapter for Extension System + * + * Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface + * required by the extension system. + */ + +import { MainHandlerInterface } from './context.js' +import { LnurlPayInfo } from './types.js' +import type Main from '../services/main/index.js' + +/** + * Create an adapter that wraps mainHandler for extension use + */ +export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface { + return { + applicationManager: { + async getById(id: string) { + // The applicationManager stores apps internally + // We need to access it through the storage layer + try { + const app = await mainHandler.storage.applicationStorage.GetApplication(id) + if (!app) return null + + return { + id: app.app_id, + name: app.name, + nostr_public: app.nostr_public_key || '', + balance: app.owner?.balance_sats || 0 + } + } catch (e) { + // GetApplication throws if not found + return null + } + } + }, + + paymentManager: { + async createInvoice(params: { + applicationId: string + amountSats: number + memo?: string + expiry?: number + metadata?: Record + }) { + // Get the app to find the user ID + const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId) + if (!app) { + throw new Error(`Application not found: ${params.applicationId}`) + } + + // Create invoice using the app owner's user ID + const result = await mainHandler.paymentManager.NewInvoice( + app.owner.user_id, + { + amountSats: params.amountSats, + memo: params.memo || '' + }, + { + expiry: params.expiry || 3600 + } + ) + + return { + id: result.invoice.split(':')[0] || result.invoice, // Extract ID if present + paymentRequest: result.invoice, + paymentHash: '', // Not directly available from NewInvoice response + expiry: Date.now() + (params.expiry || 3600) * 1000 + } + }, + + async payInvoice(params: { + applicationId: string + paymentRequest: string + maxFeeSats?: number + }) { + // Get the app to find the user ID and app reference + const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId) + if (!app) { + throw new Error(`Application not found: ${params.applicationId}`) + } + + // Pay invoice from the app's balance + const result = await mainHandler.paymentManager.PayInvoice( + app.owner.user_id, + { + invoice: params.paymentRequest, + amount: 0 // Use invoice amount + }, + app, // linkedApplication + {} + ) + + return { + paymentHash: result.preimage || '', // preimage serves as proof of payment + feeSats: result.network_fee || 0 + } + }, + + async getLnurlPayInfoByPubkey(pubkeyHex: string, options?: { + metadata?: string + description?: string + }): Promise { + // This would need implementation based on how Lightning.Pub handles LNURL-pay + // For now, throw not implemented + throw new Error('getLnurlPayInfoByPubkey not yet implemented') + } + }, + + async sendNostrEvent(event: any): Promise { + // The mainHandler doesn't directly expose nostrSend + // This would need to be implemented through the nostrMiddleware + // For now, return null (not implemented) + console.warn('[MainHandlerAdapter] sendNostrEvent not fully implemented') + return null + }, + + async sendEncryptedDM( + applicationId: string, + recipientPubkey: string, + content: string + ): Promise { + // This would need implementation using NIP-44 encryption + // For now, throw not implemented + throw new Error('sendEncryptedDM not yet implemented') + } + } +} diff --git a/src/index.ts b/src/index.ts index fbe6802c..87181da2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ import 'dotenv/config' +import express from 'express' +import path from 'path' +import { fileURLToPath } from 'url' import NewServer from '../proto/autogenerated/ts/express_server.js' import GetServerMethods from './services/serverMethods/index.js' import serverOptions from './auth.js'; @@ -8,9 +11,15 @@ import { initMainHandler, initSettings } from './services/main/init.js'; import { nip19 } from 'nostr-tools' import { LoadStorageSettingsFromEnv } from './services/storage/index.js'; import { AppInfo } from './services/nostr/nostrPool.js'; +import { createExtensionLoader, ExtensionLoader } from './extensions/loader.js' +import { createMainHandlerAdapter } from './extensions/mainHandlerAdapter.js' +import type { HttpRoute } from './extensions/withdraw/types.js' //@ts-ignore const { nprofileEncode } = nip19 +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + const start = async () => { const log = getLogger({}) @@ -58,8 +67,91 @@ const start = async () => { wizard.AddConnectInfo(appNprofile, relays) } adminManager.setAppNprofile(appNprofile) + + // Initialize extension system + let extensionLoader: ExtensionLoader | null = null + const mainPort = settingsManager.getSettings().serviceSettings.servicePort + const extensionPort = mainPort + 1 + + // Extension routes run on a separate port (main port + 1) + // SERVICE_URL for extensions should point to this port for LNURL to work + // In production, use a reverse proxy to route /api/v1/lnurl/* to extension port + const extensionServiceUrl = process.env.EXTENSION_SERVICE_URL || `http://localhost:${extensionPort}` + + try { + log("initializing extension system") + const extensionsDir = path.join(__dirname, 'extensions') + const databaseDir = path.join(__dirname, '..', 'data', 'extensions') + + const mainHandlerAdapter = createMainHandlerAdapter(mainHandler) + extensionLoader = createExtensionLoader( + { extensionsDir, databaseDir }, + mainHandlerAdapter + ) + + await extensionLoader.loadAll() + log(`loaded ${extensionLoader.getAllExtensions().length} extension(s)`) + + // Set base URL for LNURL generation on withdraw extension + const withdrawExt = extensionLoader.getExtension('withdraw') + if (withdrawExt && withdrawExt.instance && 'setBaseUrl' in withdrawExt.instance) { + (withdrawExt.instance as any).setBaseUrl(extensionServiceUrl) + log(`withdraw extension base URL set to ${extensionServiceUrl}`) + } + } catch (e) { + log(`extension system initialization failed: ${e}`) + } + + // Create Express app for extension HTTP routes + const extensionApp = express() + extensionApp.use(express.json()) + + // Mount extension HTTP routes + if (extensionLoader) { + for (const ext of extensionLoader.getAllExtensions()) { + if (ext.status === 'ready' && 'getHttpRoutes' in ext.instance) { + const routes = (ext.instance as any).getHttpRoutes() as HttpRoute[] + for (const route of routes) { + log(`mounting extension route: ${route.method} ${route.path}`) + const handler = async (req: express.Request, res: express.Response) => { + try { + const httpReq = { + params: req.params, + query: req.query as Record, + body: req.body, + headers: req.headers as Record + } + const result = await route.handler(httpReq) + res.status(result.status) + if (result.headers) { + for (const [key, value] of Object.entries(result.headers)) { + res.setHeader(key, value) + } + } + res.json(result.body) + } catch (e: any) { + log(`extension route error: ${e.message}`) + res.status(500).json({ status: 'ERROR', reason: e.message }) + } + } + if (route.method === 'GET') { + extensionApp.get(route.path, handler) + } else if (route.method === 'POST') { + extensionApp.post(route.path, handler) + } + } + } + } + } + + // Start extension routes server + extensionApp.listen(extensionPort, () => { + log(`extension HTTP routes listening on port ${extensionPort}`) + }) + + // Start main proto server const Server = NewServer(serverMethods, serverOptions(mainHandler)) - Server.Listen(settingsManager.getSettings().serviceSettings.servicePort) + Server.Listen(mainPort) } start() From f06d50f2272a0ea0fbb8d2067fca6770dfb92b65 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 14 Feb 2026 15:28:54 -0500 Subject: [PATCH 03/13] feat(server): add CORS support for extension HTTP routes Enable CORS on the extension HTTP server to allow cross-origin requests from ATM apps and other web-based clients. Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 87181da2..2788c5d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import express from 'express' +import cors from 'cors' import path from 'path' import { fileURLToPath } from 'url' import NewServer from '../proto/autogenerated/ts/express_server.js' @@ -104,6 +105,7 @@ const start = async () => { // Create Express app for extension HTTP routes const extensionApp = express() + extensionApp.use(cors()) // Enable CORS for all origins (ATM apps, wallets, etc.) extensionApp.use(express.json()) // Mount extension HTTP routes From 3ee8b6b0106fcad7a1f14c132060642823a7e9b5 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 14 Feb 2026 15:29:04 -0500 Subject: [PATCH 04/13] feat(withdraw): add HTTP API for creating withdraw links Add POST /api/v1/withdraw/create endpoint to allow external apps (ATM, web clients) to create LNURL-withdraw links via HTTP instead of RPC. Changes: - Add handleCreateWithdrawLink HTTP handler - Fix route ordering: callback routes before wildcard :unique_hash - Extract app_id from Authorization header (Bearer app_) - Use is_unique=false for simple single-use ATM links Co-Authored-By: Claude Opus 4.5 --- src/extensions/withdraw/index.ts | 91 +++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/src/extensions/withdraw/index.ts b/src/extensions/withdraw/index.ts index 0b8259d6..dacf045e 100644 --- a/src/extensions/withdraw/index.ts +++ b/src/extensions/withdraw/index.ts @@ -82,11 +82,17 @@ export default class WithdrawExtension implements Extension { */ getHttpRoutes(): HttpRoute[] { return [ - // Initial LNURL request (simple link) + // Create withdraw link (HTTP API for ATM/external integrations) + { + method: 'POST', + path: '/api/v1/withdraw/create', + handler: this.handleCreateWithdrawLink.bind(this) + }, + // LNURL callback (user submits invoice) - MUST be before :unique_hash routes { method: 'GET', - path: '/api/v1/lnurl/:unique_hash', - handler: this.handleLnurlRequest.bind(this) + path: '/api/v1/lnurl/cb/:unique_hash', + handler: this.handleLnurlCallback.bind(this) }, // Initial LNURL request (unique link with use hash) { @@ -94,11 +100,11 @@ export default class WithdrawExtension implements Extension { path: '/api/v1/lnurl/:unique_hash/:id_unique_hash', handler: this.handleLnurlUniqueRequest.bind(this) }, - // LNURL callback (user submits invoice) + // Initial LNURL request (simple link) - MUST be last (catches all) { method: 'GET', - path: '/api/v1/lnurl/cb/:unique_hash', - handler: this.handleLnurlCallback.bind(this) + path: '/api/v1/lnurl/:unique_hash', + handler: this.handleLnurlRequest.bind(this) } ] } @@ -231,6 +237,79 @@ export default class WithdrawExtension implements Extension { // HTTP Route Handlers // ========================================================================= + /** + * Handle create withdraw link request (HTTP API) + * POST /api/v1/withdraw/create + * + * Body: { + * title: string + * min_withdrawable: number (sats) + * max_withdrawable: number (sats) + * uses?: number (defaults to 1) + * wait_time?: number (seconds between uses, defaults to 0) + * } + * + * Auth: Bearer token in Authorization header (app_) + * + * Returns: { + * link: { lnurl, unique_hash, id, ... } + * } + */ + private async handleCreateWithdrawLink(req: HttpRequest): Promise { + try { + const { title, min_withdrawable, max_withdrawable, uses, wait_time } = req.body + + // Extract app_id from Authorization header (Bearer app_) + const authHeader = req.headers?.authorization || req.headers?.Authorization || '' + let app_id = 'default' + if (authHeader.startsWith('Bearer app_')) { + app_id = authHeader.replace('Bearer app_', '') + } + + if (!title || !min_withdrawable) { + return { + status: 400, + body: { status: 'ERROR', reason: 'Missing required fields: title, min_withdrawable' }, + headers: { 'Content-Type': 'application/json' } + } + } + + const link = await this.manager.create(app_id, { + title, + min_withdrawable, + max_withdrawable: max_withdrawable || min_withdrawable, + uses: uses || 1, + wait_time: wait_time || 0, + is_unique: false // Simple single-use links for ATM + }) + + // Return in format expected by ATM client + return { + status: 200, + body: { + status: 'OK', + link: { + lnurl: link.lnurl, + unique_hash: link.unique_hash, + id: link.id, + title: link.title, + min_withdrawable: link.min_withdrawable, + max_withdrawable: link.max_withdrawable, + uses: link.uses, + used: link.used + } + }, + headers: { 'Content-Type': 'application/json' } + } + } catch (error: any) { + return { + status: 500, + body: { status: 'ERROR', reason: error.message }, + headers: { 'Content-Type': 'application/json' } + } + } + } + /** * Handle initial LNURL request (simple link) * GET /api/v1/lnurl/:unique_hash From 1273da90206529a14041ce70ddad94f1e4d33bf3 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 16:55:53 -0500 Subject: [PATCH 05/13] feat: route Nostr RPC to extension methods Initialize extension system before nostrMiddleware so registered RPC methods are available. Extension methods (e.g. withdraw.createLink) are intercepted and routed to the extension loader before falling through to the standard nostrTransport. Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 71 ++++++++++++++++++++++-------------------- src/nostrMiddleware.ts | 33 +++++++++++++++++++- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2788c5d0..5fbb21dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,41 +35,8 @@ const start = async () => { const { mainHandler, localProviderClient, wizard, adminManager } = keepOn const serverMethods = GetServerMethods(mainHandler) - log("initializing nostr middleware") - const relays = settingsManager.getSettings().nostrRelaySettings.relays - const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength - const apps: AppInfo[] = keepOn.apps.map(app => { - return { - appId: app.appId, - privateKey: app.privateKey, - publicKey: app.publicKey, - name: app.name, - provider: app.publicKey === localProviderClient.publicKey ? { - clientId: `client_${localProviderClient.appId}`, - pubkey: settingsManager.getSettings().liquiditySettings.liquidityProviderPub, - relayUrl: settingsManager.getSettings().liquiditySettings.providerRelayUrl - } : undefined - } - }) - const { Send, Stop, Ping, Reset } = nostrMiddleware(serverMethods, mainHandler, - { - relays, maxEventContentLength, apps - }, - (e, p) => mainHandler.liquidityProvider.onEvent(e, p) - ) - exitHandler(() => { Stop(); mainHandler.Stop() }) - log("starting server") - mainHandler.attachNostrSend(Send) - mainHandler.attachNostrProcessPing(Ping) - mainHandler.attachNostrReset(Reset) - mainHandler.StartBeacons() - const appNprofile = nprofileEncode({ pubkey: localProviderClient.publicKey, relays }) - if (wizard) { - wizard.AddConnectInfo(appNprofile, relays) - } - adminManager.setAppNprofile(appNprofile) - // Initialize extension system + // Initialize extension system BEFORE nostrMiddleware so RPC methods are available let extensionLoader: ExtensionLoader | null = null const mainPort = settingsManager.getSettings().serviceSettings.servicePort const extensionPort = mainPort + 1 @@ -103,6 +70,42 @@ const start = async () => { log(`extension system initialization failed: ${e}`) } + // Initialize nostr middleware with extension loader for RPC routing + log("initializing nostr middleware") + const relays = settingsManager.getSettings().nostrRelaySettings.relays + const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength + const apps: AppInfo[] = keepOn.apps.map(app => { + return { + appId: app.appId, + privateKey: app.privateKey, + publicKey: app.publicKey, + name: app.name, + provider: app.publicKey === localProviderClient.publicKey ? { + clientId: `client_${localProviderClient.appId}`, + pubkey: settingsManager.getSettings().liquiditySettings.liquidityProviderPub, + relayUrl: settingsManager.getSettings().liquiditySettings.providerRelayUrl + } : undefined + } + }) + const { Send, Stop, Ping, Reset } = nostrMiddleware(serverMethods, mainHandler, + { + relays, maxEventContentLength, apps + }, + (e, p) => mainHandler.liquidityProvider.onEvent(e, p), + { extensionLoader: extensionLoader || undefined } + ) + exitHandler(() => { Stop(); mainHandler.Stop() }) + log("starting server") + mainHandler.attachNostrSend(Send) + mainHandler.attachNostrProcessPing(Ping) + mainHandler.attachNostrReset(Reset) + mainHandler.StartBeacons() + const appNprofile = nprofileEncode({ pubkey: localProviderClient.publicKey, relays }) + if (wizard) { + wizard.AddConnectInfo(appNprofile, relays) + } + adminManager.setAppNprofile(appNprofile) + // Create Express app for extension HTTP routes const extensionApp = express() extensionApp.use(cors()) // Enable CORS for all origins (ATM apps, wallets, etc.) diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 4dd3b281..a7131375 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -5,9 +5,15 @@ import * as Types from '../proto/autogenerated/ts/types.js' import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js'; import { ERROR, getLogger } from "./services/helpers/logger.js"; import { NdebitData, NofferData, NmanageRequest } from "@shocknet/clink-sdk"; +import type { ExtensionLoader } from "./extensions/loader.js" type ExportedCalls = { Stop: () => void, Send: NostrSend, Ping: () => Promise, Reset: (settings: NostrSettings) => void } type ClientEventCallback = (e: { requestId: string }, fromPub: string) => void -export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback): ExportedCalls => { + +export type NostrMiddlewareOptions = { + extensionLoader?: ExtensionLoader +} + +export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback, options?: NostrMiddlewareOptions): ExportedCalls => { const log = getLogger({}) const nostrTransport = NewNostrTransport(serverMethods, { NostrUserAuthGuard: async (appId, pub) => { @@ -95,6 +101,31 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett log(ERROR, "authIdentifier does not match", j.authIdentifier || "--", event.pub) return } + + // Check if this is an extension RPC method + const extensionLoader = options?.extensionLoader + if (extensionLoader && j.rpcName && extensionLoader.hasMethod(j.rpcName)) { + // Route to extension + log(`[Nostr] Routing to extension method: ${j.rpcName}`) + extensionLoader.callMethod(j.rpcName, j.body || {}, event.appId, event.pub) + .then(result => { + const response = { status: 'OK', requestId: j.requestId, ...result } + nostr.Send( + { type: 'app', appId: event.appId }, + { type: 'content', pub: event.pub, content: JSON.stringify(response) } + ) + }) + .catch(err => { + log(ERROR, `Extension method ${j.rpcName} failed:`, err.message) + const response = { status: 'ERROR', requestId: j.requestId, reason: err.message } + nostr.Send( + { type: 'app', appId: event.appId }, + { type: 'content', pub: event.pub, content: JSON.stringify(response) } + ) + }) + return + } + nostrTransport({ ...j, appId: event.appId }, res => { nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) }) }, event.startAtNano, event.startAtMs) From cb9fb78eb889c4b2dc565c3921ba1894ae05e011 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 16:56:21 -0500 Subject: [PATCH 06/13] feat(withdraw): track creator pubkey on withdraw links Store the Nostr pubkey of the user who creates a withdraw link so the LNURL callback debits the correct user's balance instead of the app owner's. Pass userPubkey through from RPC handler to WithdrawManager. - Add creator_pubkey column (migration v4) - Store creatorPubkey on link creation - Pass creator_pubkey to payInvoice on LNURL callback Co-Authored-By: Claude Opus 4.6 --- src/extensions/withdraw/index.ts | 4 ++-- .../withdraw/managers/withdrawManager.ts | 14 ++++++++++---- src/extensions/withdraw/migrations.ts | 11 +++++++++++ src/extensions/withdraw/types.ts | 3 +++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/extensions/withdraw/index.ts b/src/extensions/withdraw/index.ts index dacf045e..1a38930b 100644 --- a/src/extensions/withdraw/index.ts +++ b/src/extensions/withdraw/index.ts @@ -114,8 +114,8 @@ export default class WithdrawExtension implements Extension { */ 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) + ctx.registerMethod('withdraw.createLink', async (req, appId, userPubkey) => { + const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest, userPubkey) const stats = await this.manager.getWithdrawalStats(link.id) return { link, diff --git a/src/extensions/withdraw/managers/withdrawManager.ts b/src/extensions/withdraw/managers/withdrawManager.ts index 91677f82..5f76008e 100644 --- a/src/extensions/withdraw/managers/withdrawManager.ts +++ b/src/extensions/withdraw/managers/withdrawManager.ts @@ -48,6 +48,7 @@ interface WithdrawLinkRow { is_unique: number uses_csv: string open_time: number + creator_pubkey: string | null webhook_url: string | null webhook_headers: string | null webhook_body: string | null @@ -87,6 +88,7 @@ function rowToLink(row: WithdrawLinkRow): WithdrawLink { is_unique: row.is_unique === 1, uses_csv: row.uses_csv, open_time: row.open_time, + creator_pubkey: row.creator_pubkey || undefined, webhook_url: row.webhook_url || undefined, webhook_headers: row.webhook_headers || undefined, webhook_body: row.webhook_body || undefined, @@ -150,7 +152,7 @@ export class WithdrawManager { /** * Create a new withdraw link */ - async create(applicationId: string, req: CreateWithdrawLinkRequest): Promise { + async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise { // Validation if (req.uses < 1 || req.uses > 250) { throw new Error('Uses must be between 1 and 250') @@ -200,6 +202,7 @@ export class WithdrawManager { is_unique: req.is_unique || false, uses_csv: usesCsv, open_time: now, + creator_pubkey: creatorPubkey, webhook_url: req.webhook_url, webhook_headers: req.webhook_headers, webhook_body: req.webhook_body, @@ -212,13 +215,15 @@ export class WithdrawManager { id, application_id, title, description, min_withdrawable, max_withdrawable, uses, used, wait_time, unique_hash, k1, is_unique, uses_csv, open_time, + creator_pubkey, webhook_url, webhook_headers, webhook_body, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) 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.creator_pubkey || null, link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null, link.created_at, link.updated_at ] @@ -502,11 +507,12 @@ export class WithdrawManager { } try { - // Pay the invoice + // Pay the invoice from the creator's balance (if created via Nostr RPC) const payment = await this.ctx.payInvoice( link.application_id, params.pr, - link.max_withdrawable + link.max_withdrawable, + link.creator_pubkey ) // Record the withdrawal diff --git a/src/extensions/withdraw/migrations.ts b/src/extensions/withdraw/migrations.ts index 86886885..1625638a 100644 --- a/src/extensions/withdraw/migrations.ts +++ b/src/extensions/withdraw/migrations.ts @@ -122,6 +122,17 @@ export const migrations: Migration[] = [ ) `) } + }, + { + version: 4, + name: 'add_creator_pubkey_column', + up: async (db: ExtensionDatabase) => { + // Store the Nostr pubkey of the user who created the withdraw link + // so that when the LNURL callback fires, we debit the correct user's balance + await db.execute(` + ALTER TABLE withdraw_links ADD COLUMN creator_pubkey TEXT + `) + } } ] diff --git a/src/extensions/withdraw/types.ts b/src/extensions/withdraw/types.ts index d9d05da0..88d25f33 100644 --- a/src/extensions/withdraw/types.ts +++ b/src/extensions/withdraw/types.ts @@ -46,6 +46,9 @@ export interface WithdrawLink { // Rate limiting open_time: number // Unix timestamp when next use is allowed + // Creator identity (for Nostr RPC-created links) + creator_pubkey?: string // Nostr pubkey of the user who created this link + // Webhook notifications webhook_url?: string webhook_headers?: string // JSON string From 5aaa3bcc23d3b5cd3e1a16012ea667f36782d373 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 16:56:34 -0500 Subject: [PATCH 07/13] feat(extensions): pay from caller's balance via PayAppUserInvoice When userPubkey is provided, resolve the ApplicationUser and call applicationManager.PayAppUserInvoice instead of paymentManager.PayInvoice directly. This ensures notifyAppUserPayment fires, sending LiveUserOperation events via Nostr for real-time balance updates. Co-Authored-By: Claude Opus 4.6 --- src/extensions/context.ts | 19 +++++++++++++-- src/extensions/mainHandlerAdapter.ts | 35 ++++++++++++++++++++++++---- src/extensions/types.ts | 3 ++- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/extensions/context.ts b/src/extensions/context.ts index b1c6e8d6..f18891f5 100644 --- a/src/extensions/context.ts +++ b/src/extensions/context.ts @@ -20,6 +20,17 @@ export interface MainHandlerInterface { // Application management applicationManager: { getById(id: string): Promise + PayAppUserInvoice(appId: string, req: { + amount: number + invoice: string + user_identifier: string + debit_npub?: string + }): Promise<{ + preimage: string + amount_paid: number + network_fee: number + service_fee: number + }> } // Payment operations @@ -41,6 +52,7 @@ export interface MainHandlerInterface { applicationId: string paymentRequest: string maxFeeSats?: number + userPubkey?: string }): Promise<{ paymentHash: string feeSats: number @@ -156,16 +168,19 @@ export class ExtensionContextImpl implements ExtensionContext { /** * Pay a Lightning invoice + * If userPubkey is provided, pays from that user's balance instead of app.owner */ async payInvoice( applicationId: string, paymentRequest: string, - maxFeeSats?: number + maxFeeSats?: number, + userPubkey?: string ): Promise<{ paymentHash: string; feeSats: number }> { return this.mainHandler.paymentManager.payInvoice({ applicationId, paymentRequest, - maxFeeSats + maxFeeSats, + userPubkey }) } diff --git a/src/extensions/mainHandlerAdapter.ts b/src/extensions/mainHandlerAdapter.ts index eecc96b3..fec73c3f 100644 --- a/src/extensions/mainHandlerAdapter.ts +++ b/src/extensions/mainHandlerAdapter.ts @@ -32,6 +32,10 @@ export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterfac // GetApplication throws if not found return null } + }, + + async PayAppUserInvoice(appId, req) { + return mainHandler.applicationManager.PayAppUserInvoice(appId, req) } }, @@ -73,6 +77,7 @@ export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterfac applicationId: string paymentRequest: string maxFeeSats?: number + userPubkey?: string }) { // Get the app to find the user ID and app reference const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId) @@ -80,19 +85,41 @@ export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterfac throw new Error(`Application not found: ${params.applicationId}`) } - // Pay invoice from the app's balance + if (params.userPubkey) { + // Resolve the Nostr user's ApplicationUser to get their identifier + const appUser = await mainHandler.storage.applicationStorage.GetOrCreateNostrAppUser(app, params.userPubkey) + console.log(`[MainHandlerAdapter] Paying via PayAppUserInvoice from Nostr user ${params.userPubkey.slice(0, 8)}... (identifier: ${appUser.identifier})`) + + // Use applicationManager.PayAppUserInvoice so notifyAppUserPayment fires + // This sends LiveUserOperation events via Nostr for real-time balance updates + const result = await mainHandler.applicationManager.PayAppUserInvoice( + params.applicationId, + { + invoice: params.paymentRequest, + amount: 0, // Use invoice amount + user_identifier: appUser.identifier + } + ) + + return { + paymentHash: result.preimage || '', + feeSats: result.network_fee || 0 + } + } + + // Fallback: pay from app owner's balance (no Nostr user context) const result = await mainHandler.paymentManager.PayInvoice( app.owner.user_id, { invoice: params.paymentRequest, - amount: 0 // Use invoice amount + amount: 0 }, - app, // linkedApplication + app, {} ) return { - paymentHash: result.preimage || '', // preimage serves as proof of payment + paymentHash: result.preimage || '', feeSats: result.network_fee || 0 } }, diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 2027fb09..66a4c46a 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -140,8 +140,9 @@ export interface ExtensionContext { /** * Pay a Lightning invoice (requires sufficient balance) + * If userPubkey is provided, pays from that user's balance instead of app.owner */ - payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number): Promise<{ + payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number, userPubkey?: string): Promise<{ paymentHash: string feeSats: number }> From 68c71599f8ac61a36a0b0775ee1ac2429d69eaab Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 28 Feb 2026 10:55:44 -0500 Subject: [PATCH 08/13] fix(lnd): allow self-payments for LNURL-withdraw When the user's wallet (e.g. Zeus) is connected to the same LND node that LP uses, LNURL-withdraw fails because LND rejects the payment with "no self-payments allowed". This is safe because LP always decrements the user's balance before paying and refunds on failure. Co-Authored-By: Claude Opus 4.6 --- src/services/lnd/payInvoiceReq.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/lnd/payInvoiceReq.ts b/src/services/lnd/payInvoiceReq.ts index 3b90dd3d..3ae28c30 100644 --- a/src/services/lnd/payInvoiceReq.ts +++ b/src/services/lnd/payInvoiceReq.ts @@ -9,7 +9,7 @@ export const PayInvoiceReq = (invoice: string, amount: number, feeLimit: number) maxParts: 3, timeoutSeconds: 50, - allowSelfPayment: false, + allowSelfPayment: true, amp: false, amtMsat: 0n, cltvLimit: 0, From e18fe9f83a8ef2c02557244336eedd802327d012 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 14:27:04 -0500 Subject: [PATCH 09/13] feat(extensions): add NIP-05 identity extension Implements Nostr NIP-05 for human-readable identity verification: - Username claiming and management (username@domain) - /.well-known/nostr.json endpoint per spec - Optional relay hints in JSON response - Admin controls for identity management RPC methods: - nip05.claim - Claim a username - nip05.release - Release your username - nip05.updateRelays - Update relay hints - nip05.getMyIdentity - Get your identity - nip05.lookup - Look up by username - nip05.lookupByPubkey - Look up by pubkey - nip05.listIdentities - List all (admin) - nip05.deactivate/reactivate - Admin controls Co-Authored-By: Claude Opus 4.5 --- src/extensions/nip05/index.ts | 227 +++++++++ src/extensions/nip05/managers/nip05Manager.ts | 452 ++++++++++++++++++ src/extensions/nip05/migrations.ts | 93 ++++ src/extensions/nip05/types.ts | 130 +++++ 4 files changed, 902 insertions(+) create mode 100644 src/extensions/nip05/index.ts create mode 100644 src/extensions/nip05/managers/nip05Manager.ts create mode 100644 src/extensions/nip05/migrations.ts create mode 100644 src/extensions/nip05/types.ts diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts new file mode 100644 index 00000000..a37fe8e0 --- /dev/null +++ b/src/extensions/nip05/index.ts @@ -0,0 +1,227 @@ +/** + * NIP-05 Extension for Lightning.Pub + * + * Implements Nostr NIP-05: Mapping Nostr keys to DNS-based internet identifiers + * Allows users to claim human-readable addresses like alice@domain.com + * + * Features: + * - Username claiming and management + * - .well-known/nostr.json endpoint + * - Optional relay hints + * - Admin controls for identity management + */ + +import { + Extension, + ExtensionInfo, + ExtensionContext, + ExtensionDatabase, + HttpRoute, + HttpRequest, + HttpResponse +} from '../types.js' +import { runMigrations } from './migrations.js' +import { Nip05Manager } from './managers/nip05Manager.js' +import { + ClaimUsernameRequest, + UpdateRelaysRequest, + Nip05Config +} from './types.js' + +/** + * NIP-05 Extension + */ +export default class Nip05Extension implements Extension { + readonly info: ExtensionInfo = { + id: 'nip05', + name: 'NIP-05 Identity', + version: '1.0.0', + description: 'Human-readable Nostr identities (username@domain)', + author: 'Lightning.Pub', + minPubVersion: '1.0.0' + } + + private manager!: Nip05Manager + private ctx!: ExtensionContext + private config: Nip05Config = {} + + /** + * Initialize the extension + */ + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + this.ctx = ctx + + // Run migrations + await runMigrations(db) + + // Initialize manager + this.manager = new Nip05Manager(ctx, db, this.config) + + // Register RPC methods + this.registerRpcMethods(ctx) + + ctx.log('info', 'Extension initialized') + } + + /** + * Shutdown the extension + */ + async shutdown(): Promise { + // Cleanup if needed + } + + /** + * Configure the extension + */ + configure(config: Nip05Config): void { + this.config = config + } + + /** + * Get HTTP routes for this extension + * These need to be mounted by the main HTTP server + */ + getHttpRoutes(): HttpRoute[] { + return [ + // NIP-05 well-known endpoint + { + method: 'GET', + path: '/.well-known/nostr.json', + handler: this.handleNostrJson.bind(this) + }, + // Alternative path for proxied setups + { + method: 'GET', + path: '/api/v1/nip05/nostr.json', + handler: this.handleNostrJson.bind(this) + } + ] + } + + /** + * Register RPC methods with the extension context + */ + private registerRpcMethods(ctx: ExtensionContext): void { + // Claim a username + ctx.registerMethod('nip05.claim', async (req, appId, userId, pubkey) => { + if (!userId || !pubkey) { + throw new Error('Authentication required') + } + return this.manager.claimUsername(userId, pubkey, appId, req as ClaimUsernameRequest) + }) + + // Release your username + ctx.registerMethod('nip05.release', async (req, appId, userId) => { + if (!userId) { + throw new Error('Authentication required') + } + await this.manager.releaseUsername(userId, appId) + return { success: true } + }) + + // Update your relays + ctx.registerMethod('nip05.updateRelays', async (req, appId, userId) => { + if (!userId) { + throw new Error('Authentication required') + } + const identity = await this.manager.updateRelays(userId, appId, req as UpdateRelaysRequest) + return { identity } + }) + + // Get your identity + ctx.registerMethod('nip05.getMyIdentity', async (req, appId, userId) => { + if (!userId) { + throw new Error('Authentication required') + } + return this.manager.getMyIdentity(userId, appId) + }) + + // Look up a username (public) + ctx.registerMethod('nip05.lookup', async (req, appId) => { + return this.manager.lookupUsername(appId, req.username) + }) + + // Look up by pubkey (public) + ctx.registerMethod('nip05.lookupByPubkey', async (req, appId) => { + return this.manager.lookupByPubkey(appId, req.pubkey) + }) + + // List all identities (admin) + ctx.registerMethod('nip05.listIdentities', async (req, appId) => { + return this.manager.listIdentities(appId, { + limit: req.limit, + offset: req.offset, + activeOnly: req.active_only + }) + }) + + // Deactivate an identity (admin) + ctx.registerMethod('nip05.deactivate', async (req, appId) => { + await this.manager.deactivateIdentity(appId, req.identity_id) + return { success: true } + }) + + // Reactivate an identity (admin) + ctx.registerMethod('nip05.reactivate', async (req, appId) => { + await this.manager.reactivateIdentity(appId, req.identity_id) + return { success: true } + }) + } + + // ========================================================================= + // HTTP Route Handlers + // ========================================================================= + + /** + * Handle /.well-known/nostr.json request + * GET /.well-known/nostr.json?name= + * + * Per NIP-05 spec, returns: + * { + * "names": { "": "" }, + * "relays": { "": ["wss://..."] } + * } + */ + private async handleNostrJson(req: HttpRequest): Promise { + try { + // Get application ID from request context + // In a multi-tenant setup, this would come from the host or path + const appId = req.headers['x-application-id'] || 'default' + + // Set domain from request host for NIP-05 address formatting + if (req.headers['host']) { + this.manager.setDomain(req.headers['host'].split(':')[0]) + } + + // Get the name parameter + const name = req.query.name + + // Get the JSON response + const response = await this.manager.handleNostrJson(appId, name) + + return { + status: 200, + body: response, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=300' // Cache for 5 minutes + } + } + } catch (error) { + this.ctx.log('error', `Error handling nostr.json: ${error}`) + return { + status: 500, + body: { error: 'Internal server error' }, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + } + } + } +} + +// Export types for external use +export * from './types.js' +export { Nip05Manager } from './managers/nip05Manager.js' diff --git a/src/extensions/nip05/managers/nip05Manager.ts b/src/extensions/nip05/managers/nip05Manager.ts new file mode 100644 index 00000000..3efe85b8 --- /dev/null +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -0,0 +1,452 @@ +/** + * NIP-05 Identity Manager + * + * Handles username claiming, lookup, and .well-known/nostr.json responses + */ + +import { ExtensionContext, ExtensionDatabase } from '../../types.js' +import { + Nip05Identity, + Nip05IdentityRow, + Nip05JsonResponse, + Nip05Config, + UsernameValidation, + ClaimUsernameRequest, + ClaimUsernameResponse, + UpdateRelaysRequest, + LookupUsernameResponse, + GetMyIdentityResponse +} from '../types.js' +import crypto from 'crypto' + +/** + * Default configuration + */ +const DEFAULT_CONFIG: Required = { + max_username_length: 30, + min_username_length: 1, + reserved_usernames: ['admin', 'root', 'system', 'support', 'help', 'info', 'contact', 'abuse', 'postmaster', 'webmaster', 'hostmaster', 'noreply', 'no-reply', 'null', 'undefined', 'api', 'www', 'mail', 'ftp', 'ssh', 'test', 'demo'], + include_relays: true, + default_relays: [] +} + +/** + * Convert database row to Nip05Identity + */ +function rowToIdentity(row: Nip05IdentityRow): Nip05Identity { + return { + id: row.id, + application_id: row.application_id, + user_id: row.user_id, + username: row.username, + pubkey_hex: row.pubkey_hex, + relays: JSON.parse(row.relays_json), + is_active: row.is_active === 1, + created_at: row.created_at, + updated_at: row.updated_at + } +} + +/** + * Generate a unique ID + */ +function generateId(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * Validate username format + * - Lowercase alphanumeric and underscore only + * - Must start with a letter + * - Length within bounds + */ +function validateUsername(username: string, config: Required): UsernameValidation { + if (!username) { + return { valid: false, error: 'Username is required' } + } + + const normalized = username.toLowerCase().trim() + + if (normalized.length < config.min_username_length) { + return { valid: false, error: `Username must be at least ${config.min_username_length} character(s)` } + } + + if (normalized.length > config.max_username_length) { + return { valid: false, error: `Username must be at most ${config.max_username_length} characters` } + } + + // Only lowercase letters, numbers, and underscores + if (!/^[a-z][a-z0-9_]*$/.test(normalized)) { + return { valid: false, error: 'Username must start with a letter and contain only lowercase letters, numbers, and underscores' } + } + + // Check reserved usernames + if (config.reserved_usernames.includes(normalized)) { + return { valid: false, error: 'This username is reserved' } + } + + return { valid: true } +} + +/** + * Validate relay URLs + */ +function validateRelays(relays: string[]): UsernameValidation { + if (!Array.isArray(relays)) { + return { valid: false, error: 'Relays must be an array' } + } + + for (const relay of relays) { + if (typeof relay !== 'string') { + return { valid: false, error: 'Each relay must be a string' } + } + if (!relay.startsWith('wss://') && !relay.startsWith('ws://')) { + return { valid: false, error: `Invalid relay URL: ${relay}` } + } + } + + return { valid: true } +} + +export class Nip05Manager { + private ctx: ExtensionContext + private db: ExtensionDatabase + private config: Required + private domain: string + + constructor(ctx: ExtensionContext, db: ExtensionDatabase, config?: Nip05Config) { + this.ctx = ctx + this.db = db + this.config = { ...DEFAULT_CONFIG, ...config } + // Extract domain from the service URL + this.domain = this.extractDomain() + } + + /** + * Extract domain from service URL for NIP-05 addresses + */ + private extractDomain(): string { + // This would come from Lightning.Pub's configuration + // For now, we'll derive it when needed from the request host + return 'localhost' + } + + /** + * Set the domain (called from HTTP request context) + */ + setDomain(domain: string): void { + this.domain = domain + } + + /** + * Claim a username for the current user + */ + async claimUsername( + userId: string, + pubkeyHex: string, + applicationId: string, + request: ClaimUsernameRequest + ): Promise { + const normalizedUsername = request.username.toLowerCase().trim() + + // Validate username format + const validation = validateUsername(normalizedUsername, this.config) + if (!validation.valid) { + throw new Error(validation.error) + } + + // Validate relays if provided + const relays = request.relays || this.config.default_relays + if (relays.length > 0) { + const relayValidation = validateRelays(relays) + if (!relayValidation.valid) { + throw new Error(relayValidation.error) + } + } + + // Check if user already has an identity in this application + const existingByUser = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND user_id = ?`, + [applicationId, userId] + ) + if (existingByUser.length > 0) { + throw new Error('You already have a username. Release it first to claim a new one.') + } + + // Check if username is already taken + const existingByUsername = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND username = ?`, + [applicationId, normalizedUsername] + ) + if (existingByUsername.length > 0) { + throw new Error('This username is already taken') + } + + // Create the identity + const now = Math.floor(Date.now() / 1000) + const id = generateId() + + await this.db.execute( + `INSERT INTO identities (id, application_id, user_id, username, pubkey_hex, relays_json, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + [id, applicationId, userId, normalizedUsername, pubkeyHex, JSON.stringify(relays), now, now] + ) + + const identity: Nip05Identity = { + id, + application_id: applicationId, + user_id: userId, + username: normalizedUsername, + pubkey_hex: pubkeyHex, + relays, + is_active: true, + created_at: now, + updated_at: now + } + + return { + identity, + nip05_address: `${normalizedUsername}@${this.domain}` + } + } + + /** + * Release (delete) the current user's username + */ + async releaseUsername(userId: string, applicationId: string): Promise { + const result = await this.db.execute( + `DELETE FROM identities WHERE application_id = ? AND user_id = ?`, + [applicationId, userId] + ) + + if (result.changes === 0) { + throw new Error('You do not have a username to release') + } + } + + /** + * Update relays for the current user's identity + */ + async updateRelays( + userId: string, + applicationId: string, + request: UpdateRelaysRequest + ): Promise { + // Validate relays + const validation = validateRelays(request.relays) + if (!validation.valid) { + throw new Error(validation.error) + } + + const now = Math.floor(Date.now() / 1000) + + const result = await this.db.execute( + `UPDATE identities SET relays_json = ?, updated_at = ? WHERE application_id = ? AND user_id = ?`, + [JSON.stringify(request.relays), now, applicationId, userId] + ) + + if (result.changes === 0) { + throw new Error('You do not have a username') + } + + // Fetch and return the updated identity + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND user_id = ?`, + [applicationId, userId] + ) + + return rowToIdentity(rows[0]) + } + + /** + * Get the current user's identity + */ + async getMyIdentity(userId: string, applicationId: string): Promise { + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND user_id = ?`, + [applicationId, userId] + ) + + if (rows.length === 0) { + return { has_identity: false } + } + + const identity = rowToIdentity(rows[0]) + return { + has_identity: true, + identity, + nip05_address: `${identity.username}@${this.domain}` + } + } + + /** + * Look up a username (public, no auth required) + */ + async lookupUsername(applicationId: string, username: string): Promise { + const normalizedUsername = username.toLowerCase().trim() + + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND username = ? AND is_active = 1`, + [applicationId, normalizedUsername] + ) + + if (rows.length === 0) { + return { found: false } + } + + const identity = rowToIdentity(rows[0]) + return { + found: true, + identity, + nip05_address: `${identity.username}@${this.domain}` + } + } + + /** + * Look up by pubkey + */ + async lookupByPubkey(applicationId: string, pubkeyHex: string): Promise { + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND pubkey_hex = ? AND is_active = 1`, + [applicationId, pubkeyHex] + ) + + if (rows.length === 0) { + return { found: false } + } + + const identity = rowToIdentity(rows[0]) + return { + found: true, + identity, + nip05_address: `${identity.username}@${this.domain}` + } + } + + /** + * Handle /.well-known/nostr.json request + * This is the core NIP-05 endpoint + */ + async handleNostrJson(applicationId: string, name?: string): Promise { + const response: Nip05JsonResponse = { + names: {} + } + + if (this.config.include_relays) { + response.relays = {} + } + + if (name) { + // Look up specific username + const normalizedName = name.toLowerCase().trim() + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND username = ? AND is_active = 1`, + [applicationId, normalizedName] + ) + + if (rows.length > 0) { + const identity = rowToIdentity(rows[0]) + response.names[identity.username] = identity.pubkey_hex + + if (this.config.include_relays && identity.relays.length > 0) { + response.relays![identity.pubkey_hex] = identity.relays + } + } + } else { + // Return all active identities (with reasonable limit) + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND is_active = 1 LIMIT 1000`, + [applicationId] + ) + + for (const row of rows) { + const identity = rowToIdentity(row) + response.names[identity.username] = identity.pubkey_hex + + if (this.config.include_relays && identity.relays.length > 0) { + response.relays![identity.pubkey_hex] = identity.relays + } + } + } + + return response + } + + /** + * List all identities for an application (admin) + */ + async listIdentities( + applicationId: string, + options?: { limit?: number; offset?: number; activeOnly?: boolean } + ): Promise<{ identities: Nip05Identity[]; total: number }> { + const limit = options?.limit || 50 + const offset = options?.offset || 0 + const activeClause = options?.activeOnly !== false ? 'AND is_active = 1' : '' + + // Get total count + const countResult = await this.db.query<{ count: number }>( + `SELECT COUNT(*) as count FROM identities WHERE application_id = ? ${activeClause}`, + [applicationId] + ) + const total = countResult[0]?.count || 0 + + // Get page of results + const rows = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? ${activeClause} + ORDER BY created_at DESC LIMIT ? OFFSET ?`, + [applicationId, limit, offset] + ) + + return { + identities: rows.map(rowToIdentity), + total + } + } + + /** + * Deactivate an identity (admin) + */ + async deactivateIdentity(applicationId: string, identityId: string): Promise { + const now = Math.floor(Date.now() / 1000) + + const result = await this.db.execute( + `UPDATE identities SET is_active = 0, updated_at = ? WHERE application_id = ? AND id = ?`, + [now, applicationId, identityId] + ) + + if (result.changes === 0) { + throw new Error('Identity not found') + } + } + + /** + * Reactivate an identity (admin) + */ + async reactivateIdentity(applicationId: string, identityId: string): Promise { + const now = Math.floor(Date.now() / 1000) + + // Check if username is taken by an active identity + const identity = await this.db.query( + `SELECT * FROM identities WHERE id = ? AND application_id = ?`, + [identityId, applicationId] + ) + + if (identity.length === 0) { + throw new Error('Identity not found') + } + + const conflicting = await this.db.query( + `SELECT * FROM identities WHERE application_id = ? AND username = ? AND is_active = 1 AND id != ?`, + [applicationId, identity[0].username, identityId] + ) + + if (conflicting.length > 0) { + throw new Error('Username is already taken by another active identity') + } + + await this.db.execute( + `UPDATE identities SET is_active = 1, updated_at = ? WHERE application_id = ? AND id = ?`, + [now, applicationId, identityId] + ) + } +} diff --git a/src/extensions/nip05/migrations.ts b/src/extensions/nip05/migrations.ts new file mode 100644 index 00000000..5cf22909 --- /dev/null +++ b/src/extensions/nip05/migrations.ts @@ -0,0 +1,93 @@ +/** + * NIP-05 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_identities_table', + up: async (db: ExtensionDatabase) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS identities ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + user_id TEXT NOT NULL, + + -- Identity mapping + username TEXT NOT NULL, + pubkey_hex TEXT NOT NULL, + + -- Optional relays (JSON array) + relays_json TEXT NOT NULL DEFAULT '[]', + + -- Status + is_active INTEGER NOT NULL DEFAULT 1, + + -- Timestamps + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + + // Unique username per application (case-insensitive via lowercase storage) + await db.execute(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_username_app + ON identities(application_id, username) + `) + + // One identity per user per application + await db.execute(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_user_app + ON identities(application_id, user_id) + `) + + // Look up by pubkey + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_identities_pubkey + ON identities(pubkey_hex) + `) + + // Look up active identities for .well-known endpoint + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_identities_active + ON identities(application_id, is_active, username) + `) + } + } +] + +/** + * 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(`[NIP-05] 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/nip05/types.ts b/src/extensions/nip05/types.ts new file mode 100644 index 00000000..93967f4c --- /dev/null +++ b/src/extensions/nip05/types.ts @@ -0,0 +1,130 @@ +/** + * NIP-05 Extension Types + * + * Implements Nostr NIP-05: Mapping Nostr keys to DNS-based internet identifiers + * Allows users to have human-readable addresses like alice@domain.com + */ + +/** + * A NIP-05 identity mapping a username to a Nostr public key + */ +export interface Nip05Identity { + id: string + application_id: string + user_id: string + + /** The human-readable username (lowercase, alphanumeric + underscore) */ + username: string + + /** The Nostr public key in hex format */ + pubkey_hex: string + + /** Optional list of relay URLs for this user */ + relays: string[] + + /** Whether this identity is active */ + is_active: boolean + + created_at: number + updated_at: number +} + +/** + * NIP-05 JSON response format per the spec + * GET /.well-known/nostr.json?name= + */ +export interface Nip05JsonResponse { + names: Record + relays?: Record +} + +/** + * Request to claim a username + */ +export interface ClaimUsernameRequest { + username: string + relays?: string[] +} + +/** + * Response after claiming a username + */ +export interface ClaimUsernameResponse { + identity: Nip05Identity + nip05_address: string +} + +/** + * Request to update relays for a username + */ +export interface UpdateRelaysRequest { + relays: string[] +} + +/** + * Request to look up a username + */ +export interface LookupUsernameRequest { + username: string +} + +/** + * Response for username lookup + */ +export interface LookupUsernameResponse { + found: boolean + identity?: Nip05Identity + nip05_address?: string +} + +/** + * Response for getting current user's identity + */ +export interface GetMyIdentityResponse { + has_identity: boolean + identity?: Nip05Identity + nip05_address?: string +} + +/** + * Database row for NIP-05 identity + */ +export interface Nip05IdentityRow { + id: string + application_id: string + user_id: string + username: string + pubkey_hex: string + relays_json: string + is_active: number + created_at: number + updated_at: number +} + +/** + * Extension configuration + */ +export interface Nip05Config { + /** Maximum username length (default: 30) */ + max_username_length?: number + + /** Minimum username length (default: 1) */ + min_username_length?: number + + /** Reserved usernames that cannot be claimed */ + reserved_usernames?: string[] + + /** Whether to include relays in the JSON response (default: true) */ + include_relays?: boolean + + /** Default relays to suggest for new users */ + default_relays?: string[] +} + +/** + * Validation result for username + */ +export interface UsernameValidation { + valid: boolean + error?: string +} From 883bb71116c35fbbaf43788e9f9cff3e890480ef Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 15:04:59 -0500 Subject: [PATCH 10/13] feat(nip05): add Lightning Address support for zaps Adds /.well-known/lnurlp/:username endpoint that: 1. Looks up username in NIP-05 database 2. Gets LNURL-pay info from Lightning.Pub for that user 3. Returns standard LUD-16 response for wallet compatibility This makes NIP-05 addresses (alice@domain) work seamlessly as Lightning Addresses for receiving payments and NIP-57 zaps. Co-Authored-By: Claude Opus 4.5 --- src/extensions/nip05/index.ts | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts index a37fe8e0..a8c1a7fd 100644 --- a/src/extensions/nip05/index.ts +++ b/src/extensions/nip05/index.ts @@ -94,6 +94,13 @@ export default class Nip05Extension implements Extension { method: 'GET', path: '/api/v1/nip05/nostr.json', handler: this.handleNostrJson.bind(this) + }, + // Lightning Address endpoint (LUD-16) + // Makes NIP-05 usernames work as Lightning Addresses for zaps + { + method: 'GET', + path: '/.well-known/lnurlp/:username', + handler: this.handleLnurlPay.bind(this) } ] } @@ -220,6 +227,72 @@ export default class Nip05Extension implements Extension { } } } + + /** + * Handle /.well-known/lnurlp/:username request (Lightning Address / LUD-16) + * + * This enables NIP-05 usernames to work as Lightning Addresses for receiving + * payments and zaps. When someone sends to alice@domain.com: + * 1. Wallet resolves /.well-known/lnurlp/alice + * 2. We look up alice -> pubkey in our NIP-05 database + * 3. We return LNURL-pay info from Lightning.Pub for that user + */ + private async handleLnurlPay(req: HttpRequest): Promise { + try { + const { username } = req.params + const appId = req.headers['x-application-id'] || 'default' + + if (!username) { + return { + status: 400, + body: { status: 'ERROR', reason: 'Username required' }, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + } + } + + // Look up the username in our NIP-05 database + const lookup = await this.manager.lookupUsername(appId, username) + + if (!lookup.found || !lookup.identity) { + return { + status: 404, + body: { status: 'ERROR', reason: 'User not found' }, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + } + } + + // Get LNURL-pay info from Lightning.Pub for this user's pubkey + const lnurlPayInfo = await this.ctx.getLnurlPayInfo(lookup.identity.pubkey_hex, { + description: `Pay to ${username}` + }) + + return { + status: 200, + body: lnurlPayInfo, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=60' // Cache for 1 minute + } + } + } catch (error) { + this.ctx.log('error', `Error handling lnurlp: ${error}`) + return { + status: 500, + body: { status: 'ERROR', reason: 'Internal server error' }, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + } + } + } } // Export types for external use From 7dd767a78aa1230fb5db829b10064a64f07a5f4f Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 13:25:06 -0400 Subject: [PATCH 11/13] fix(nip05): allow hyphens and periods in usernames per NIP-05 spec NIP-05 spec states local-part MUST only use characters a-z0-9-_. The previous regex /^[a-z][a-z0-9_]*$/ rejected hyphens and periods. Updated to /^[a-z][a-z0-9._-]*[a-z0-9]$/ and added support for the root identifier "_" (_@domain) as described in the spec. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/nip05/managers/nip05Manager.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/extensions/nip05/managers/nip05Manager.ts b/src/extensions/nip05/managers/nip05Manager.ts index 3efe85b8..d83fef1c 100644 --- a/src/extensions/nip05/managers/nip05Manager.ts +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -55,10 +55,12 @@ function generateId(): string { } /** - * Validate username format - * - Lowercase alphanumeric and underscore only + * Validate username format per NIP-05 spec + * - Characters allowed: a-z, 0-9, hyphen (-), underscore (_), period (.) * - Must start with a letter + * - Must not end with a hyphen, underscore, or period * - Length within bounds + * - Special case: "_" alone is the root identifier (_@domain) */ function validateUsername(username: string, config: Required): UsernameValidation { if (!username) { @@ -67,6 +69,11 @@ function validateUsername(username: string, config: Required): User const normalized = username.toLowerCase().trim() + // Special case: root identifier "_" per NIP-05 + if (normalized === '_') { + return { valid: true } + } + if (normalized.length < config.min_username_length) { return { valid: false, error: `Username must be at least ${config.min_username_length} character(s)` } } @@ -75,9 +82,10 @@ function validateUsername(username: string, config: Required): User return { valid: false, error: `Username must be at most ${config.max_username_length} characters` } } - // Only lowercase letters, numbers, and underscores - if (!/^[a-z][a-z0-9_]*$/.test(normalized)) { - return { valid: false, error: 'Username must start with a letter and contain only lowercase letters, numbers, and underscores' } + // NIP-05 spec: local-part MUST only use characters a-z0-9-_. + // Must start with a letter, must not end with separator + if (!/^[a-z][a-z0-9._-]*[a-z0-9]$/.test(normalized) && !/^[a-z]$/.test(normalized)) { + return { valid: false, error: 'Username must start with a letter, end with a letter or number, and contain only a-z, 0-9, hyphens, underscores, and periods' } } // Check reserved usernames From 915ca667e53440b05416134ff22c24daa01d5839 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 13:25:19 -0400 Subject: [PATCH 12/13] fix(nip05): add redirect prevention docs and zap field validation Gap #5: Document NIP-05 spec requirement that /.well-known/nostr.json MUST NOT return HTTP redirects. The extension already complies (always returns direct responses), but reverse proxy deployments need awareness. Gap #7: Log a warning when getLnurlPayInfo() response is missing allowsNostr or nostrPubkey fields required by NIP-57 for zap support. This surfaces misconfiguration early instead of silently breaking zaps. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/nip05/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts index a8c1a7fd..c74c0bf5 100644 --- a/src/extensions/nip05/index.ts +++ b/src/extensions/nip05/index.ts @@ -189,6 +189,12 @@ export default class Nip05Extension implements Extension { * "relays": { "": ["wss://..."] } * } */ + /** + * NIP-05 spec: "The /.well-known/nostr.json endpoint MUST NOT return any + * HTTP redirects." This extension always returns direct 200/4xx/5xx responses. + * Deployment note: ensure reverse proxies do not add 3xx redirects on this path + * (e.g. HTTP→HTTPS or trailing-slash redirects). + */ private async handleNostrJson(req: HttpRequest): Promise { try { // Get application ID from request context @@ -272,6 +278,11 @@ export default class Nip05Extension implements Extension { description: `Pay to ${username}` }) + // NIP-57: ensure zap support fields are present for wallet compatibility + if (!lnurlPayInfo.allowsNostr || !lnurlPayInfo.nostrPubkey) { + this.ctx.log('warn', `LNURL-pay response for ${username} missing zap fields (allowsNostr=${lnurlPayInfo.allowsNostr}, nostrPubkey=${!!lnurlPayInfo.nostrPubkey}). Zaps will not work.`) + } + return { status: 200, body: lnurlPayInfo, From f59073e58932cadb30ba705c230cd5123ae0cbe6 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 13:31:33 -0400 Subject: [PATCH 13/13] fix(nip05): add configurable limits for relays per user and identity listing Adds max_relays_per_user (default: 10) to prevent users from attaching excessive relay URLs that inflate .well-known/nostr.json responses. Enforced in both claimUsername and updateRelays. Reduces the no-name listing limit from hardcoded 1000 to configurable max_identities_listing (default: 100) for the /.well-known/nostr.json endpoint when no ?name= parameter is provided. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/nip05/managers/nip05Manager.ts | 21 ++++++++++++------- src/extensions/nip05/types.ts | 6 ++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/extensions/nip05/managers/nip05Manager.ts b/src/extensions/nip05/managers/nip05Manager.ts index d83fef1c..4eba8225 100644 --- a/src/extensions/nip05/managers/nip05Manager.ts +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -27,7 +27,9 @@ const DEFAULT_CONFIG: Required = { min_username_length: 1, reserved_usernames: ['admin', 'root', 'system', 'support', 'help', 'info', 'contact', 'abuse', 'postmaster', 'webmaster', 'hostmaster', 'noreply', 'no-reply', 'null', 'undefined', 'api', 'www', 'mail', 'ftp', 'ssh', 'test', 'demo'], include_relays: true, - default_relays: [] + default_relays: [], + max_relays_per_user: 10, + max_identities_listing: 100 } /** @@ -99,11 +101,15 @@ function validateUsername(username: string, config: Required): User /** * Validate relay URLs */ -function validateRelays(relays: string[]): UsernameValidation { +function validateRelays(relays: string[], maxRelays?: number): UsernameValidation { if (!Array.isArray(relays)) { return { valid: false, error: 'Relays must be an array' } } + if (maxRelays && relays.length > maxRelays) { + return { valid: false, error: `Too many relays (max ${maxRelays})` } + } + for (const relay of relays) { if (typeof relay !== 'string') { return { valid: false, error: 'Each relay must be a string' } @@ -166,7 +172,7 @@ export class Nip05Manager { // Validate relays if provided const relays = request.relays || this.config.default_relays if (relays.length > 0) { - const relayValidation = validateRelays(relays) + const relayValidation = validateRelays(relays, this.config.max_relays_per_user) if (!relayValidation.valid) { throw new Error(relayValidation.error) } @@ -241,7 +247,7 @@ export class Nip05Manager { request: UpdateRelaysRequest ): Promise { // Validate relays - const validation = validateRelays(request.relays) + const validation = validateRelays(request.relays, this.config.max_relays_per_user) if (!validation.valid) { throw new Error(validation.error) } @@ -361,10 +367,11 @@ export class Nip05Manager { } } } else { - // Return all active identities (with reasonable limit) + // Return all active identities (with configurable limit) + const limit = this.config.max_identities_listing const rows = await this.db.query( - `SELECT * FROM identities WHERE application_id = ? AND is_active = 1 LIMIT 1000`, - [applicationId] + `SELECT * FROM identities WHERE application_id = ? AND is_active = 1 LIMIT ?`, + [applicationId, limit] ) for (const row of rows) { diff --git a/src/extensions/nip05/types.ts b/src/extensions/nip05/types.ts index 93967f4c..6836f714 100644 --- a/src/extensions/nip05/types.ts +++ b/src/extensions/nip05/types.ts @@ -119,6 +119,12 @@ export interface Nip05Config { /** Default relays to suggest for new users */ default_relays?: string[] + + /** Maximum number of relays per user (default: 10) */ + max_relays_per_user?: number + + /** Maximum number of identities returned when no name query param is provided (default: 100) */ + max_identities_listing?: number } /**