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 new file mode 100644 index 00000000..fec73c3f --- /dev/null +++ b/src/extensions/mainHandlerAdapter.ts @@ -0,0 +1,155 @@ +/** + * 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/nip05/index.ts b/src/extensions/nip05/index.ts new file mode 100644 index 00000000..c74c0bf5 --- /dev/null +++ b/src/extensions/nip05/index.ts @@ -0,0 +1,311 @@ +/** + * 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) + }, + // 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) + } + ] + } + + /** + * 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://..."] } + * } + */ + /** + * 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 + // 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': '*' + } + } + } + } + + /** + * 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}` + }) + + // 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, + 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 +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..4eba8225 --- /dev/null +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -0,0 +1,467 @@ +/** + * 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: [], + max_relays_per_user: 10, + max_identities_listing: 100 +} + +/** + * 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 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) { + return { valid: false, error: 'Username is required' } + } + + 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)` } + } + + if (normalized.length > config.max_username_length) { + return { valid: false, error: `Username must be at most ${config.max_username_length} characters` } + } + + // 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 + if (config.reserved_usernames.includes(normalized)) { + return { valid: false, error: 'This username is reserved' } + } + + return { valid: true } +} + +/** + * Validate relay URLs + */ +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' } + } + 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, this.config.max_relays_per_user) + 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, this.config.max_relays_per_user) + 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 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 ?`, + [applicationId, limit] + ) + + 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..6836f714 --- /dev/null +++ b/src/extensions/nip05/types.ts @@ -0,0 +1,136 @@ +/** + * 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[] + + /** 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 +} + +/** + * Validation result for username + */ +export interface UsernameValidation { + valid: boolean + error?: string +} 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 }> diff --git a/src/extensions/withdraw/index.ts b/src/extensions/withdraw/index.ts new file mode 100644 index 00000000..1a38930b --- /dev/null +++ b/src/extensions/withdraw/index.ts @@ -0,0 +1,383 @@ +/** + * 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 new file mode 100644 index 00000000..5f76008e --- /dev/null +++ b/src/extensions/withdraw/managers/withdrawManager.ts @@ -0,0 +1,717 @@ +/** + * 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 new file mode 100644 index 00000000..1625638a --- /dev/null +++ b/src/extensions/withdraw/migrations.ts @@ -0,0 +1,164 @@ +/** + * 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 new file mode 100644 index 00000000..88d25f33 --- /dev/null +++ b/src/extensions/withdraw/types.ts @@ -0,0 +1,264 @@ +/** + * 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 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') +} diff --git a/src/index.ts b/src/index.ts index fbe6802c..5fbb21dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ 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'; @@ -8,9 +12,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({}) @@ -25,6 +35,42 @@ 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 @@ -45,7 +91,8 @@ const start = async () => { { relays, maxEventContentLength, apps }, - (e, p) => mainHandler.liquidityProvider.onEvent(e, p) + (e, p) => mainHandler.liquidityProvider.onEvent(e, p), + { extensionLoader: extensionLoader || undefined } ) exitHandler(() => { Stop(); mainHandler.Stop() }) log("starting server") @@ -58,8 +105,58 @@ 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(settingsManager.getSettings().serviceSettings.servicePort) + Server.Listen(mainPort) } start() 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) 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,