diff --git a/src/extensions/context.ts b/src/extensions/context.ts index f18891f5..b1c6e8d6 100644 --- a/src/extensions/context.ts +++ b/src/extensions/context.ts @@ -20,17 +20,6 @@ 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 @@ -52,7 +41,6 @@ export interface MainHandlerInterface { applicationId: string paymentRequest: string maxFeeSats?: number - userPubkey?: string }): Promise<{ paymentHash: string feeSats: number @@ -168,19 +156,16 @@ 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, - userPubkey?: string + maxFeeSats?: number ): Promise<{ paymentHash: string; feeSats: number }> { return this.mainHandler.paymentManager.payInvoice({ applicationId, paymentRequest, - maxFeeSats, - userPubkey + maxFeeSats }) } diff --git a/src/extensions/mainHandlerAdapter.ts b/src/extensions/mainHandlerAdapter.ts deleted file mode 100644 index fec73c3f..00000000 --- a/src/extensions/mainHandlerAdapter.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 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 - } - }, - - async PayAppUserInvoice(appId, req) { - return mainHandler.applicationManager.PayAppUserInvoice(appId, req) - } - }, - - 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 - userPubkey?: string - }) { - // 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}`) - } - - 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 - }, - app, - {} - ) - - return { - paymentHash: result.preimage || '', - 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/extensions/types.ts b/src/extensions/types.ts index 66a4c46a..2027fb09 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -140,9 +140,8 @@ 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, userPubkey?: string): Promise<{ + payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number): Promise<{ paymentHash: string feeSats: number }> diff --git a/src/extensions/withdraw/index.ts b/src/extensions/withdraw/index.ts deleted file mode 100644 index 1a38930b..00000000 --- a/src/extensions/withdraw/index.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * 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 [ - // 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/cb/:unique_hash', - handler: this.handleLnurlCallback.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) - }, - // Initial LNURL request (simple link) - MUST be last (catches all) - { - method: 'GET', - path: '/api/v1/lnurl/:unique_hash', - handler: this.handleLnurlRequest.bind(this) - } - ] - } - - /** - * Register RPC methods with the extension context - */ - private registerRpcMethods(ctx: ExtensionContext): void { - // Create withdraw link - 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, - 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 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 - */ - 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 deleted file mode 100644 index 5f76008e..00000000 --- a/src/extensions/withdraw/managers/withdrawManager.ts +++ /dev/null @@ -1,717 +0,0 @@ -/** - * 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 - creator_pubkey: string | null - 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, - creator_pubkey: row.creator_pubkey || undefined, - 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, creatorPubkey?: string): 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, - creator_pubkey: creatorPubkey, - 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, - creator_pubkey, - 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.creator_pubkey || null, - 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 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.creator_pubkey - ) - - // 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 deleted file mode 100644 index 1625638a..00000000 --- a/src/extensions/withdraw/migrations.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * 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 - ) - `) - } - }, - { - 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 - `) - } - } -] - -/** - * 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 deleted file mode 100644 index 88d25f33..00000000 --- a/src/extensions/withdraw/types.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * 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 - - // 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 - 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 deleted file mode 100644 index 96926c52..00000000 --- a/src/extensions/withdraw/utils/lnurl.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * 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') -} diff --git a/src/index.ts b/src/index.ts index 5fbb21dc..fbe6802c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,4 @@ 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' import GetServerMethods from './services/serverMethods/index.js' import serverOptions from './auth.js'; @@ -12,15 +8,9 @@ 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({}) @@ -35,42 +25,6 @@ const start = async () => { const { mainHandler, localProviderClient, wizard, adminManager } = keepOn const serverMethods = GetServerMethods(mainHandler) - - // 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 - - // 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}`) - } - - // 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 @@ -91,8 +45,7 @@ const start = async () => { { relays, maxEventContentLength, apps }, - (e, p) => mainHandler.liquidityProvider.onEvent(e, p), - { extensionLoader: extensionLoader || undefined } + (e, p) => mainHandler.liquidityProvider.onEvent(e, p) ) exitHandler(() => { Stop(); mainHandler.Stop() }) log("starting server") @@ -105,58 +58,8 @@ const start = async () => { 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.) - 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(mainPort) + Server.Listen(settingsManager.getSettings().serviceSettings.servicePort) } start() diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index a7131375..4dd3b281 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -5,15 +5,9 @@ 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 type NostrMiddlewareOptions = { - extensionLoader?: ExtensionLoader -} - -export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback, options?: NostrMiddlewareOptions): ExportedCalls => { +export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback): ExportedCalls => { const log = getLogger({}) const nostrTransport = NewNostrTransport(serverMethods, { NostrUserAuthGuard: async (appId, pub) => { @@ -101,31 +95,6 @@ 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) diff --git a/src/services/lnd/payInvoiceReq.ts b/src/services/lnd/payInvoiceReq.ts index 3ae28c30..3b90dd3d 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: true, + allowSelfPayment: false, amp: false, amtMsat: 0n, cltvLimit: 0,