Compare commits
19 commits
4c69a4d390
...
c08bf4b849
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08bf4b849 | ||
|
|
ed7e0d1f86 | ||
|
|
9d352e5f07 | ||
|
|
55077d818e | ||
|
|
512c83ad21 | ||
|
|
2cac13b5a4 | ||
|
|
de233b16c7 | ||
|
|
59d3b0e888 | ||
|
|
4d0d12a4d9 | ||
|
|
b31d043d6f | ||
|
|
64bf155bfb | ||
|
|
211776ff2e | ||
|
|
ba999f8b33 | ||
|
|
2ceec5b571 | ||
|
|
01ea762ec0 | ||
|
|
2ce5a8ffcd | ||
|
|
48ee930b36 | ||
|
|
cc9aa49b5b | ||
|
|
3b03c64d0c |
29 changed files with 5028 additions and 1890 deletions
|
|
@ -2,3 +2,21 @@
|
|||
.github
|
||||
build
|
||||
node_modules
|
||||
|
||||
# Runtime state files (should not be baked into image)
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
*.db
|
||||
admin.connect
|
||||
admin.enroll
|
||||
admin.npub
|
||||
app.nprofile
|
||||
.jwt_secret
|
||||
|
||||
# Runtime data directories
|
||||
metric_cache/
|
||||
metric_events/
|
||||
bundler_events/
|
||||
logs/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:18
|
||||
FROM node:20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
|
|||
2926
package-lock.json
generated
2926
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -77,6 +77,7 @@
|
|||
"zip-a-folder": "^3.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^4.3.4",
|
||||
"@types/chai-string": "^1.4.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
|
|
@ -93,4 +94,4 @@
|
|||
"typescript": "5.5.4"
|
||||
},
|
||||
"overrides": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
731
src/extensions/README.md
Normal file
731
src/extensions/README.md
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
# Lightning.Pub Extension System
|
||||
|
||||
A modular extension system that allows third-party functionality to be added to Lightning.Pub without modifying core code.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Creating an Extension](#creating-an-extension)
|
||||
- [Extension Lifecycle](#extension-lifecycle)
|
||||
- [ExtensionContext API](#extensioncontext-api)
|
||||
- [Database Isolation](#database-isolation)
|
||||
- [RPC Methods](#rpc-methods)
|
||||
- [HTTP Routes](#http-routes)
|
||||
- [Event Handling](#event-handling)
|
||||
- [Configuration](#configuration)
|
||||
- [Examples](#examples)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The extension system provides:
|
||||
|
||||
- **Modularity**: Extensions are self-contained modules with their own code and data
|
||||
- **Isolation**: Each extension gets its own SQLite database
|
||||
- **Integration**: Extensions can register RPC methods, handle events, and interact with Lightning.Pub's payment and Nostr systems
|
||||
- **Lifecycle Management**: Automatic discovery, loading, and graceful shutdown
|
||||
|
||||
### Built-in Extensions
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `marketplace` | NIP-15 Nostr marketplace for selling products via Lightning |
|
||||
| `withdraw` | LNURL-withdraw (LUD-03) for vouchers, faucets, and gifts |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Lightning.Pub │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Extension Loader │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Extension A │ │ Extension B │ │ Extension C │ ... │
|
||||
│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │
|
||||
│ │ │Context│ │ │ │Context│ │ │ │Context│ │ │
|
||||
│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │
|
||||
│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │
|
||||
│ │ │ DB │ │ │ │ DB │ │ │ │ DB │ │ │
|
||||
│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Payment Manager │ Nostr Transport │ Application Manager │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
| Component | File | Description |
|
||||
|-----------|------|-------------|
|
||||
| `ExtensionLoader` | `loader.ts` | Discovers, loads, and manages extensions |
|
||||
| `ExtensionContext` | `context.ts` | Bridge between extensions and Lightning.Pub |
|
||||
| `ExtensionDatabase` | `database.ts` | Isolated SQLite database per extension |
|
||||
|
||||
---
|
||||
|
||||
## Creating an Extension
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/extensions/
|
||||
└── my-extension/
|
||||
├── index.ts # Main entry point (required)
|
||||
├── types.ts # TypeScript interfaces
|
||||
├── migrations.ts # Database migrations
|
||||
└── managers/ # Business logic
|
||||
└── myManager.ts
|
||||
```
|
||||
|
||||
### Minimal Extension
|
||||
|
||||
```typescript
|
||||
// src/extensions/my-extension/index.ts
|
||||
|
||||
import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js'
|
||||
|
||||
export default class MyExtension implements Extension {
|
||||
readonly info: ExtensionInfo = {
|
||||
id: 'my-extension', // Must match directory name
|
||||
name: 'My Extension',
|
||||
version: '1.0.0',
|
||||
description: 'Does something useful',
|
||||
author: 'Your Name',
|
||||
minPubVersion: '1.0.0' // Minimum Lightning.Pub version
|
||||
}
|
||||
|
||||
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||
// Run migrations
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS my_table (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Register RPC methods
|
||||
ctx.registerMethod('my-extension.doSomething', async (req, appId) => {
|
||||
return { result: 'done' }
|
||||
})
|
||||
|
||||
ctx.log('info', 'Extension initialized')
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
// Cleanup resources
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extension Interface
|
||||
|
||||
```typescript
|
||||
interface Extension {
|
||||
// Required: Extension metadata
|
||||
readonly info: ExtensionInfo
|
||||
|
||||
// Required: Called once when extension is loaded
|
||||
initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void>
|
||||
|
||||
// Optional: Called when Lightning.Pub shuts down
|
||||
shutdown?(): Promise<void>
|
||||
|
||||
// Optional: Health check for monitoring
|
||||
healthCheck?(): Promise<boolean>
|
||||
}
|
||||
|
||||
interface ExtensionInfo {
|
||||
id: string // Unique identifier (lowercase, no spaces)
|
||||
name: string // Display name
|
||||
version: string // Semver version
|
||||
description: string // Short description
|
||||
author: string // Author name
|
||||
minPubVersion?: string // Minimum Lightning.Pub version
|
||||
dependencies?: string[] // Other extension IDs required
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension Lifecycle
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Discover │ Scan extensions directory for index.ts files
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Load │ Import module, instantiate class
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Initialize │ Create database, call initialize()
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Ready │ Extension is active, handling requests
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼ (on shutdown)
|
||||
┌──────────────┐
|
||||
│ Shutdown │ Call shutdown(), close database
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### States
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `loading` | Extension is being loaded |
|
||||
| `ready` | Extension is active and healthy |
|
||||
| `error` | Initialization failed |
|
||||
| `stopped` | Extension has been shut down |
|
||||
|
||||
---
|
||||
|
||||
## ExtensionContext API
|
||||
|
||||
The `ExtensionContext` is passed to your extension during initialization. It provides access to Lightning.Pub functionality.
|
||||
|
||||
### Application Management
|
||||
|
||||
```typescript
|
||||
// Get information about an application
|
||||
const app = await ctx.getApplication(applicationId)
|
||||
// Returns: { id, name, nostr_public, balance_sats } | null
|
||||
```
|
||||
|
||||
### Payment Operations
|
||||
|
||||
```typescript
|
||||
// Create a Lightning invoice
|
||||
const invoice = await ctx.createInvoice(amountSats, {
|
||||
memo: 'Payment for service',
|
||||
expiry: 3600, // seconds
|
||||
metadata: { order_id: '123' } // Returned in payment callback
|
||||
})
|
||||
// Returns: { id, paymentRequest, paymentHash, expiry }
|
||||
|
||||
// Pay a Lightning invoice
|
||||
const result = await ctx.payInvoice(applicationId, bolt11Invoice, maxFeeSats)
|
||||
// Returns: { paymentHash, feeSats }
|
||||
```
|
||||
|
||||
### Nostr Operations
|
||||
|
||||
```typescript
|
||||
// Send encrypted DM (NIP-44)
|
||||
const eventId = await ctx.sendEncryptedDM(applicationId, recipientPubkey, content)
|
||||
|
||||
// Publish a Nostr event (signed by application's key)
|
||||
const eventId = await ctx.publishNostrEvent({
|
||||
kind: 30017,
|
||||
pubkey: appPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 'identifier']],
|
||||
content: JSON.stringify(data)
|
||||
})
|
||||
```
|
||||
|
||||
### RPC Method Registration
|
||||
|
||||
```typescript
|
||||
// Register a method that can be called via RPC
|
||||
ctx.registerMethod('my-extension.methodName', async (request, applicationId, userPubkey?) => {
|
||||
// request: The RPC request payload
|
||||
// applicationId: The calling application's ID
|
||||
// userPubkey: The user's Nostr pubkey (if authenticated)
|
||||
|
||||
return { result: 'success' }
|
||||
})
|
||||
```
|
||||
|
||||
### Event Subscriptions
|
||||
|
||||
```typescript
|
||||
// Subscribe to payment received events
|
||||
ctx.onPaymentReceived(async (payment) => {
|
||||
// payment: { invoiceId, paymentHash, amountSats, metadata }
|
||||
|
||||
if (payment.metadata?.extension === 'my-extension') {
|
||||
// Handle payment for this extension
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to incoming Nostr events
|
||||
ctx.onNostrEvent(async (event, applicationId) => {
|
||||
// event: { id, pubkey, kind, tags, content, created_at }
|
||||
// applicationId: The application this event is for
|
||||
|
||||
if (event.kind === 4) { // DM
|
||||
// Handle incoming message
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
```typescript
|
||||
ctx.log('debug', 'Detailed debugging info')
|
||||
ctx.log('info', 'Normal operation info')
|
||||
ctx.log('warn', 'Warning message')
|
||||
ctx.log('error', 'Error occurred', errorObject)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Isolation
|
||||
|
||||
Each extension gets its own SQLite database file at:
|
||||
```
|
||||
{databaseDir}/{extension-id}.db
|
||||
```
|
||||
|
||||
### Database Interface
|
||||
|
||||
```typescript
|
||||
interface ExtensionDatabase {
|
||||
// Execute write queries (INSERT, UPDATE, DELETE, CREATE)
|
||||
execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }>
|
||||
|
||||
// Execute read queries (SELECT)
|
||||
query<T>(sql: string, params?: any[]): Promise<T[]>
|
||||
|
||||
// Run multiple statements in a transaction
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Pattern
|
||||
|
||||
```typescript
|
||||
// migrations.ts
|
||||
|
||||
export interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
up: (db: ExtensionDatabase) => Promise<void>
|
||||
}
|
||||
|
||||
export const migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'create_initial_tables',
|
||||
up: async (db) => {
|
||||
await db.execute(`
|
||||
CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: 'add_status_column',
|
||||
up: async (db) => {
|
||||
await db.execute(`ALTER TABLE items ADD COLUMN status TEXT DEFAULT 'active'`)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Run migrations in initialize()
|
||||
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
|
||||
const result = await db.query<{ value: string }>(
|
||||
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
|
||||
).catch(() => [])
|
||||
|
||||
const currentVersion = result.length > 0 ? parseInt(result[0].value, 10) : 0
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (migration.version > currentVersion) {
|
||||
console.log(`Running migration ${migration.version}: ${migration.name}`)
|
||||
await migration.up(db)
|
||||
await db.execute(
|
||||
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[String(migration.version)]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RPC Methods
|
||||
|
||||
Extensions register RPC methods that can be called by clients.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
Methods should be namespaced with the extension ID:
|
||||
```
|
||||
{extension-id}.{methodName}
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `marketplace.createStall`
|
||||
- `withdraw.createLink`
|
||||
|
||||
### Method Handler Signature
|
||||
|
||||
```typescript
|
||||
type RpcMethodHandler = (
|
||||
request: any, // The request payload
|
||||
applicationId: string, // The calling application
|
||||
userPubkey?: string // The authenticated user (if any)
|
||||
) => Promise<any>
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
ctx.registerMethod('my-extension.createItem', async (req, appId, userPubkey) => {
|
||||
// Validate request
|
||||
if (!req.name) {
|
||||
throw new Error('Name is required')
|
||||
}
|
||||
|
||||
// Create item
|
||||
const item = await this.manager.create(appId, req)
|
||||
|
||||
// Return response
|
||||
return { item }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Routes
|
||||
|
||||
Some extensions need HTTP endpoints (e.g., LNURL protocol). Extensions can define routes that the main application mounts.
|
||||
|
||||
### Defining Routes
|
||||
|
||||
```typescript
|
||||
interface HttpRoute {
|
||||
method: 'GET' | 'POST'
|
||||
path: string
|
||||
handler: (req: HttpRequest) => Promise<HttpResponse>
|
||||
}
|
||||
|
||||
interface HttpRequest {
|
||||
params: Record<string, string> // URL path params
|
||||
query: Record<string, string> // Query string params
|
||||
body?: any // POST body
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface HttpResponse {
|
||||
status: number
|
||||
body: any
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
class MyExtension implements Extension {
|
||||
getHttpRoutes(): HttpRoute[] {
|
||||
return [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/my-extension/:id',
|
||||
handler: async (req) => {
|
||||
const item = await this.getItem(req.params.id)
|
||||
return {
|
||||
status: 200,
|
||||
body: item,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Handling
|
||||
|
||||
### Payment Callbacks
|
||||
|
||||
When you create an invoice with metadata, you'll receive that metadata back in the payment callback:
|
||||
|
||||
```typescript
|
||||
// Creating invoice with metadata
|
||||
const invoice = await ctx.createInvoice(1000, {
|
||||
metadata: {
|
||||
extension: 'my-extension',
|
||||
order_id: 'order-123'
|
||||
}
|
||||
})
|
||||
|
||||
// Handling payment
|
||||
ctx.onPaymentReceived(async (payment) => {
|
||||
if (payment.metadata?.extension === 'my-extension') {
|
||||
const orderId = payment.metadata.order_id
|
||||
await this.handlePayment(orderId, payment)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Nostr Events
|
||||
|
||||
Subscribe to Nostr events for your application:
|
||||
|
||||
```typescript
|
||||
ctx.onNostrEvent(async (event, applicationId) => {
|
||||
// Filter by event kind
|
||||
if (event.kind === 4) { // Encrypted DM
|
||||
await this.handleDirectMessage(event, applicationId)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Loader Configuration
|
||||
|
||||
```typescript
|
||||
interface ExtensionLoaderConfig {
|
||||
extensionsDir: string // Directory containing extensions
|
||||
databaseDir: string // Directory for extension databases
|
||||
enabledExtensions?: string[] // Whitelist (if set, only these load)
|
||||
disabledExtensions?: string[] // Blacklist
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { createExtensionLoader } from './extensions'
|
||||
|
||||
const loader = createExtensionLoader({
|
||||
extensionsDir: './src/extensions',
|
||||
databaseDir: './data/extensions',
|
||||
disabledExtensions: ['experimental-ext']
|
||||
}, mainHandler)
|
||||
|
||||
await loader.loadAll()
|
||||
|
||||
// Call extension methods
|
||||
const result = await loader.callMethod(
|
||||
'marketplace.createStall',
|
||||
{ name: 'My Shop', currency: 'sat', shipping_zones: [] },
|
||||
applicationId,
|
||||
userPubkey
|
||||
)
|
||||
|
||||
// Dispatch events
|
||||
loader.dispatchPaymentReceived(paymentData)
|
||||
loader.dispatchNostrEvent(event, applicationId)
|
||||
|
||||
// Shutdown
|
||||
await loader.shutdown()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Example: Simple Counter Extension
|
||||
|
||||
```typescript
|
||||
// src/extensions/counter/index.ts
|
||||
|
||||
import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js'
|
||||
|
||||
export default class CounterExtension implements Extension {
|
||||
readonly info: ExtensionInfo = {
|
||||
id: 'counter',
|
||||
name: 'Simple Counter',
|
||||
version: '1.0.0',
|
||||
description: 'A simple counter for each application',
|
||||
author: 'Example'
|
||||
}
|
||||
|
||||
private db!: ExtensionDatabase
|
||||
|
||||
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||
this.db = db
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
application_id TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`)
|
||||
|
||||
ctx.registerMethod('counter.increment', async (req, appId) => {
|
||||
await db.execute(
|
||||
`INSERT INTO counters (application_id, count) VALUES (?, 1)
|
||||
ON CONFLICT(application_id) DO UPDATE SET count = count + 1`,
|
||||
[appId]
|
||||
)
|
||||
const result = await db.query<{ count: number }>(
|
||||
'SELECT count FROM counters WHERE application_id = ?',
|
||||
[appId]
|
||||
)
|
||||
return { count: result[0]?.count || 0 }
|
||||
})
|
||||
|
||||
ctx.registerMethod('counter.get', async (req, appId) => {
|
||||
const result = await db.query<{ count: number }>(
|
||||
'SELECT count FROM counters WHERE application_id = ?',
|
||||
[appId]
|
||||
)
|
||||
return { count: result[0]?.count || 0 }
|
||||
})
|
||||
|
||||
ctx.registerMethod('counter.reset', async (req, appId) => {
|
||||
await db.execute(
|
||||
'UPDATE counters SET count = 0 WHERE application_id = ?',
|
||||
[appId]
|
||||
)
|
||||
return { count: 0 }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Payment-Triggered Extension
|
||||
|
||||
```typescript
|
||||
// src/extensions/donations/index.ts
|
||||
|
||||
import { Extension, ExtensionContext, ExtensionDatabase } from '../types.js'
|
||||
|
||||
export default class DonationsExtension implements Extension {
|
||||
readonly info = {
|
||||
id: 'donations',
|
||||
name: 'Donations',
|
||||
version: '1.0.0',
|
||||
description: 'Accept donations with thank-you messages',
|
||||
author: 'Example'
|
||||
}
|
||||
|
||||
private db!: ExtensionDatabase
|
||||
private ctx!: ExtensionContext
|
||||
|
||||
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||
this.db = db
|
||||
this.ctx = ctx
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS donations (
|
||||
id TEXT PRIMARY KEY,
|
||||
application_id TEXT NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
donor_pubkey TEXT,
|
||||
message TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Create donation invoice
|
||||
ctx.registerMethod('donations.createInvoice', async (req, appId) => {
|
||||
const invoice = await ctx.createInvoice(req.amount_sats, {
|
||||
memo: req.message || 'Donation',
|
||||
metadata: {
|
||||
extension: 'donations',
|
||||
donor_pubkey: req.donor_pubkey,
|
||||
message: req.message
|
||||
}
|
||||
})
|
||||
return { invoice: invoice.paymentRequest }
|
||||
})
|
||||
|
||||
// Handle successful payments
|
||||
ctx.onPaymentReceived(async (payment) => {
|
||||
if (payment.metadata?.extension !== 'donations') return
|
||||
|
||||
// Record donation
|
||||
await db.execute(
|
||||
`INSERT INTO donations (id, application_id, amount_sats, donor_pubkey, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
payment.paymentHash,
|
||||
payment.metadata.application_id,
|
||||
payment.amountSats,
|
||||
payment.metadata.donor_pubkey,
|
||||
payment.metadata.message,
|
||||
Math.floor(Date.now() / 1000)
|
||||
]
|
||||
)
|
||||
|
||||
// Send thank-you DM if donor has pubkey
|
||||
if (payment.metadata.donor_pubkey) {
|
||||
await ctx.sendEncryptedDM(
|
||||
payment.metadata.application_id,
|
||||
payment.metadata.donor_pubkey,
|
||||
`Thank you for your donation of ${payment.amountSats} sats!`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// List donations
|
||||
ctx.registerMethod('donations.list', async (req, appId) => {
|
||||
const donations = await db.query(
|
||||
`SELECT * FROM donations WHERE application_id = ? ORDER BY created_at DESC LIMIT ?`,
|
||||
[appId, req.limit || 50]
|
||||
)
|
||||
return { donations }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Namespace your methods**: Always prefix RPC methods with your extension ID
|
||||
2. **Use migrations**: Never modify existing migration files; create new ones
|
||||
3. **Handle errors gracefully**: Throw descriptive errors, don't return error objects
|
||||
4. **Clean up in shutdown**: Close connections, cancel timers, etc.
|
||||
5. **Log appropriately**: Use debug for verbose info, error for failures
|
||||
6. **Validate inputs**: Check request parameters before processing
|
||||
7. **Use transactions**: For multi-step database operations
|
||||
8. **Document your API**: Include types and descriptions for RPC methods
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Extension not loading
|
||||
|
||||
1. Check that directory name matches `info.id`
|
||||
2. Verify `index.ts` has a default export
|
||||
3. Check for TypeScript/import errors in logs
|
||||
|
||||
### Database errors
|
||||
|
||||
1. Check migration syntax
|
||||
2. Verify column types match queries
|
||||
3. Look for migration version conflicts
|
||||
|
||||
### RPC method not found
|
||||
|
||||
1. Verify method is registered in `initialize()`
|
||||
2. Check method name includes extension prefix
|
||||
3. Ensure extension status is `ready`
|
||||
|
||||
### Payment callbacks not firing
|
||||
|
||||
1. Verify `metadata.extension` matches your extension ID
|
||||
2. Check that `onPaymentReceived` is registered in `initialize()`
|
||||
3. Confirm invoice was created through the extension
|
||||
324
src/extensions/context.ts
Normal file
324
src/extensions/context.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import {
|
||||
ExtensionContext,
|
||||
ExtensionDatabase,
|
||||
ExtensionInfo,
|
||||
ApplicationInfo,
|
||||
CreateInvoiceOptions,
|
||||
CreatedInvoice,
|
||||
PaymentReceivedData,
|
||||
NostrEvent,
|
||||
UnsignedNostrEvent,
|
||||
RpcMethodHandler,
|
||||
LnurlPayInfo
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Main Handler interface (from Lightning.Pub)
|
||||
* This is a minimal interface - the actual MainHandler has more methods
|
||||
*/
|
||||
export interface MainHandlerInterface {
|
||||
// Application management
|
||||
applicationManager: {
|
||||
getById(id: string): Promise<any>
|
||||
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
|
||||
paymentManager: {
|
||||
createInvoice(params: {
|
||||
applicationId: string
|
||||
amountSats: number
|
||||
memo?: string
|
||||
expiry?: number
|
||||
metadata?: Record<string, any>
|
||||
}): Promise<{
|
||||
id: string
|
||||
paymentRequest: string
|
||||
paymentHash: string
|
||||
expiry: number
|
||||
}>
|
||||
|
||||
payInvoice(params: {
|
||||
applicationId: string
|
||||
paymentRequest: string
|
||||
maxFeeSats?: number
|
||||
userPubkey?: string
|
||||
}): Promise<{
|
||||
paymentHash: string
|
||||
feeSats: number
|
||||
}>
|
||||
|
||||
/**
|
||||
* Get LNURL-pay info for a user by their Nostr pubkey
|
||||
* This enables Lightning Address (LUD-16) and zap (NIP-57) support
|
||||
*/
|
||||
getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
|
||||
metadata?: string
|
||||
description?: string
|
||||
}): Promise<LnurlPayInfo>
|
||||
}
|
||||
|
||||
// Nostr operations
|
||||
sendNostrEvent(event: any): Promise<string | null>
|
||||
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback registries for extension events
|
||||
*/
|
||||
interface CallbackRegistries {
|
||||
paymentReceived: Array<(payment: PaymentReceivedData) => Promise<void>>
|
||||
nostrEvent: Array<(event: NostrEvent, applicationId: string) => Promise<void>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered RPC method
|
||||
*/
|
||||
interface RegisteredMethod {
|
||||
extensionId: string
|
||||
handler: RpcMethodHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension Context Implementation
|
||||
*
|
||||
* Provides the interface for extensions to interact with Lightning.Pub.
|
||||
* Each extension gets its own context instance.
|
||||
*/
|
||||
export class ExtensionContextImpl implements ExtensionContext {
|
||||
private callbacks: CallbackRegistries = {
|
||||
paymentReceived: [],
|
||||
nostrEvent: []
|
||||
}
|
||||
|
||||
constructor(
|
||||
private extensionInfo: ExtensionInfo,
|
||||
private database: ExtensionDatabase,
|
||||
private mainHandler: MainHandlerInterface,
|
||||
private methodRegistry: Map<string, RegisteredMethod>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get information about an application
|
||||
*/
|
||||
async getApplication(applicationId: string): Promise<ApplicationInfo | null> {
|
||||
try {
|
||||
const app = await this.mainHandler.applicationManager.getById(applicationId)
|
||||
if (!app) return null
|
||||
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
nostr_public: app.nostr_public,
|
||||
balance_sats: app.balance || 0
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('error', `Failed to get application ${applicationId}:`, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async createInvoice(amountSats: number, options: CreateInvoiceOptions = {}): Promise<CreatedInvoice> {
|
||||
// Note: In practice, this needs an applicationId. Extensions typically
|
||||
// get this from the RPC request context. For now, we'll need to handle
|
||||
// this in the actual implementation.
|
||||
throw new Error('createInvoice requires applicationId from request context')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create invoice with explicit application ID
|
||||
* This is the internal method used by extensions
|
||||
*/
|
||||
async createInvoiceForApp(
|
||||
applicationId: string,
|
||||
amountSats: number,
|
||||
options: CreateInvoiceOptions = {}
|
||||
): Promise<CreatedInvoice> {
|
||||
const result = await this.mainHandler.paymentManager.createInvoice({
|
||||
applicationId,
|
||||
amountSats,
|
||||
memo: options.memo,
|
||||
expiry: options.expiry,
|
||||
metadata: {
|
||||
...options.metadata,
|
||||
extension: this.extensionInfo.id
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
paymentRequest: result.paymentRequest,
|
||||
paymentHash: result.paymentHash,
|
||||
expiry: result.expiry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice
|
||||
* If userPubkey is provided, pays from that user's balance instead of app.owner
|
||||
*/
|
||||
async payInvoice(
|
||||
applicationId: string,
|
||||
paymentRequest: string,
|
||||
maxFeeSats?: number,
|
||||
userPubkey?: string
|
||||
): Promise<{ paymentHash: string; feeSats: number }> {
|
||||
return this.mainHandler.paymentManager.payInvoice({
|
||||
applicationId,
|
||||
paymentRequest,
|
||||
maxFeeSats,
|
||||
userPubkey
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM via Nostr
|
||||
*/
|
||||
async sendEncryptedDM(
|
||||
applicationId: string,
|
||||
recipientPubkey: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
return this.mainHandler.sendEncryptedDM(applicationId, recipientPubkey, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a Nostr event
|
||||
*/
|
||||
async publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null> {
|
||||
return this.mainHandler.sendNostrEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LNURL-pay info for a user by pubkey
|
||||
* Enables Lightning Address and zap support
|
||||
*/
|
||||
async getLnurlPayInfo(pubkeyHex: string, options?: {
|
||||
metadata?: string
|
||||
description?: string
|
||||
}): Promise<LnurlPayInfo> {
|
||||
return this.mainHandler.paymentManager.getLnurlPayInfoByPubkey(pubkeyHex, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to payment received callbacks
|
||||
*/
|
||||
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void {
|
||||
this.callbacks.paymentReceived.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming Nostr events
|
||||
*/
|
||||
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): void {
|
||||
this.callbacks.nostrEvent.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an RPC method
|
||||
*/
|
||||
registerMethod(name: string, handler: RpcMethodHandler): void {
|
||||
const fullName = name.startsWith(`${this.extensionInfo.id}.`)
|
||||
? name
|
||||
: `${this.extensionInfo.id}.${name}`
|
||||
|
||||
if (this.methodRegistry.has(fullName)) {
|
||||
throw new Error(`RPC method ${fullName} already registered`)
|
||||
}
|
||||
|
||||
this.methodRegistry.set(fullName, {
|
||||
extensionId: this.extensionInfo.id,
|
||||
handler
|
||||
})
|
||||
|
||||
this.log('debug', `Registered RPC method: ${fullName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension's database
|
||||
*/
|
||||
getDatabase(): ExtensionDatabase {
|
||||
return this.database
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message
|
||||
*/
|
||||
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void {
|
||||
const prefix = `[Extension:${this.extensionInfo.id}]`
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(prefix, message, ...args)
|
||||
break
|
||||
case 'info':
|
||||
console.info(prefix, message, ...args)
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(prefix, message, ...args)
|
||||
break
|
||||
case 'error':
|
||||
console.error(prefix, message, ...args)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Internal Methods (called by ExtensionLoader) =====
|
||||
|
||||
/**
|
||||
* Dispatch payment received event to extension callbacks
|
||||
*/
|
||||
async dispatchPaymentReceived(payment: PaymentReceivedData): Promise<void> {
|
||||
for (const callback of this.callbacks.paymentReceived) {
|
||||
try {
|
||||
await callback(payment)
|
||||
} catch (e) {
|
||||
this.log('error', 'Error in payment callback:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch Nostr event to extension callbacks
|
||||
*/
|
||||
async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
|
||||
for (const callback of this.callbacks.nostrEvent) {
|
||||
try {
|
||||
await callback(event, applicationId)
|
||||
} catch (e) {
|
||||
this.log('error', 'Error in Nostr event callback:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered callbacks for external access
|
||||
*/
|
||||
getCallbacks(): CallbackRegistries {
|
||||
return this.callbacks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension context
|
||||
*/
|
||||
export function createExtensionContext(
|
||||
extensionInfo: ExtensionInfo,
|
||||
database: ExtensionDatabase,
|
||||
mainHandler: MainHandlerInterface,
|
||||
methodRegistry: Map<string, RegisteredMethod>
|
||||
): ExtensionContextImpl {
|
||||
return new ExtensionContextImpl(extensionInfo, database, mainHandler, methodRegistry)
|
||||
}
|
||||
148
src/extensions/database.ts
Normal file
148
src/extensions/database.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { ExtensionDatabase } from './types.js'
|
||||
|
||||
/**
|
||||
* Extension Database Implementation
|
||||
*
|
||||
* Provides isolated SQLite database access for each extension.
|
||||
* Uses better-sqlite3 for synchronous, high-performance access.
|
||||
*/
|
||||
export class ExtensionDatabaseImpl implements ExtensionDatabase {
|
||||
private db: Database.Database
|
||||
private extensionId: string
|
||||
|
||||
constructor(extensionId: string, databaseDir: string) {
|
||||
this.extensionId = extensionId
|
||||
|
||||
// Ensure database directory exists
|
||||
if (!fs.existsSync(databaseDir)) {
|
||||
fs.mkdirSync(databaseDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Create database file for this extension
|
||||
const dbPath = path.join(databaseDir, `${extensionId}.db`)
|
||||
this.db = new Database(dbPath)
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
this.db.pragma('journal_mode = WAL')
|
||||
|
||||
// Enable foreign keys
|
||||
this.db.pragma('foreign_keys = ON')
|
||||
|
||||
// Create metadata table for tracking migrations
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _extension_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.)
|
||||
*/
|
||||
async execute(sql: string, params: any[] = []): Promise<{ changes?: number; lastId?: number }> {
|
||||
try {
|
||||
const stmt = this.db.prepare(sql)
|
||||
const result = stmt.run(...params)
|
||||
|
||||
return {
|
||||
changes: result.changes,
|
||||
lastId: result.lastInsertRowid as number
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extension:${this.extensionId}] Database execute error:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a read query (SELECT)
|
||||
*/
|
||||
async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
||||
try {
|
||||
const stmt = this.db.prepare(sql)
|
||||
return stmt.all(...params) as T[]
|
||||
} catch (e) {
|
||||
console.error(`[Extension:${this.extensionId}] Database query error:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple statements in a transaction
|
||||
*/
|
||||
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const runTransaction = this.db.transaction(() => {
|
||||
// Note: better-sqlite3 transactions are synchronous
|
||||
// We wrap the async function but it executes synchronously
|
||||
return fn()
|
||||
})
|
||||
|
||||
return runTransaction() as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a metadata value
|
||||
*/
|
||||
async getMeta(key: string): Promise<string | null> {
|
||||
const rows = await this.query<{ value: string }>(
|
||||
'SELECT value FROM _extension_meta WHERE key = ?',
|
||||
[key]
|
||||
)
|
||||
return rows.length > 0 ? rows[0].value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a metadata value
|
||||
*/
|
||||
async setMeta(key: string, value: string): Promise<void> {
|
||||
await this.execute(
|
||||
`INSERT INTO _extension_meta (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[key, value]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current migration version
|
||||
*/
|
||||
async getMigrationVersion(): Promise<number> {
|
||||
const version = await this.getMeta('migration_version')
|
||||
return version ? parseInt(version, 10) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Set migration version
|
||||
*/
|
||||
async setMigrationVersion(version: number): Promise<void> {
|
||||
await this.setMeta('migration_version', String(version))
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying database for advanced operations
|
||||
* (Use with caution - bypasses isolation)
|
||||
*/
|
||||
getUnderlyingDb(): Database.Database {
|
||||
return this.db
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension database instance
|
||||
*/
|
||||
export function createExtensionDatabase(
|
||||
extensionId: string,
|
||||
databaseDir: string
|
||||
): ExtensionDatabaseImpl {
|
||||
return new ExtensionDatabaseImpl(extensionId, databaseDir)
|
||||
}
|
||||
56
src/extensions/index.ts
Normal file
56
src/extensions/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Lightning.Pub Extension System
|
||||
*
|
||||
* This module provides the extension infrastructure for Lightning.Pub.
|
||||
* Extensions can add functionality like marketplaces, subscriptions,
|
||||
* tipping, and more.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```typescript
|
||||
* import { createExtensionLoader, ExtensionLoaderConfig } from './extensions'
|
||||
*
|
||||
* const config: ExtensionLoaderConfig = {
|
||||
* extensionsDir: './extensions',
|
||||
* databaseDir: './data/extensions'
|
||||
* }
|
||||
*
|
||||
* const loader = createExtensionLoader(config, mainHandler)
|
||||
* await loader.loadAll()
|
||||
*
|
||||
* // Call extension methods
|
||||
* const result = await loader.callMethod(
|
||||
* 'marketplace.createStall',
|
||||
* { name: 'My Shop', currency: 'sat', shipping_zones: [...] },
|
||||
* applicationId
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export {
|
||||
Extension,
|
||||
ExtensionInfo,
|
||||
ExtensionContext,
|
||||
ExtensionDatabase,
|
||||
ExtensionModule,
|
||||
ExtensionConstructor,
|
||||
LoadedExtension,
|
||||
ExtensionLoaderConfig,
|
||||
ApplicationInfo,
|
||||
CreateInvoiceOptions,
|
||||
CreatedInvoice,
|
||||
PaymentReceivedData,
|
||||
NostrEvent,
|
||||
UnsignedNostrEvent,
|
||||
RpcMethodHandler
|
||||
} from './types.js'
|
||||
|
||||
// Export loader
|
||||
export { ExtensionLoader, createExtensionLoader } from './loader.js'
|
||||
|
||||
// Export database utilities
|
||||
export { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js'
|
||||
|
||||
// Export context utilities
|
||||
export { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js'
|
||||
406
src/extensions/loader.ts
Normal file
406
src/extensions/loader.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
Extension,
|
||||
ExtensionInfo,
|
||||
ExtensionModule,
|
||||
LoadedExtension,
|
||||
ExtensionLoaderConfig,
|
||||
RpcMethodHandler,
|
||||
PaymentReceivedData,
|
||||
NostrEvent
|
||||
} from './types.js'
|
||||
import { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js'
|
||||
import { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js'
|
||||
|
||||
/**
|
||||
* Registered RPC method entry
|
||||
*/
|
||||
interface RegisteredMethod {
|
||||
extensionId: string
|
||||
handler: RpcMethodHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension Loader
|
||||
*
|
||||
* Discovers, loads, and manages Lightning.Pub extensions.
|
||||
* Provides lifecycle management and event dispatching.
|
||||
*/
|
||||
export class ExtensionLoader {
|
||||
private config: ExtensionLoaderConfig
|
||||
private mainHandler: MainHandlerInterface
|
||||
private extensions: Map<string, LoadedExtension> = new Map()
|
||||
private contexts: Map<string, ExtensionContextImpl> = new Map()
|
||||
private methodRegistry: Map<string, RegisteredMethod> = new Map()
|
||||
private initialized = false
|
||||
|
||||
constructor(config: ExtensionLoaderConfig, mainHandler: MainHandlerInterface) {
|
||||
this.config = config
|
||||
this.mainHandler = mainHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load all extensions
|
||||
*/
|
||||
async loadAll(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
throw new Error('Extension loader already initialized')
|
||||
}
|
||||
|
||||
console.log('[Extensions] Loading extensions from:', this.config.extensionsDir)
|
||||
|
||||
// Ensure directories exist
|
||||
if (!fs.existsSync(this.config.extensionsDir)) {
|
||||
console.log('[Extensions] Extensions directory does not exist, creating...')
|
||||
fs.mkdirSync(this.config.extensionsDir, { recursive: true })
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.config.databaseDir)) {
|
||||
fs.mkdirSync(this.config.databaseDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Discover extensions
|
||||
const extensionDirs = await this.discoverExtensions()
|
||||
console.log(`[Extensions] Found ${extensionDirs.length} extension(s)`)
|
||||
|
||||
// Load extensions in dependency order
|
||||
const loadOrder = await this.resolveDependencies(extensionDirs)
|
||||
|
||||
for (const extDir of loadOrder) {
|
||||
try {
|
||||
await this.loadExtension(extDir)
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Failed to load extension from ${extDir}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
console.log(`[Extensions] Loaded ${this.extensions.size} extension(s)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover extension directories
|
||||
*/
|
||||
private async discoverExtensions(): Promise<string[]> {
|
||||
const entries = fs.readdirSync(this.config.extensionsDir, { withFileTypes: true })
|
||||
const extensionDirs: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const extDir = path.join(this.config.extensionsDir, entry.name)
|
||||
const indexPath = path.join(extDir, 'index.ts')
|
||||
const indexJsPath = path.join(extDir, 'index.js')
|
||||
|
||||
// Check for index file
|
||||
if (fs.existsSync(indexPath) || fs.existsSync(indexJsPath)) {
|
||||
// Check enabled/disabled lists
|
||||
if (this.config.disabledExtensions?.includes(entry.name)) {
|
||||
console.log(`[Extensions] Skipping disabled extension: ${entry.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.config.enabledExtensions &&
|
||||
!this.config.enabledExtensions.includes(entry.name)) {
|
||||
console.log(`[Extensions] Skipping non-enabled extension: ${entry.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
extensionDirs.push(extDir)
|
||||
}
|
||||
}
|
||||
|
||||
return extensionDirs
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve extension dependencies and return load order
|
||||
*/
|
||||
private async resolveDependencies(extensionDirs: string[]): Promise<string[]> {
|
||||
// For now, simple alphabetical order
|
||||
// TODO: Implement proper dependency resolution with topological sort
|
||||
return extensionDirs.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single extension
|
||||
*/
|
||||
private async loadExtension(extensionDir: string): Promise<void> {
|
||||
const dirName = path.basename(extensionDir)
|
||||
console.log(`[Extensions] Loading extension: ${dirName}`)
|
||||
|
||||
// Determine index file path
|
||||
let indexPath = path.join(extensionDir, 'index.js')
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
indexPath = path.join(extensionDir, 'index.ts')
|
||||
}
|
||||
|
||||
// Dynamic import
|
||||
const moduleUrl = `file://${indexPath}`
|
||||
const module = await import(moduleUrl) as ExtensionModule
|
||||
|
||||
if (!module.default) {
|
||||
throw new Error(`Extension ${dirName} has no default export`)
|
||||
}
|
||||
|
||||
// Instantiate extension
|
||||
const ExtensionClass = module.default
|
||||
const instance = new ExtensionClass() as Extension
|
||||
|
||||
if (!instance.info) {
|
||||
throw new Error(`Extension ${dirName} has no info property`)
|
||||
}
|
||||
|
||||
const info = instance.info
|
||||
|
||||
// Validate extension ID matches directory name
|
||||
if (info.id !== dirName) {
|
||||
console.warn(
|
||||
`[Extensions] Extension ID '${info.id}' doesn't match directory '${dirName}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (this.extensions.has(info.id)) {
|
||||
throw new Error(`Extension ${info.id} already loaded`)
|
||||
}
|
||||
|
||||
// Create isolated database
|
||||
const database = createExtensionDatabase(info.id, this.config.databaseDir)
|
||||
|
||||
// Create context
|
||||
const context = createExtensionContext(
|
||||
info,
|
||||
database,
|
||||
this.mainHandler,
|
||||
this.methodRegistry
|
||||
)
|
||||
|
||||
// Track as loading
|
||||
const loaded: LoadedExtension = {
|
||||
info,
|
||||
instance,
|
||||
database,
|
||||
status: 'loading',
|
||||
loadedAt: Date.now()
|
||||
}
|
||||
this.extensions.set(info.id, loaded)
|
||||
this.contexts.set(info.id, context)
|
||||
|
||||
try {
|
||||
// Initialize extension
|
||||
await instance.initialize(context, database)
|
||||
|
||||
loaded.status = 'ready'
|
||||
console.log(`[Extensions] Extension ${info.id} v${info.version} loaded successfully`)
|
||||
} catch (e) {
|
||||
loaded.status = 'error'
|
||||
loaded.error = e as Error
|
||||
console.error(`[Extensions] Extension ${info.id} initialization failed:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a specific extension
|
||||
*/
|
||||
async unloadExtension(extensionId: string): Promise<void> {
|
||||
const loaded = this.extensions.get(extensionId)
|
||||
if (!loaded) {
|
||||
throw new Error(`Extension ${extensionId} not found`)
|
||||
}
|
||||
|
||||
console.log(`[Extensions] Unloading extension: ${extensionId}`)
|
||||
|
||||
try {
|
||||
// Call shutdown if available
|
||||
if (loaded.instance.shutdown) {
|
||||
await loaded.instance.shutdown()
|
||||
}
|
||||
|
||||
loaded.status = 'stopped'
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Error during ${extensionId} shutdown:`, e)
|
||||
}
|
||||
|
||||
// Close database
|
||||
if (loaded.database instanceof ExtensionDatabaseImpl) {
|
||||
loaded.database.close()
|
||||
}
|
||||
|
||||
// Remove registered methods
|
||||
for (const [name, method] of this.methodRegistry.entries()) {
|
||||
if (method.extensionId === extensionId) {
|
||||
this.methodRegistry.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from maps
|
||||
this.extensions.delete(extensionId)
|
||||
this.contexts.delete(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all extensions
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
console.log('[Extensions] Shutting down all extensions...')
|
||||
|
||||
for (const extensionId of this.extensions.keys()) {
|
||||
try {
|
||||
await this.unloadExtension(extensionId)
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Error unloading ${extensionId}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Extensions] All extensions shut down')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a loaded extension
|
||||
*/
|
||||
getExtension(extensionId: string): LoadedExtension | undefined {
|
||||
return this.extensions.get(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded extensions
|
||||
*/
|
||||
getAllExtensions(): LoadedExtension[] {
|
||||
return Array.from(this.extensions.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an extension is loaded and ready
|
||||
*/
|
||||
isReady(extensionId: string): boolean {
|
||||
const ext = this.extensions.get(extensionId)
|
||||
return ext?.status === 'ready'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered RPC methods
|
||||
*/
|
||||
getRegisteredMethods(): Map<string, RegisteredMethod> {
|
||||
return this.methodRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an extension RPC method
|
||||
*/
|
||||
async callMethod(
|
||||
methodName: string,
|
||||
request: any,
|
||||
applicationId: string,
|
||||
userPubkey?: string
|
||||
): Promise<any> {
|
||||
const method = this.methodRegistry.get(methodName)
|
||||
if (!method) {
|
||||
throw new Error(`Unknown method: ${methodName}`)
|
||||
}
|
||||
|
||||
const ext = this.extensions.get(method.extensionId)
|
||||
if (!ext || ext.status !== 'ready') {
|
||||
throw new Error(`Extension ${method.extensionId} not ready`)
|
||||
}
|
||||
|
||||
return method.handler(request, applicationId, userPubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method exists
|
||||
*/
|
||||
hasMethod(methodName: string): boolean {
|
||||
return this.methodRegistry.has(methodName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch payment received event to all extensions
|
||||
*/
|
||||
async dispatchPaymentReceived(payment: PaymentReceivedData): Promise<void> {
|
||||
for (const context of this.contexts.values()) {
|
||||
try {
|
||||
await context.dispatchPaymentReceived(payment)
|
||||
} catch (e) {
|
||||
console.error('[Extensions] Error dispatching payment:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch Nostr event to all extensions
|
||||
*/
|
||||
async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
|
||||
for (const context of this.contexts.values()) {
|
||||
try {
|
||||
await context.dispatchNostrEvent(event, applicationId)
|
||||
} catch (e) {
|
||||
console.error('[Extensions] Error dispatching Nostr event:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health checks on all extensions
|
||||
*/
|
||||
async healthCheck(): Promise<Map<string, boolean>> {
|
||||
const results = new Map<string, boolean>()
|
||||
|
||||
for (const [id, ext] of this.extensions.entries()) {
|
||||
if (ext.status !== 'ready') {
|
||||
results.set(id, false)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (ext.instance.healthCheck) {
|
||||
results.set(id, await ext.instance.healthCheck())
|
||||
} else {
|
||||
results.set(id, true)
|
||||
}
|
||||
} catch (e) {
|
||||
results.set(id, false)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension status summary
|
||||
*/
|
||||
getStatus(): {
|
||||
total: number
|
||||
ready: number
|
||||
error: number
|
||||
extensions: Array<{ id: string; name: string; version: string; status: string }>
|
||||
} {
|
||||
const extensions = this.getAllExtensions().map(ext => ({
|
||||
id: ext.info.id,
|
||||
name: ext.info.name,
|
||||
version: ext.info.version,
|
||||
status: ext.status
|
||||
}))
|
||||
|
||||
return {
|
||||
total: extensions.length,
|
||||
ready: extensions.filter(e => e.status === 'ready').length,
|
||||
error: extensions.filter(e => e.status === 'error').length,
|
||||
extensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension loader instance
|
||||
*/
|
||||
export function createExtensionLoader(
|
||||
config: ExtensionLoaderConfig,
|
||||
mainHandler: MainHandlerInterface
|
||||
): ExtensionLoader {
|
||||
return new ExtensionLoader(config, mainHandler)
|
||||
}
|
||||
155
src/extensions/mainHandlerAdapter.ts
Normal file
155
src/extensions/mainHandlerAdapter.ts
Normal file
|
|
@ -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<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
|
||||
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<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')
|
||||
}
|
||||
}
|
||||
}
|
||||
255
src/extensions/types.ts
Normal file
255
src/extensions/types.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* Extension System Core Types
|
||||
*
|
||||
* These types define the contract between Lightning.Pub and extensions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extension metadata
|
||||
*/
|
||||
export interface ExtensionInfo {
|
||||
id: string // Unique identifier (lowercase, no spaces)
|
||||
name: string // Display name
|
||||
version: string // Semver version
|
||||
description: string // Short description
|
||||
author: string // Author name or organization
|
||||
minPubVersion?: string // Minimum Lightning.Pub version required
|
||||
dependencies?: string[] // Other extension IDs this depends on
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension database interface
|
||||
* Provides isolated database access for each extension
|
||||
*/
|
||||
export interface ExtensionDatabase {
|
||||
/**
|
||||
* Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.)
|
||||
*/
|
||||
execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }>
|
||||
|
||||
/**
|
||||
* Execute a read query (SELECT)
|
||||
*/
|
||||
query<T = any>(sql: string, params?: any[]): Promise<T[]>
|
||||
|
||||
/**
|
||||
* Execute multiple statements in a transaction
|
||||
*/
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Application info provided to extensions
|
||||
*/
|
||||
export interface ApplicationInfo {
|
||||
id: string
|
||||
name: string
|
||||
nostr_public: string // Application's Nostr pubkey (hex)
|
||||
balance_sats: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice creation options
|
||||
*/
|
||||
export interface CreateInvoiceOptions {
|
||||
memo?: string
|
||||
expiry?: number // Seconds until expiry
|
||||
metadata?: Record<string, any> // Custom metadata for callbacks
|
||||
}
|
||||
|
||||
/**
|
||||
* Created invoice result
|
||||
*/
|
||||
export interface CreatedInvoice {
|
||||
id: string // Internal invoice ID
|
||||
paymentRequest: string // BOLT11 invoice string
|
||||
paymentHash: string // Payment hash (hex)
|
||||
expiry: number // Expiry timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment received callback data
|
||||
*/
|
||||
export interface PaymentReceivedData {
|
||||
invoiceId: string
|
||||
paymentHash: string
|
||||
amountSats: number
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* LNURL-pay info response (LUD-06/LUD-16)
|
||||
* Used for Lightning Address and zap support
|
||||
*/
|
||||
export interface LnurlPayInfo {
|
||||
tag: 'payRequest'
|
||||
callback: string // URL to call with amount
|
||||
minSendable: number // Minimum msats
|
||||
maxSendable: number // Maximum msats
|
||||
metadata: string // JSON-encoded metadata array
|
||||
allowsNostr?: boolean // Whether zaps are supported
|
||||
nostrPubkey?: string // Pubkey for zap receipts (hex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr event structure (minimal)
|
||||
*/
|
||||
export interface NostrEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsigned Nostr event for publishing
|
||||
*/
|
||||
export interface UnsignedNostrEvent {
|
||||
kind: number
|
||||
pubkey: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC method handler function
|
||||
*/
|
||||
export type RpcMethodHandler = (
|
||||
request: any,
|
||||
applicationId: string,
|
||||
userPubkey?: string
|
||||
) => Promise<any>
|
||||
|
||||
/**
|
||||
* Extension context - interface provided to extensions for interacting with Lightning.Pub
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Get information about an application
|
||||
*/
|
||||
getApplication(applicationId: string): Promise<ApplicationInfo | null>
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
createInvoice(amountSats: number, options?: CreateInvoiceOptions): Promise<CreatedInvoice>
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice (requires sufficient balance)
|
||||
* If userPubkey is provided, pays from that user's balance instead of app.owner
|
||||
*/
|
||||
payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number, userPubkey?: string): Promise<{
|
||||
paymentHash: string
|
||||
feeSats: number
|
||||
}>
|
||||
|
||||
/**
|
||||
* Send an encrypted DM via Nostr (NIP-44)
|
||||
*/
|
||||
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
|
||||
|
||||
/**
|
||||
* Publish a Nostr event (signed by application's key)
|
||||
*/
|
||||
publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null>
|
||||
|
||||
/**
|
||||
* Get LNURL-pay info for a user (by pubkey)
|
||||
* Used to enable Lightning Address support (LUD-16) and zaps (NIP-57)
|
||||
*/
|
||||
getLnurlPayInfo(pubkeyHex: string, options?: {
|
||||
metadata?: string // Custom metadata JSON
|
||||
description?: string // Human-readable description
|
||||
}): Promise<LnurlPayInfo>
|
||||
|
||||
/**
|
||||
* Subscribe to payment received callbacks
|
||||
*/
|
||||
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void
|
||||
|
||||
/**
|
||||
* Subscribe to incoming Nostr events for the application
|
||||
*/
|
||||
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): void
|
||||
|
||||
/**
|
||||
* Register an RPC method
|
||||
*/
|
||||
registerMethod(name: string, handler: RpcMethodHandler): void
|
||||
|
||||
/**
|
||||
* Get the extension's isolated database
|
||||
*/
|
||||
getDatabase(): ExtensionDatabase
|
||||
|
||||
/**
|
||||
* Log a message (prefixed with extension ID)
|
||||
*/
|
||||
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension interface - what extensions must implement
|
||||
*/
|
||||
export interface Extension {
|
||||
/**
|
||||
* Extension metadata
|
||||
*/
|
||||
readonly info: ExtensionInfo
|
||||
|
||||
/**
|
||||
* Initialize the extension
|
||||
* Called once when the extension is loaded
|
||||
*/
|
||||
initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void>
|
||||
|
||||
/**
|
||||
* Shutdown the extension
|
||||
* Called when Lightning.Pub is shutting down
|
||||
*/
|
||||
shutdown?(): Promise<void>
|
||||
|
||||
/**
|
||||
* Health check
|
||||
* Return true if extension is healthy
|
||||
*/
|
||||
healthCheck?(): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension constructor type
|
||||
*/
|
||||
export type ExtensionConstructor = new () => Extension
|
||||
|
||||
/**
|
||||
* Extension module default export
|
||||
*/
|
||||
export interface ExtensionModule {
|
||||
default: ExtensionConstructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded extension state
|
||||
*/
|
||||
export interface LoadedExtension {
|
||||
info: ExtensionInfo
|
||||
instance: Extension
|
||||
database: ExtensionDatabase
|
||||
status: 'loading' | 'ready' | 'error' | 'stopped'
|
||||
error?: Error
|
||||
loadedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension loader configuration
|
||||
*/
|
||||
export interface ExtensionLoaderConfig {
|
||||
extensionsDir: string // Directory containing extensions
|
||||
databaseDir: string // Directory for extension databases
|
||||
enabledExtensions?: string[] // If set, only load these extensions
|
||||
disabledExtensions?: string[] // Extensions to skip
|
||||
}
|
||||
383
src/extensions/withdraw/index.ts
Normal file
383
src/extensions/withdraw/index.ts
Normal file
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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_<app_id>)
|
||||
*
|
||||
* Returns: {
|
||||
* link: { lnurl, unique_hash, id, ... }
|
||||
* }
|
||||
*/
|
||||
private async handleCreateWithdrawLink(req: HttpRequest): Promise<HttpResponse> {
|
||||
try {
|
||||
const { title, min_withdrawable, max_withdrawable, uses, wait_time } = req.body
|
||||
|
||||
// Extract app_id from Authorization header (Bearer app_<app_id>)
|
||||
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<HttpResponse> {
|
||||
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<HttpResponse> {
|
||||
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<HttpResponse> {
|
||||
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'
|
||||
717
src/extensions/withdraw/managers/withdrawManager.ts
Normal file
717
src/extensions/withdraw/managers/withdrawManager.ts
Normal file
|
|
@ -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<WithdrawLinkWithLnurl> {
|
||||
// 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<WithdrawLinkWithLnurl[]> {
|
||||
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<WithdrawLinkWithLnurl | null> {
|
||||
const rows = await this.db.query<WithdrawLinkRow>(
|
||||
'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<WithdrawLink | null> {
|
||||
const rows = await this.db.query<WithdrawLinkRow>(
|
||||
'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<WithdrawLinkWithLnurl[]> {
|
||||
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<WithdrawLinkRow>(sql, params)
|
||||
return rows.map(row => this.addLnurl(rowToLink(row)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a withdraw link
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
applicationId: string,
|
||||
req: UpdateWithdrawLinkRequest
|
||||
): Promise<WithdrawLinkWithLnurl | null> {
|
||||
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<boolean> {
|
||||
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<LnurlWithdrawResponse | LnurlErrorResponse> {
|
||||
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<LnurlSuccessResponse | LnurlErrorResponse> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.db.execute('DELETE FROM hash_checks WHERE hash = ?', [hash])
|
||||
}
|
||||
|
||||
/**
|
||||
* List withdrawals
|
||||
*/
|
||||
async listWithdrawals(
|
||||
applicationId: string,
|
||||
linkId?: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<Withdrawal[]> {
|
||||
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<WithdrawalRow>(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<void> {
|
||||
if (!link.webhook_url) return
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/extensions/withdraw/migrations.ts
Normal file
164
src/extensions/withdraw/migrations.ts
Normal file
|
|
@ -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<void>
|
||||
down?: (db: ExtensionDatabase) => Promise<void>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// 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)]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
264
src/extensions/withdraw/types.ts
Normal file
264
src/extensions/withdraw/types.ts
Normal file
|
|
@ -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<HttpResponse>
|
||||
}
|
||||
|
||||
export interface HttpRequest {
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
body?: any
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface HttpResponse {
|
||||
status: number
|
||||
body: any
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
131
src/extensions/withdraw/utils/lnurl.ts
Normal file
131
src/extensions/withdraw/utils/lnurl.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
101
src/index.ts
101
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<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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>, 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)
|
||||
|
|
@ -105,7 +136,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
|
|||
|
||||
return {
|
||||
Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop },
|
||||
Send: (...args) => nostr.Send(...args),
|
||||
Send: async (...args) => nostr.Send(...args),
|
||||
Ping: () => nostr.Ping(),
|
||||
Reset: (settings: NostrSettings) => nostr.Reset(settings)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,15 +142,20 @@ export default class {
|
|||
return new Promise<void>((res, rej) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await this.GetInfo()
|
||||
const info = await this.GetInfo()
|
||||
if (!info.syncedToChain || !info.syncedToGraph) {
|
||||
this.log("LND responding but not synced yet, waiting...")
|
||||
return
|
||||
}
|
||||
clearInterval(interval)
|
||||
this.ready = true
|
||||
res()
|
||||
} catch (err) {
|
||||
this.log(INFO, "LND is not ready yet, will try again in 1 second")
|
||||
if (Date.now() - now > 1000 * 60) {
|
||||
rej(new Error("LND not ready after 1 minute"))
|
||||
}
|
||||
}
|
||||
if (Date.now() - now > 1000 * 60 * 10) {
|
||||
clearInterval(interval)
|
||||
rej(new Error("LND not synced after 10 minutes"))
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -241,6 +241,8 @@ export default class {
|
|||
const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, {
|
||||
ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) }
|
||||
})
|
||||
// Refresh appUser balance from DB so notification has accurate latest_balance
|
||||
appUser.user.balance_sats = paid.latest_balance
|
||||
this.notifyAppUserPayment(appUser, paid.operation)
|
||||
getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats")
|
||||
return paid
|
||||
|
|
|
|||
|
|
@ -153,13 +153,14 @@ export class DebitManager {
|
|||
}
|
||||
|
||||
notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
|
||||
this.logger("✅ [DEBIT REQUEST] Payment successful, sending OK response to", event.pub.slice(0, 16) + "...", "for event", event.id.slice(0, 16) + "...")
|
||||
this.sendDebitResponse(debitRes, event)
|
||||
}
|
||||
|
||||
sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
|
||||
this.logger("📤 [DEBIT RESPONSE] Sending Kind 21002 response:", JSON.stringify(debitRes), "to", event.pub.slice(0, 16) + "...")
|
||||
const e = newNdebitResponse(JSON.stringify(debitRes), event)
|
||||
this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } })
|
||||
|
||||
}
|
||||
|
||||
payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => {
|
||||
|
|
|
|||
|
|
@ -238,13 +238,17 @@ export class Watchdog {
|
|||
const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset)
|
||||
const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex)
|
||||
const historyMismatch = newLatest > knownMaxIndex
|
||||
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
|
||||
if (historyMismatch) {
|
||||
getLogger({ component: 'bark' })("History mismatch detected in absolute update, locking outgoing operations")
|
||||
this.lnd.LockOutgoingOperations()
|
||||
return
|
||||
this.log("Payment index advanced from", knownMaxIndex, "to", newLatest, "- updating offset (likely LND restart or external payment)")
|
||||
this.latestPaymentIndexOffset = newLatest
|
||||
}
|
||||
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
|
||||
if (deny) {
|
||||
if (historyMismatch) {
|
||||
getLogger({ component: 'bark' })("Balance mismatch with unexpected payment history, locking outgoing operations")
|
||||
this.lnd.LockOutgoingOperations()
|
||||
return
|
||||
}
|
||||
this.log("Balance mismatch detected in absolute update, but history is ok")
|
||||
}
|
||||
this.lnd.UnlockOutgoingOperations()
|
||||
|
|
|
|||
|
|
@ -132,12 +132,12 @@ const handleNostrSettings = (settings: NostrSettings) => {
|
|||
send(event)
|
||||
})
|
||||
} */
|
||||
const sendToNostr: NostrSend = (initiator, data, relays) => {
|
||||
const sendToNostr: NostrSend = async (initiator, data, relays) => {
|
||||
if (!subProcessHandler) {
|
||||
getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized")
|
||||
return
|
||||
}
|
||||
subProcessHandler.Send(initiator, data, relays)
|
||||
await subProcessHandler.Send(initiator, data, relays)
|
||||
}
|
||||
|
||||
send({ type: 'ready' })
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { base64 } from "@scure/base";
|
||||
import { base64, hex } from "@scure/base";
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
export type EncryptedData = {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
export const getSharedSecret = (privateKey: string, publicKey: string) => {
|
||||
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
|
||||
const key = secp256k1.getSharedSecret(hex.decode(privateKey), hex.decode("02" + publicKey));
|
||||
return sha256(key.slice(1, 33));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type SendDataContent = { type: "content", content: string, pub: string }
|
|||
export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } }
|
||||
export type SendData = SendDataContent | SendDataEvent
|
||||
export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string }
|
||||
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void
|
||||
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => Promise<void>
|
||||
|
||||
export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string }
|
||||
export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo }
|
||||
|
|
@ -203,21 +203,26 @@ export class NostrPool {
|
|||
const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex'))
|
||||
let sent = false
|
||||
const log = getLogger({ appName: keys.name })
|
||||
// const r = relays ? relays : this.getServiceRelays()
|
||||
this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`)
|
||||
const pool = new SimplePool()
|
||||
await Promise.all(pool.publish(relays, signed).map(async p => {
|
||||
try {
|
||||
await p
|
||||
sent = true
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
log(e)
|
||||
try {
|
||||
await Promise.all(pool.publish(relays, signed).map(async p => {
|
||||
try {
|
||||
await p
|
||||
sent = true
|
||||
} catch (e: any) {
|
||||
this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e)
|
||||
log(e)
|
||||
}
|
||||
}))
|
||||
if (!sent) {
|
||||
this.log(ERROR, `Failed to send Kind ${event.kind} event to any relay`)
|
||||
log("failed to send event")
|
||||
} else {
|
||||
this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
|
||||
}
|
||||
}))
|
||||
if (!sent) {
|
||||
log("failed to send event")
|
||||
} else {
|
||||
//log("sent event")
|
||||
} finally {
|
||||
pool.close(relays)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NostrSend, SendData, SendInitiator } from "./nostrPool.js"
|
||||
import { getLogger } from "../helpers/logger.js"
|
||||
import { ERROR, getLogger } from "../helpers/logger.js"
|
||||
export class NostrSender {
|
||||
private _nostrSend: NostrSend = () => { throw new Error('nostr send not initialized yet') }
|
||||
private _nostrSend: NostrSend = async () => { throw new Error('nostr send not initialized yet') }
|
||||
private isReady: boolean = false
|
||||
private onReadyCallbacks: (() => void)[] = []
|
||||
private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = []
|
||||
|
|
@ -12,7 +12,12 @@ export class NostrSender {
|
|||
this.isReady = true
|
||||
this.onReadyCallbacks.forEach(cb => cb())
|
||||
this.onReadyCallbacks = []
|
||||
this.pendingSends.forEach(send => this._nostrSend(send.initiator, send.data, send.relays))
|
||||
// Process pending sends with proper error handling
|
||||
this.pendingSends.forEach(send => {
|
||||
this._nostrSend(send.initiator, send.data, send.relays).catch(e => {
|
||||
this.log(ERROR, "failed to send pending event", e.message || e)
|
||||
})
|
||||
})
|
||||
this.pendingSends = []
|
||||
}
|
||||
OnReady(callback: () => void) {
|
||||
|
|
@ -22,13 +27,16 @@ export class NostrSender {
|
|||
this.onReadyCallbacks.push(callback)
|
||||
}
|
||||
}
|
||||
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined) {
|
||||
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined): void {
|
||||
if (!this.isReady) {
|
||||
this.log("tried to send before nostr was ready, caching request")
|
||||
this.pendingSends.push({ initiator, data, relays })
|
||||
return
|
||||
}
|
||||
this._nostrSend(initiator, data, relays)
|
||||
// Fire and forget but log errors
|
||||
this._nostrSend(initiator, data, relays).catch(e => {
|
||||
this.log(ERROR, "failed to send event", e.message || e)
|
||||
})
|
||||
}
|
||||
IsReady() {
|
||||
return this.isReady
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ class TlvFilesStorageProcessor {
|
|||
throw new Error('Unknown metric type: ' + t)
|
||||
}
|
||||
})
|
||||
this.wrtc.attachNostrSend((initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
||||
this.wrtc.attachNostrSend(async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
||||
this.sendResponse({
|
||||
success: true,
|
||||
type: 'nostrSend',
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ export default class webRTC {
|
|||
attachNostrSend(f: NostrSend) {
|
||||
this._nostrSend = f
|
||||
}
|
||||
private nostrSend: NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
||||
private nostrSend: NostrSend = async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
||||
if (!this._nostrSend) {
|
||||
throw new Error("No nostrSend attached")
|
||||
}
|
||||
this._nostrSend(initiator, data, relays)
|
||||
await this._nostrSend(initiator, data, relays)
|
||||
}
|
||||
|
||||
private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue