feat: integrate extension system with withdraw extension support
- Add extension loader initialization to startup - Create mainHandlerAdapter to bridge mainHandler with extension context - Mount extension HTTP routes on separate port (main port + 1) - Configure EXTENSION_SERVICE_URL for LNURL link generation The withdraw extension provides LUD-03 LNURL-withdraw support for creating withdraw links that allow users to pull funds. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4bc0e1801f
commit
e3d70f84b9
2 changed files with 221 additions and 1 deletions
128
src/extensions/mainHandlerAdapter.ts
Normal file
128
src/extensions/mainHandlerAdapter.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* MainHandler Adapter for Extension System
|
||||||
|
*
|
||||||
|
* Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface
|
||||||
|
* required by the extension system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MainHandlerInterface } from './context.js'
|
||||||
|
import { LnurlPayInfo } from './types.js'
|
||||||
|
import type Main from '../services/main/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an adapter that wraps mainHandler for extension use
|
||||||
|
*/
|
||||||
|
export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface {
|
||||||
|
return {
|
||||||
|
applicationManager: {
|
||||||
|
async getById(id: string) {
|
||||||
|
// The applicationManager stores apps internally
|
||||||
|
// We need to access it through the storage layer
|
||||||
|
try {
|
||||||
|
const app = await mainHandler.storage.applicationStorage.GetApplication(id)
|
||||||
|
if (!app) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: app.app_id,
|
||||||
|
name: app.name,
|
||||||
|
nostr_public: app.nostr_public_key || '',
|
||||||
|
balance: app.owner?.balance_sats || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// GetApplication throws if not found
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
paymentManager: {
|
||||||
|
async createInvoice(params: {
|
||||||
|
applicationId: string
|
||||||
|
amountSats: number
|
||||||
|
memo?: string
|
||||||
|
expiry?: number
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}) {
|
||||||
|
// Get the app to find the user ID
|
||||||
|
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
|
||||||
|
if (!app) {
|
||||||
|
throw new Error(`Application not found: ${params.applicationId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create invoice using the app owner's user ID
|
||||||
|
const result = await mainHandler.paymentManager.NewInvoice(
|
||||||
|
app.owner.user_id,
|
||||||
|
{
|
||||||
|
amountSats: params.amountSats,
|
||||||
|
memo: params.memo || ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expiry: params.expiry || 3600
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.invoice.split(':')[0] || result.invoice, // Extract ID if present
|
||||||
|
paymentRequest: result.invoice,
|
||||||
|
paymentHash: '', // Not directly available from NewInvoice response
|
||||||
|
expiry: Date.now() + (params.expiry || 3600) * 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async payInvoice(params: {
|
||||||
|
applicationId: string
|
||||||
|
paymentRequest: string
|
||||||
|
maxFeeSats?: number
|
||||||
|
}) {
|
||||||
|
// Get the app to find the user ID and app reference
|
||||||
|
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
|
||||||
|
if (!app) {
|
||||||
|
throw new Error(`Application not found: ${params.applicationId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay invoice from the app's balance
|
||||||
|
const result = await mainHandler.paymentManager.PayInvoice(
|
||||||
|
app.owner.user_id,
|
||||||
|
{
|
||||||
|
invoice: params.paymentRequest,
|
||||||
|
amount: 0 // Use invoice amount
|
||||||
|
},
|
||||||
|
app, // linkedApplication
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: result.preimage || '', // preimage serves as proof of payment
|
||||||
|
feeSats: result.network_fee || 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
|
||||||
|
metadata?: string
|
||||||
|
description?: string
|
||||||
|
}): Promise<LnurlPayInfo> {
|
||||||
|
// 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<string | null> {
|
||||||
|
// 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<string> {
|
||||||
|
// This would need implementation using NIP-44 encryption
|
||||||
|
// For now, throw not implemented
|
||||||
|
throw new Error('sendEncryptedDM not yet implemented')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/index.ts
94
src/index.ts
|
|
@ -1,4 +1,7 @@
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
import express from 'express'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
import NewServer from '../proto/autogenerated/ts/express_server.js'
|
import NewServer from '../proto/autogenerated/ts/express_server.js'
|
||||||
import GetServerMethods from './services/serverMethods/index.js'
|
import GetServerMethods from './services/serverMethods/index.js'
|
||||||
import serverOptions from './auth.js';
|
import serverOptions from './auth.js';
|
||||||
|
|
@ -8,9 +11,15 @@ import { initMainHandler, initSettings } from './services/main/init.js';
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
|
import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
|
||||||
import { AppInfo } from './services/nostr/nostrPool.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
|
//@ts-ignore
|
||||||
const { nprofileEncode } = nip19
|
const { nprofileEncode } = nip19
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
const log = getLogger({})
|
const log = getLogger({})
|
||||||
|
|
@ -58,8 +67,91 @@ const start = async () => {
|
||||||
wizard.AddConnectInfo(appNprofile, relays)
|
wizard.AddConnectInfo(appNprofile, relays)
|
||||||
}
|
}
|
||||||
adminManager.setAppNprofile(appNprofile)
|
adminManager.setAppNprofile(appNprofile)
|
||||||
|
|
||||||
|
// Initialize extension system
|
||||||
|
let extensionLoader: ExtensionLoader | null = null
|
||||||
|
const mainPort = settingsManager.getSettings().serviceSettings.servicePort
|
||||||
|
const extensionPort = mainPort + 1
|
||||||
|
|
||||||
|
// Extension routes run on a separate port (main port + 1)
|
||||||
|
// SERVICE_URL for extensions should point to this port for LNURL to work
|
||||||
|
// In production, use a reverse proxy to route /api/v1/lnurl/* to extension port
|
||||||
|
const extensionServiceUrl = process.env.EXTENSION_SERVICE_URL || `http://localhost:${extensionPort}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
log("initializing extension system")
|
||||||
|
const extensionsDir = path.join(__dirname, 'extensions')
|
||||||
|
const databaseDir = path.join(__dirname, '..', 'data', 'extensions')
|
||||||
|
|
||||||
|
const mainHandlerAdapter = createMainHandlerAdapter(mainHandler)
|
||||||
|
extensionLoader = createExtensionLoader(
|
||||||
|
{ extensionsDir, databaseDir },
|
||||||
|
mainHandlerAdapter
|
||||||
|
)
|
||||||
|
|
||||||
|
await extensionLoader.loadAll()
|
||||||
|
log(`loaded ${extensionLoader.getAllExtensions().length} extension(s)`)
|
||||||
|
|
||||||
|
// Set base URL for LNURL generation on withdraw extension
|
||||||
|
const withdrawExt = extensionLoader.getExtension('withdraw')
|
||||||
|
if (withdrawExt && withdrawExt.instance && 'setBaseUrl' in withdrawExt.instance) {
|
||||||
|
(withdrawExt.instance as any).setBaseUrl(extensionServiceUrl)
|
||||||
|
log(`withdraw extension base URL set to ${extensionServiceUrl}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`extension system initialization failed: ${e}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Express app for extension HTTP routes
|
||||||
|
const extensionApp = express()
|
||||||
|
extensionApp.use(express.json())
|
||||||
|
|
||||||
|
// Mount extension HTTP routes
|
||||||
|
if (extensionLoader) {
|
||||||
|
for (const ext of extensionLoader.getAllExtensions()) {
|
||||||
|
if (ext.status === 'ready' && 'getHttpRoutes' in ext.instance) {
|
||||||
|
const routes = (ext.instance as any).getHttpRoutes() as HttpRoute[]
|
||||||
|
for (const route of routes) {
|
||||||
|
log(`mounting extension route: ${route.method} ${route.path}`)
|
||||||
|
const handler = async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const httpReq = {
|
||||||
|
params: req.params,
|
||||||
|
query: req.query as Record<string, string>,
|
||||||
|
body: req.body,
|
||||||
|
headers: req.headers as Record<string, string>
|
||||||
|
}
|
||||||
|
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))
|
const Server = NewServer(serverMethods, serverOptions(mainHandler))
|
||||||
Server.Listen(settingsManager.getSettings().serviceSettings.servicePort)
|
Server.Listen(mainPort)
|
||||||
}
|
}
|
||||||
start()
|
start()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue