feat(extensions): add LNURL-withdraw extension #6
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 express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import NewServer from '../proto/autogenerated/ts/express_server.js'
|
||||
import GetServerMethods from './services/serverMethods/index.js'
|
||||
import serverOptions from './auth.js';
|
||||
|
|
@ -8,9 +11,15 @@ import { initMainHandler, initSettings } from './services/main/init.js';
|
|||
import { nip19 } from 'nostr-tools'
|
||||
import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
|
||||
import { AppInfo } from './services/nostr/nostrPool.js';
|
||||
import { createExtensionLoader, ExtensionLoader } from './extensions/loader.js'
|
||||
import { createMainHandlerAdapter } from './extensions/mainHandlerAdapter.js'
|
||||
import type { HttpRoute } from './extensions/withdraw/types.js'
|
||||
//@ts-ignore
|
||||
const { nprofileEncode } = nip19
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
|
||||
const start = async () => {
|
||||
const log = getLogger({})
|
||||
|
|
@ -58,8 +67,91 @@ const start = async () => {
|
|||
wizard.AddConnectInfo(appNprofile, relays)
|
||||
}
|
||||
adminManager.setAppNprofile(appNprofile)
|
||||
|
||||
// Initialize extension system
|
||||
let extensionLoader: ExtensionLoader | null = null
|
||||
const mainPort = settingsManager.getSettings().serviceSettings.servicePort
|
||||
const extensionPort = mainPort + 1
|
||||
|
||||
// Extension routes run on a separate port (main port + 1)
|
||||
// SERVICE_URL for extensions should point to this port for LNURL to work
|
||||
// In production, use a reverse proxy to route /api/v1/lnurl/* to extension port
|
||||
const extensionServiceUrl = process.env.EXTENSION_SERVICE_URL || `http://localhost:${extensionPort}`
|
||||
|
||||
try {
|
||||
log("initializing extension system")
|
||||
const extensionsDir = path.join(__dirname, 'extensions')
|
||||
const databaseDir = path.join(__dirname, '..', 'data', 'extensions')
|
||||
|
||||
const mainHandlerAdapter = createMainHandlerAdapter(mainHandler)
|
||||
extensionLoader = createExtensionLoader(
|
||||
{ extensionsDir, databaseDir },
|
||||
mainHandlerAdapter
|
||||
)
|
||||
|
||||
await extensionLoader.loadAll()
|
||||
log(`loaded ${extensionLoader.getAllExtensions().length} extension(s)`)
|
||||
|
||||
// Set base URL for LNURL generation on withdraw extension
|
||||
const withdrawExt = extensionLoader.getExtension('withdraw')
|
||||
if (withdrawExt && withdrawExt.instance && 'setBaseUrl' in withdrawExt.instance) {
|
||||
(withdrawExt.instance as any).setBaseUrl(extensionServiceUrl)
|
||||
log(`withdraw extension base URL set to ${extensionServiceUrl}`)
|
||||
}
|
||||
} catch (e) {
|
||||
log(`extension system initialization failed: ${e}`)
|
||||
}
|
||||
|
||||
// Create Express app for extension HTTP routes
|
||||
const extensionApp = express()
|
||||
extensionApp.use(express.json())
|
||||
|
||||
// Mount extension HTTP routes
|
||||
if (extensionLoader) {
|
||||
for (const ext of extensionLoader.getAllExtensions()) {
|
||||
if (ext.status === 'ready' && 'getHttpRoutes' in ext.instance) {
|
||||
const routes = (ext.instance as any).getHttpRoutes() as HttpRoute[]
|
||||
for (const route of routes) {
|
||||
log(`mounting extension route: ${route.method} ${route.path}`)
|
||||
const handler = async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const httpReq = {
|
||||
params: req.params,
|
||||
query: req.query as Record<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))
|
||||
Server.Listen(settingsManager.getSettings().serviceSettings.servicePort)
|
||||
Server.Listen(mainPort)
|
||||
}
|
||||
start()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue