Compare commits

..

14 commits

Author SHA1 Message Date
Patrick Mulligan
6037dbe600 Add NIP-47 (Nostr Wallet Connect) support
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
Implements NWC protocol alongside the existing CLINK/Ndebit system,
allowing any NWC-compatible wallet (Alby, Amethyst, Damus, etc.) to
connect to a Lightning Pub node.

Supported NIP-47 methods: pay_invoice, make_invoice, get_balance,
get_info, lookup_invoice, list_transactions.

New files:
- NwcConnection entity with per-connection spending limits
- NwcStorage for connection CRUD operations
- NwcManager for NIP-47 request handling and connection management
- Database migration for nwc_connection table

Modified files:
- nostrPool: subscribe to kind 23194 events
- nostrMiddleware: route kind 23194 to NwcManager
- main/index: wire NwcManager, publish kind 13194 info events
- storage: register NwcConnection entity and NwcStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:45:17 -04:00
Justin (shocknet)
ef53408485
Merge pull request #912 from shocknet/metrics-cache
Metrics cache
2026-03-26 14:17:09 -04:00
boufni95
1ad3166460
fix imports 2026-03-26 18:13:10 +00:00
boufni95
9499fdc923
metrics cache + update proto + queue fix 2026-03-26 17:59:43 +00:00
Justin (shocknet)
a163215860
Merge pull request #910 from shocknet/refund-swap-info
refund swap info
2026-03-12 15:20:00 -04:00
Justin (shocknet)
43efb63054
Merge pull request #911 from shocknet/fix-zaps
zaps fix
2026-03-12 15:19:27 -04:00
boufni95
423c4e9c73
undo ineffective change 2026-03-12 19:16:30 +00:00
boufni95
e7085e2ef3
fix zaps amt validation 2026-03-12 19:15:43 +00:00
boufni95
7b33669b51
zaps fix 2026-03-12 19:09:02 +00:00
boufni95
3c49b0fc07
refund swap info 2026-03-09 18:53:15 +00:00
Justin (shocknet)
9de5c1d982
Merge pull request #895 from shocknet/cleanup-logs
cleaup logs
2026-03-06 13:00:56 -05:00
Justin (shocknet)
b68129c316
Merge pull request #909 from shocknet/fix-swap-failure
fix swap crash
2026-03-06 11:03:55 -05:00
boufni95
0f3626869e
fix swap crash 2026-03-05 18:20:10 +00:00
boufni95
e2389b9b27
cleaup logs 2026-03-02 19:40:29 +00:00
29 changed files with 1896 additions and 5065 deletions

View file

@ -2,21 +2,3 @@
.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/

View file

@ -1,4 +1,4 @@
FROM node:20
FROM node:18
WORKDIR /app

2930
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -77,7 +77,6 @@
"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",

View file

@ -1,731 +0,0 @@
# 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

View file

@ -1,324 +0,0 @@
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)
}

View file

@ -1,148 +0,0 @@
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)
}

View file

@ -1,56 +0,0 @@
/**
* 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'

View file

@ -1,406 +0,0 @@
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)
}

View file

@ -1,155 +0,0 @@
/**
* MainHandler Adapter for Extension System
*
* Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface
* required by the extension system.
*/
import { MainHandlerInterface } from './context.js'
import { LnurlPayInfo } from './types.js'
import type Main from '../services/main/index.js'
/**
* Create an adapter that wraps mainHandler for extension use
*/
export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface {
return {
applicationManager: {
async getById(id: string) {
// The applicationManager stores apps internally
// We need to access it through the storage layer
try {
const app = await mainHandler.storage.applicationStorage.GetApplication(id)
if (!app) return null
return {
id: app.app_id,
name: app.name,
nostr_public: app.nostr_public_key || '',
balance: app.owner?.balance_sats || 0
}
} catch (e) {
// GetApplication throws if not found
return null
}
},
async PayAppUserInvoice(appId, req) {
return mainHandler.applicationManager.PayAppUserInvoice(appId, req)
}
},
paymentManager: {
async createInvoice(params: {
applicationId: string
amountSats: number
memo?: string
expiry?: number
metadata?: Record<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')
}
}
}

View file

@ -1,286 +0,0 @@
/**
* 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
}
/**
* HTTP route handler types
* Used by extensions that expose HTTP endpoints (e.g. LNURL, .well-known)
*/
export interface HttpRequest {
method: string
path: string
params: Record<string, string>
query: Record<string, string>
headers: Record<string, string>
body?: any
}
export interface HttpResponse {
status: number
body: any
headers?: Record<string, string>
}
export interface HttpRoute {
method: 'GET' | 'POST'
path: string
handler: (req: HttpRequest) => Promise<HttpResponse>
}
/**
* 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>
/**
* Get HTTP routes exposed by this extension
* The main HTTP server will mount these routes
*/
getHttpRoutes?(): HttpRoute[]
}
/**
* 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
}

View file

@ -1,383 +0,0 @@
/**
* LNURL-withdraw Extension for Lightning.Pub
*
* Implements LUD-03 (LNURL-withdraw) for creating withdraw links
* that allow anyone to pull funds from a Lightning wallet.
*
* Use cases:
* - Quick vouchers (batch single-use codes)
* - Faucets
* - Gift cards / prepaid cards
* - Tips / donations
*/
import {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
CreateWithdrawLinkRequest,
UpdateWithdrawLinkRequest,
HttpRoute,
HttpRequest,
HttpResponse
} from './types.js'
import { runMigrations } from './migrations.js'
import { WithdrawManager } from './managers/withdrawManager.js'
/**
* LNURL-withdraw Extension
*/
export default class WithdrawExtension implements Extension {
readonly info: ExtensionInfo = {
id: 'withdraw',
name: 'LNURL Withdraw',
version: '1.0.0',
description: 'Create withdraw links for vouchers, faucets, and gifts (LUD-03)',
author: 'Lightning.Pub',
minPubVersion: '1.0.0'
}
private manager!: WithdrawManager
private baseUrl: string = ''
/**
* Initialize the extension
*/
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<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'

View file

@ -1,717 +0,0 @@
/**
* Withdraw Link Manager
*
* Handles CRUD operations for withdraw links and processes withdrawals
*/
import {
ExtensionContext,
ExtensionDatabase,
WithdrawLink,
Withdrawal,
CreateWithdrawLinkRequest,
UpdateWithdrawLinkRequest,
WithdrawLinkWithLnurl,
LnurlWithdrawResponse,
LnurlErrorResponse,
LnurlSuccessResponse,
LnurlCallbackParams
} from '../types.js'
import {
generateId,
generateK1,
generateUniqueHash,
generateUseHash,
verifyUseHash,
encodeLnurl,
buildLnurlUrl,
buildUniqueLnurlUrl,
buildCallbackUrl,
satsToMsats
} from '../utils/lnurl.js'
/**
* Database row types
*/
interface WithdrawLinkRow {
id: string
application_id: string
title: string
description: string | null
min_withdrawable: number
max_withdrawable: number
uses: number
used: number
wait_time: number
unique_hash: string
k1: string
is_unique: number
uses_csv: string
open_time: number
creator_pubkey: string | null
webhook_url: string | null
webhook_headers: string | null
webhook_body: string | null
created_at: number
updated_at: number
}
interface WithdrawalRow {
id: string
link_id: string
application_id: string
payment_hash: string
amount_sats: number
fee_sats: number
recipient_node: string | null
webhook_success: number | null
webhook_response: string | null
created_at: number
}
/**
* Convert row to WithdrawLink
*/
function rowToLink(row: WithdrawLinkRow): WithdrawLink {
return {
id: row.id,
application_id: row.application_id,
title: row.title,
description: row.description || undefined,
min_withdrawable: row.min_withdrawable,
max_withdrawable: row.max_withdrawable,
uses: row.uses,
used: row.used,
wait_time: row.wait_time,
unique_hash: row.unique_hash,
k1: row.k1,
is_unique: row.is_unique === 1,
uses_csv: row.uses_csv,
open_time: row.open_time,
creator_pubkey: row.creator_pubkey || undefined,
webhook_url: row.webhook_url || undefined,
webhook_headers: row.webhook_headers || undefined,
webhook_body: row.webhook_body || undefined,
created_at: row.created_at,
updated_at: row.updated_at
}
}
/**
* Convert row to Withdrawal
*/
function rowToWithdrawal(row: WithdrawalRow): Withdrawal {
return {
id: row.id,
link_id: row.link_id,
application_id: row.application_id,
payment_hash: row.payment_hash,
amount_sats: row.amount_sats,
fee_sats: row.fee_sats,
recipient_node: row.recipient_node || undefined,
webhook_success: row.webhook_success === null ? undefined : row.webhook_success === 1,
webhook_response: row.webhook_response || undefined,
created_at: row.created_at
}
}
/**
* WithdrawManager - Handles withdraw link operations
*/
export class WithdrawManager {
private baseUrl: string = ''
constructor(
private db: ExtensionDatabase,
private ctx: ExtensionContext
) {}
/**
* Set the base URL for LNURL generation
*/
setBaseUrl(url: string): void {
this.baseUrl = url.replace(/\/$/, '')
}
/**
* Add LNURL to a withdraw link
*/
private addLnurl(link: WithdrawLink): WithdrawLinkWithLnurl {
const lnurlUrl = buildLnurlUrl(this.baseUrl, link.unique_hash)
return {
...link,
lnurl: encodeLnurl(lnurlUrl),
lnurl_url: lnurlUrl
}
}
// =========================================================================
// CRUD Operations
// =========================================================================
/**
* Create a new withdraw link
*/
async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise<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]
)
}
}
}

View file

@ -1,164 +0,0 @@
/**
* LNURL-withdraw Extension Database Migrations
*/
import { ExtensionDatabase } from '../types.js'
export interface Migration {
version: number
name: string
up: (db: ExtensionDatabase) => Promise<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)]
)
}
}
}

View file

@ -1,264 +0,0 @@
/**
* LNURL-withdraw Extension Types
* Implements LUD-03 (LNURL-withdraw) for Lightning.Pub
*/
// Re-export base extension types
export {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
ApplicationInfo,
RpcMethodHandler
} from '../types.js'
// ============================================================================
// Core Data Types
// ============================================================================
/**
* A withdraw link that can be used to pull funds
*/
export interface WithdrawLink {
id: string
application_id: string
// Display
title: string
description?: string
// Amounts (in sats)
min_withdrawable: number
max_withdrawable: number
// Usage limits
uses: number // Total allowed uses
used: number // Times used so far
wait_time: number // Seconds between uses
// Security
unique_hash: string // For LNURL URL
k1: string // Challenge for callback
is_unique: boolean // Generate unique code per use
uses_csv: string // Comma-separated list of available use IDs
// Rate limiting
open_time: number // Unix timestamp when next use is allowed
// Creator identity (for Nostr RPC-created links)
creator_pubkey?: string // Nostr pubkey of the user who created this link
// Webhook notifications
webhook_url?: string
webhook_headers?: string // JSON string
webhook_body?: string // JSON string
// Timestamps
created_at: number
updated_at: number
}
/**
* Withdrawal record - tracks each successful withdrawal
*/
export interface Withdrawal {
id: string
link_id: string
application_id: string
// Payment details
payment_hash: string
amount_sats: number
fee_sats: number
// Recipient (if known)
recipient_node?: string
// Webhook result
webhook_success?: boolean
webhook_response?: string
// Timestamp
created_at: number
}
/**
* Hash check - prevents double-spending during payment
*/
export interface HashCheck {
hash: string
k1: string
created_at: number
}
// ============================================================================
// LNURL Protocol Types (LUD-03)
// ============================================================================
/**
* LNURL-withdraw response (first call)
* Returned when user scans the QR code
*/
export interface LnurlWithdrawResponse {
tag: 'withdrawRequest'
callback: string // URL to call with invoice
k1: string // Challenge
minWithdrawable: number // Millisats
maxWithdrawable: number // Millisats
defaultDescription: string
}
/**
* LNURL error response
*/
export interface LnurlErrorResponse {
status: 'ERROR'
reason: string
}
/**
* LNURL success response
*/
export interface LnurlSuccessResponse {
status: 'OK'
}
// ============================================================================
// RPC Request/Response Types
// ============================================================================
/**
* Create a new withdraw link
*/
export interface CreateWithdrawLinkRequest {
title: string
description?: string
min_withdrawable: number // sats
max_withdrawable: number // sats
uses: number // 1-250
wait_time: number // seconds between uses
is_unique?: boolean // generate unique code per use
webhook_url?: string
webhook_headers?: string // JSON
webhook_body?: string // JSON
}
/**
* Update an existing withdraw link
*/
export interface UpdateWithdrawLinkRequest {
id: string
title?: string
description?: string
min_withdrawable?: number
max_withdrawable?: number
uses?: number
wait_time?: number
is_unique?: boolean
webhook_url?: string
webhook_headers?: string
webhook_body?: string
}
/**
* Get withdraw link by ID
*/
export interface GetWithdrawLinkRequest {
id: string
}
/**
* List withdraw links
*/
export interface ListWithdrawLinksRequest {
include_spent?: boolean // Include fully used links
limit?: number
offset?: number
}
/**
* Delete withdraw link
*/
export interface DeleteWithdrawLinkRequest {
id: string
}
/**
* Create quick vouchers (batch of single-use links)
*/
export interface CreateVouchersRequest {
title: string
amount: number // sats per voucher
count: number // number of vouchers (1-100)
description?: string
}
/**
* Get withdraw link with LNURL
*/
export interface WithdrawLinkWithLnurl extends WithdrawLink {
lnurl: string // bech32 encoded LNURL
lnurl_url: string // raw callback URL
}
/**
* List withdrawals for a link
*/
export interface ListWithdrawalsRequest {
link_id?: string
limit?: number
offset?: number
}
/**
* Withdraw link response with stats
*/
export interface WithdrawLinkResponse {
link: WithdrawLinkWithLnurl
total_withdrawn_sats: number
withdrawals_count: number
}
/**
* Vouchers response
*/
export interface VouchersResponse {
vouchers: WithdrawLinkWithLnurl[]
total_amount_sats: number
}
// ============================================================================
// HTTP Handler Types
// ============================================================================
/**
* LNURL callback parameters
*/
export interface LnurlCallbackParams {
k1: string // Challenge from initial response
pr: string // Payment request (BOLT11 invoice)
id_unique_hash?: string // For unique links
}
/**
* HTTP route handler
*/
export interface HttpRoute {
method: 'GET' | 'POST'
path: string
handler: (req: HttpRequest) => Promise<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>
}

View file

@ -1,131 +0,0 @@
/**
* LNURL Encoding Utilities
*
* LNURL is a bech32-encoded URL with hrp "lnurl"
* See: https://github.com/lnurl/luds
*/
import { bech32 } from 'bech32'
import crypto from 'crypto'
/**
* Encode a URL as LNURL (bech32)
*/
export function encodeLnurl(url: string): string {
const words = bech32.toWords(Buffer.from(url, 'utf8'))
return bech32.encode('lnurl', words, 2000) // 2000 char limit for URLs
}
/**
* Decode an LNURL to a URL
*/
export function decodeLnurl(lnurl: string): string {
const { prefix, words } = bech32.decode(lnurl, 2000)
if (prefix !== 'lnurl') {
throw new Error('Invalid LNURL prefix')
}
return Buffer.from(bech32.fromWords(words)).toString('utf8')
}
/**
* Generate a URL-safe random ID
*/
export function generateId(length: number = 22): string {
const bytes = crypto.randomBytes(Math.ceil(length * 3 / 4))
return bytes.toString('base64url').slice(0, length)
}
/**
* Generate a k1 challenge (32 bytes hex)
*/
export function generateK1(): string {
return crypto.randomBytes(32).toString('hex')
}
/**
* Generate a unique hash for a link
*/
export function generateUniqueHash(): string {
return generateId(32)
}
/**
* Generate a unique hash for a specific use of a link
* This creates a deterministic hash based on link ID, unique_hash, and use number
*/
export function generateUseHash(linkId: string, uniqueHash: string, useNumber: string): string {
const data = `${linkId}${uniqueHash}${useNumber}`
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
}
/**
* Verify a use hash matches one of the available uses
*/
export function verifyUseHash(
linkId: string,
uniqueHash: string,
usesCsv: string,
providedHash: string
): string | null {
const uses = usesCsv.split(',').filter(u => u.trim() !== '')
for (const useNumber of uses) {
const expectedHash = generateUseHash(linkId, uniqueHash, useNumber.trim())
if (expectedHash === providedHash) {
return useNumber.trim()
}
}
return null
}
/**
* Build the LNURL callback URL for a withdraw link
*/
export function buildLnurlUrl(baseUrl: string, uniqueHash: string): string {
// Remove trailing slash from baseUrl
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/v1/lnurl/${uniqueHash}`
}
/**
* Build the LNURL callback URL for a unique withdraw link
*/
export function buildUniqueLnurlUrl(
baseUrl: string,
uniqueHash: string,
useHash: string
): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/v1/lnurl/${uniqueHash}/${useHash}`
}
/**
* Build the callback URL for the second step (where user sends invoice)
*/
export function buildCallbackUrl(baseUrl: string, uniqueHash: string): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/v1/lnurl/cb/${uniqueHash}`
}
/**
* Sats to millisats
*/
export function satsToMsats(sats: number): number {
return sats * 1000
}
/**
* Millisats to sats
*/
export function msatsToSats(msats: number): number {
return Math.floor(msats / 1000)
}
/**
* Validate a BOLT11 invoice (basic check)
*/
export function isValidBolt11(invoice: string): boolean {
const lower = invoice.toLowerCase()
return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt')
}

View file

@ -1,8 +1,4 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import path from 'path'
import { fileURLToPath } from 'url'
import NewServer from '../proto/autogenerated/ts/express_server.js'
import GetServerMethods from './services/serverMethods/index.js'
import serverOptions from './auth.js';
@ -12,15 +8,9 @@ import { initMainHandler, initSettings } from './services/main/init.js';
import { nip19 } from 'nostr-tools'
import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
import { AppInfo } from './services/nostr/nostrPool.js';
import { createExtensionLoader, ExtensionLoader } from './extensions/loader.js'
import { createMainHandlerAdapter } from './extensions/mainHandlerAdapter.js'
import type { HttpRoute } from './extensions/withdraw/types.js'
//@ts-ignore
const { nprofileEncode } = nip19
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const start = async () => {
const log = getLogger({})
@ -35,42 +25,6 @@ const start = async () => {
const { mainHandler, localProviderClient, wizard, adminManager } = keepOn
const serverMethods = GetServerMethods(mainHandler)
// Initialize extension system BEFORE nostrMiddleware so RPC methods are available
let extensionLoader: ExtensionLoader | null = null
const mainPort = settingsManager.getSettings().serviceSettings.servicePort
const extensionPort = mainPort + 1
// Extension routes run on a separate port (main port + 1)
// SERVICE_URL for extensions should point to this port for LNURL to work
// In production, use a reverse proxy to route /api/v1/lnurl/* to extension port
const extensionServiceUrl = process.env.EXTENSION_SERVICE_URL || `http://localhost:${extensionPort}`
try {
log("initializing extension system")
const extensionsDir = path.join(__dirname, 'extensions')
const databaseDir = path.join(__dirname, '..', 'data', 'extensions')
const mainHandlerAdapter = createMainHandlerAdapter(mainHandler)
extensionLoader = createExtensionLoader(
{ extensionsDir, databaseDir },
mainHandlerAdapter
)
await extensionLoader.loadAll()
log(`loaded ${extensionLoader.getAllExtensions().length} extension(s)`)
// Set base URL for LNURL generation on withdraw extension
const withdrawExt = extensionLoader.getExtension('withdraw')
if (withdrawExt && withdrawExt.instance && 'setBaseUrl' in withdrawExt.instance) {
(withdrawExt.instance as any).setBaseUrl(extensionServiceUrl)
log(`withdraw extension base URL set to ${extensionServiceUrl}`)
}
} catch (e) {
log(`extension system initialization failed: ${e}`)
}
// Initialize nostr middleware with extension loader for RPC routing
log("initializing nostr middleware")
const relays = settingsManager.getSettings().nostrRelaySettings.relays
const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength
@ -91,8 +45,7 @@ const start = async () => {
{
relays, maxEventContentLength, apps
},
(e, p) => mainHandler.liquidityProvider.onEvent(e, p),
{ extensionLoader: extensionLoader || undefined }
(e, p) => mainHandler.liquidityProvider.onEvent(e, p)
)
exitHandler(() => { Stop(); mainHandler.Stop() })
log("starting server")
@ -105,58 +58,8 @@ const start = async () => {
wizard.AddConnectInfo(appNprofile, relays)
}
adminManager.setAppNprofile(appNprofile)
// Create Express app for extension HTTP routes
const extensionApp = express()
extensionApp.use(cors()) // Enable CORS for all origins (ATM apps, wallets, etc.)
extensionApp.use(express.json())
// Mount extension HTTP routes
if (extensionLoader) {
for (const ext of extensionLoader.getAllExtensions()) {
if (ext.status === 'ready' && 'getHttpRoutes' in ext.instance) {
const routes = (ext.instance as any).getHttpRoutes() as HttpRoute[]
for (const route of routes) {
log(`mounting extension route: ${route.method} ${route.path}`)
const handler = async (req: express.Request, res: express.Response) => {
try {
const httpReq = {
params: req.params,
query: req.query as Record<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(mainPort)
Server.Listen(settingsManager.getSettings().serviceSettings.servicePort)
}
start()

View file

@ -5,15 +5,9 @@ import * as Types from '../proto/autogenerated/ts/types.js'
import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js';
import { ERROR, getLogger } from "./services/helpers/logger.js";
import { NdebitData, NofferData, NmanageRequest } from "@shocknet/clink-sdk";
import type { ExtensionLoader } from "./extensions/loader.js"
type ExportedCalls = { Stop: () => void, Send: NostrSend, Ping: () => Promise<void>, Reset: (settings: NostrSettings) => void }
type ClientEventCallback = (e: { requestId: string }, fromPub: string) => void
export type NostrMiddlewareOptions = {
extensionLoader?: ExtensionLoader
}
export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback, options?: NostrMiddlewareOptions): ExportedCalls => {
export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback): ExportedCalls => {
const log = getLogger({})
const nostrTransport = NewNostrTransport(serverMethods, {
NostrUserAuthGuard: async (appId, pub) => {
@ -108,31 +102,6 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
log(ERROR, "authIdentifier does not match", j.authIdentifier || "--", event.pub)
return
}
// Check if this is an extension RPC method
const extensionLoader = options?.extensionLoader
if (extensionLoader && j.rpcName && extensionLoader.hasMethod(j.rpcName)) {
// Route to extension
log(`[Nostr] Routing to extension method: ${j.rpcName}`)
extensionLoader.callMethod(j.rpcName, j.body || {}, event.appId, event.pub)
.then(result => {
const response = { status: 'OK', requestId: j.requestId, ...result }
nostr.Send(
{ type: 'app', appId: event.appId },
{ type: 'content', pub: event.pub, content: JSON.stringify(response) }
)
})
.catch(err => {
log(ERROR, `Extension method ${j.rpcName} failed:`, err.message)
const response = { status: 'ERROR', requestId: j.requestId, reason: err.message }
nostr.Send(
{ type: 'app', appId: event.appId },
{ type: 'content', pub: event.pub, content: JSON.stringify(response) }
)
})
return
}
nostrTransport({ ...j, appId: event.appId }, res => {
nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) })
}, event.startAtNano, event.startAtMs)
@ -143,7 +112,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
return {
Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop },
Send: async (...args) => nostr.Send(...args),
Send: (...args) => nostr.Send(...args),
Ping: () => nostr.Ping(),
Reset: (settings: NostrSettings) => nostr.Reset(settings)
}

View file

@ -142,20 +142,15 @@ export default class {
return new Promise<void>((res, rej) => {
const interval = setInterval(async () => {
try {
const info = await this.GetInfo()
if (!info.syncedToChain || !info.syncedToGraph) {
this.log("LND responding but not synced yet, waiting...")
return
}
await this.GetInfo()
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)
})

View file

@ -9,7 +9,7 @@ export const PayInvoiceReq = (invoice: string, amount: number, feeLimit: number)
maxParts: 3,
timeoutSeconds: 50,
allowSelfPayment: true,
allowSelfPayment: false,
amp: false,
amtMsat: 0n,
cltvLimit: 0,

View file

@ -241,8 +241,6 @@ 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

View file

@ -153,14 +153,13 @@ 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> => {

View file

@ -238,17 +238,13 @@ export class Watchdog {
const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset)
const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex)
const historyMismatch = newLatest > knownMaxIndex
if (historyMismatch) {
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")
getLogger({ component: 'bark' })("History mismatch detected in absolute update, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
}
if (deny) {
this.log("Balance mismatch detected in absolute update, but history is ok")
}
this.lnd.UnlockOutgoingOperations()

View file

@ -132,12 +132,12 @@ const handleNostrSettings = (settings: NostrSettings) => {
send(event)
})
} */
const sendToNostr: NostrSend = async (initiator, data, relays) => {
const sendToNostr: NostrSend = (initiator, data, relays) => {
if (!subProcessHandler) {
getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized")
return
}
await subProcessHandler.Send(initiator, data, relays)
subProcessHandler.Send(initiator, data, relays)
}
send({ type: 'ready' })

View file

@ -1,14 +1,14 @@
import { base64, hex } from "@scure/base";
import { base64 } from "@scure/base";
import { randomBytes } from "@noble/hashes/utils";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from "@noble/hashes/sha256";
export type EncryptedData = {
ciphertext: Uint8Array;
nonce: Uint8Array;
}
export const getSharedSecret = (privateKey: string, publicKey: string) => {
const key = secp256k1.getSharedSecret(hex.decode(privateKey), hex.decode("02" + publicKey));
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
return sha256(key.slice(1, 33));
}

View file

@ -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) => Promise<void>
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void
export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string }
export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo }
@ -203,26 +203,21 @@ export class NostrPool {
const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex'))
let sent = false
const log = getLogger({ appName: keys.name })
this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`)
// const r = relays ? relays : this.getServiceRelays()
const pool = new SimplePool()
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)
console.log(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)}...)`)
}
} finally {
pool.close(relays)
//log("sent event")
}
}

View file

@ -1,7 +1,7 @@
import { NostrSend, SendData, SendInitiator } from "./nostrPool.js"
import { ERROR, getLogger } from "../helpers/logger.js"
import { getLogger } from "../helpers/logger.js"
export class NostrSender {
private _nostrSend: NostrSend = async () => { throw new Error('nostr send not initialized yet') }
private _nostrSend: NostrSend = () => { 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,12 +12,7 @@ export class NostrSender {
this.isReady = true
this.onReadyCallbacks.forEach(cb => cb())
this.onReadyCallbacks = []
// 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.forEach(send => this._nostrSend(send.initiator, send.data, send.relays))
this.pendingSends = []
}
OnReady(callback: () => void) {
@ -27,16 +22,13 @@ export class NostrSender {
this.onReadyCallbacks.push(callback)
}
}
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined): void {
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined) {
if (!this.isReady) {
this.log("tried to send before nostr was ready, caching request")
this.pendingSends.push({ initiator, data, relays })
return
}
// Fire and forget but log errors
this._nostrSend(initiator, data, relays).catch(e => {
this.log(ERROR, "failed to send event", e.message || e)
})
this._nostrSend(initiator, data, relays)
}
IsReady() {
return this.isReady

View file

@ -126,7 +126,7 @@ class TlvFilesStorageProcessor {
throw new Error('Unknown metric type: ' + t)
}
})
this.wrtc.attachNostrSend(async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
this.wrtc.attachNostrSend((initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
this.sendResponse({
success: true,
type: 'nostrSend',

View file

@ -27,11 +27,11 @@ export default class webRTC {
attachNostrSend(f: NostrSend) {
this._nostrSend = f
}
private nostrSend: NostrSend = async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
private nostrSend: NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
if (!this._nostrSend) {
throw new Error("No nostrSend attached")
}
await this._nostrSend(initiator, data, relays)
this._nostrSend(initiator, data, relays)
}
private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {