Compare commits

..

15 commits

Author SHA1 Message Date
Patrick Mulligan
772a2131e6 feat(extensions): add NIP-15 marketplace extension
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
Implements a Nostr-native marketplace extension for Lightning.Pub
that is compatible with NIP-15 (Nostr Marketplace).

Features:
- Stall management (kind 30017 events)
- Product listings (kind 30018 events)
- Order processing via encrypted Nostr DMs or RPC
- Multi-currency support with exchange rate caching
- Inventory management with stock tracking
- Customer relationship management
- Lightning payment integration

The extension leverages Lightning.Pub's existing Nostr infrastructure
for a more elegant implementation than traditional HTTP-based approaches.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 15:07:27 -04:00
Patrick Mulligan
68c71599f8 fix(lnd): allow self-payments for LNURL-withdraw
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
When the user's wallet (e.g. Zeus) is connected to the same LND node
that LP uses, LNURL-withdraw fails because LND rejects the payment
with "no self-payments allowed". This is safe because LP always
decrements the user's balance before paying and refunds on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
5aaa3bcc23 feat(extensions): pay from caller's balance via PayAppUserInvoice
When userPubkey is provided, resolve the ApplicationUser and call
applicationManager.PayAppUserInvoice instead of paymentManager.PayInvoice
directly. This ensures notifyAppUserPayment fires, sending
LiveUserOperation events via Nostr for real-time balance updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
cb9fb78eb8 feat(withdraw): track creator pubkey on withdraw links
Store the Nostr pubkey of the user who creates a withdraw link so the
LNURL callback debits the correct user's balance instead of the app
owner's. Pass userPubkey through from RPC handler to WithdrawManager.

- Add creator_pubkey column (migration v4)
- Store creatorPubkey on link creation
- Pass creator_pubkey to payInvoice on LNURL callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
1273da9020 feat: route Nostr RPC to extension methods
Initialize extension system before nostrMiddleware so registered
RPC methods are available. Extension methods (e.g. withdraw.createLink)
are intercepted and routed to the extension loader before falling
through to the standard nostrTransport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
3ee8b6b010 feat(withdraw): add HTTP API for creating withdraw links
Add POST /api/v1/withdraw/create endpoint to allow external apps (ATM,
web clients) to create LNURL-withdraw links via HTTP instead of RPC.

Changes:
- Add handleCreateWithdrawLink HTTP handler
- Fix route ordering: callback routes before wildcard :unique_hash
- Extract app_id from Authorization header (Bearer app_<id>)
- Use is_unique=false for simple single-use ATM links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
f06d50f227 feat(server): add CORS support for extension HTTP routes
Enable CORS on the extension HTTP server to allow cross-origin requests
from ATM apps and other web-based clients.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
e998762ca7 feat: integrate extension system with withdraw extension support
- Add extension loader initialization to startup
- Create mainHandlerAdapter to bridge mainHandler with extension context
- Mount extension HTTP routes on separate port (main port + 1)
- Configure EXTENSION_SERVICE_URL for LNURL link generation

The withdraw extension provides LUD-03 LNURL-withdraw support for
creating withdraw links that allow users to pull funds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
Patrick Mulligan
8de5e4fd3a feat(extensions): add LNURL-withdraw extension
Implements LUD-03 (LNURL-withdraw) for creating withdraw links
that allow anyone to pull funds from a Lightning wallet.

Features:
- Create withdraw links with min/max amounts
- Quick vouchers: batch creation of single-use codes
- Multi-use links with wait time between uses
- Unique QR codes per use (prevents sharing exploits)
- Webhook notifications on successful withdrawals
- Full LNURL protocol compliance for wallet compatibility

Use cases:
- Faucets
- Gift cards / prepaid cards
- Tips / donations
- User onboarding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 14:48:55 -04:00
77e5772afd feat(extensions): add extension loader infrastructure (#3)
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run
## Summary

- Adds a modular extension system for Lightning.Pub enabling third-party plugins
- Provides isolated SQLite databases per extension for data safety
- Implements ExtensionContext API for accessing Lightning.Pub services (payments, Nostr, storage)
- Supports RPC method registration with automatic namespacing
- Includes HTTP route handling for protocols like LNURL
- Event routing for payment receipts and Nostr events
- Comprehensive documentation with architecture overview and working examples

## Key Components

- `src/extensions/types.ts` - Core extension interfaces
- `src/extensions/loader.ts` - Extension discovery, loading, and lifecycle management
- `src/extensions/context.ts` - Bridge between extensions and Lightning.Pub services
- `src/extensions/database.ts` - SQLite isolation with WAL mode
- `src/extensions/README.md` - Full documentation with examples

## ExtensionContext API

| Method | Description |
|--------|-------------|
| `getApplication()` | Get application info |
| `createInvoice()` | Create Lightning invoice |
| `payInvoice()` | Pay Lightning invoice |
| `getLnurlPayInfo()` | Get LNURL-pay info for a user (enables Lightning Address/zaps) |
| `sendEncryptedDM()` | Send Nostr DM (NIP-44) |
| `publishNostrEvent()` | Publish Nostr event |
| `registerMethod()` | Register RPC method |
| `onPaymentReceived()` | Subscribe to payment callbacks |
| `onNostrEvent()` | Subscribe to Nostr events |

## Test plan

- [x] Review extension loader code for correctness
- [x] Verify TypeScript compilation succeeds
- [x] Test extension discovery from `src/extensions/` directory
- [x] Test RPC method registration and routing
- [x] Test database isolation between extensions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: boufni95 <boufni95@gmail.com>
Co-authored-by: Patrick Mulligan <patjmulligan@protonmail.com>
Reviewed-on: #3
2026-04-02 18:47:55 +00:00
Patrick Mulligan
72c9872b23 fix(watchdog): handle LND restarts without locking outgoing operations
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
When the payment index advances (e.g. after an LND restart or external
payment), update the cached offset instead of immediately locking.
Only lock if both a history mismatch AND a balance discrepancy are
detected — indicating a real security concern rather than a benign
LND restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:20 -05:00
Patrick Mulligan
5e5e30c7a2 fix(lnd): wait for chain/graph sync before marking LND ready
Warmup() previously only checked that LND responded to GetInfo(), but
did not verify syncedToChain/syncedToGraph. This caused LP to accept
requests while LND was still syncing, leading to "not synced" errors
on every Health() check. Now waits for full sync with a 10min timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:18:06 -05:00
Patrick Mulligan
611eb4fc04 fix(nostr): close SimplePool after publishing to prevent connection leak
Each sendEvent() call created a new SimplePool() but never closed it,
causing relay WebSocket connections to accumulate indefinitely (~20/min).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:16:28 -05:00
Patrick Mulligan
6512e10f08 fix(handlers): await NostrSend calls throughout codebase
Update all NostrSend call sites to properly handle the async nature
of the function now that it returns Promise<void>.

Changes:
- handler.ts: Add async to sendResponse, await nostrSend calls
- debitManager.ts: Add logging for Kind 21002 response sending
- nostrMiddleware.ts: Update nostrSend signature
- tlvFilesStorageProcessor.ts: Update nostrSend signature
- webRTC/index.ts: Add async/await for nostrSend calls

This ensures Kind 21002 (ndebit) responses are properly sent to
wallet clients, fixing the "Debit request failed" issue in ShockWallet.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:16:28 -05:00
Patrick Mulligan
e9b5dacb3b fix(nostr): update NostrSend type to Promise<void> with error handling
The NostrSend type was incorrectly typed as returning void when it actually
returns Promise<void>. This caused async errors to be silently swallowed.

Changes:
- Update NostrSend type signature to return Promise<void>
- Make NostrSender._nostrSend default to async function
- Add .catch() error handling in NostrSender.Send() to log failures
- Add logging to track event publishing status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:16:28 -05:00
20 changed files with 3135 additions and 1880 deletions

View file

@ -2,3 +2,21 @@
.github
build
node_modules
# Runtime state files (should not be baked into image)
*.sqlite
*.sqlite-journal
*.sqlite-wal
*.sqlite-shm
*.db
admin.connect
admin.enroll
admin.npub
app.nprofile
.jwt_secret
# Runtime data directories
metric_cache/
metric_events/
bundler_events/
logs/

View file

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

2926
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,7 @@
"zip-a-folder": "^3.1.9"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^4.3.4",
"@types/chai-string": "^1.4.5",
"@types/cors": "^2.8.17",
@ -93,4 +94,4 @@
"typescript": "5.5.4"
},
"overrides": {}
}
}

View file

@ -20,6 +20,17 @@ 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
@ -41,6 +52,7 @@ export interface MainHandlerInterface {
applicationId: string
paymentRequest: string
maxFeeSats?: number
userPubkey?: string
}): Promise<{
paymentHash: string
feeSats: number
@ -156,16 +168,19 @@ export class ExtensionContextImpl implements ExtensionContext {
/**
* Pay a Lightning invoice
* If userPubkey is provided, pays from that user's balance instead of app.owner
*/
async payInvoice(
applicationId: string,
paymentRequest: string,
maxFeeSats?: number
maxFeeSats?: number,
userPubkey?: string
): Promise<{ paymentHash: string; feeSats: number }> {
return this.mainHandler.paymentManager.payInvoice({
applicationId,
paymentRequest,
maxFeeSats
maxFeeSats,
userPubkey
})
}

View file

@ -0,0 +1,155 @@
/**
* MainHandler Adapter for Extension System
*
* Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface
* required by the extension system.
*/
import { MainHandlerInterface } from './context.js'
import { LnurlPayInfo } from './types.js'
import type Main from '../services/main/index.js'
/**
* Create an adapter that wraps mainHandler for extension use
*/
export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface {
return {
applicationManager: {
async getById(id: string) {
// The applicationManager stores apps internally
// We need to access it through the storage layer
try {
const app = await mainHandler.storage.applicationStorage.GetApplication(id)
if (!app) return null
return {
id: app.app_id,
name: app.name,
nostr_public: app.nostr_public_key || '',
balance: app.owner?.balance_sats || 0
}
} catch (e) {
// GetApplication throws if not found
return null
}
},
async PayAppUserInvoice(appId, req) {
return mainHandler.applicationManager.PayAppUserInvoice(appId, req)
}
},
paymentManager: {
async createInvoice(params: {
applicationId: string
amountSats: number
memo?: string
expiry?: number
metadata?: Record<string, any>
}) {
// Get the app to find the user ID
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
if (!app) {
throw new Error(`Application not found: ${params.applicationId}`)
}
// Create invoice using the app owner's user ID
const result = await mainHandler.paymentManager.NewInvoice(
app.owner.user_id,
{
amountSats: params.amountSats,
memo: params.memo || ''
},
{
expiry: params.expiry || 3600
}
)
return {
id: result.invoice.split(':')[0] || result.invoice, // Extract ID if present
paymentRequest: result.invoice,
paymentHash: '', // Not directly available from NewInvoice response
expiry: Date.now() + (params.expiry || 3600) * 1000
}
},
async payInvoice(params: {
applicationId: string
paymentRequest: string
maxFeeSats?: number
userPubkey?: string
}) {
// Get the app to find the user ID and app reference
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
if (!app) {
throw new Error(`Application not found: ${params.applicationId}`)
}
if (params.userPubkey) {
// Resolve the Nostr user's ApplicationUser to get their identifier
const appUser = await mainHandler.storage.applicationStorage.GetOrCreateNostrAppUser(app, params.userPubkey)
console.log(`[MainHandlerAdapter] Paying via PayAppUserInvoice from Nostr user ${params.userPubkey.slice(0, 8)}... (identifier: ${appUser.identifier})`)
// Use applicationManager.PayAppUserInvoice so notifyAppUserPayment fires
// This sends LiveUserOperation events via Nostr for real-time balance updates
const result = await mainHandler.applicationManager.PayAppUserInvoice(
params.applicationId,
{
invoice: params.paymentRequest,
amount: 0, // Use invoice amount
user_identifier: appUser.identifier
}
)
return {
paymentHash: result.preimage || '',
feeSats: result.network_fee || 0
}
}
// Fallback: pay from app owner's balance (no Nostr user context)
const result = await mainHandler.paymentManager.PayInvoice(
app.owner.user_id,
{
invoice: params.paymentRequest,
amount: 0
},
app,
{}
)
return {
paymentHash: result.preimage || '',
feeSats: result.network_fee || 0
}
},
async getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
metadata?: string
description?: string
}): Promise<LnurlPayInfo> {
// This would need implementation based on how Lightning.Pub handles LNURL-pay
// For now, throw not implemented
throw new Error('getLnurlPayInfoByPubkey not yet implemented')
}
},
async sendNostrEvent(event: any): Promise<string | null> {
// The mainHandler doesn't directly expose nostrSend
// This would need to be implemented through the nostrMiddleware
// For now, return null (not implemented)
console.warn('[MainHandlerAdapter] sendNostrEvent not fully implemented')
return null
},
async sendEncryptedDM(
applicationId: string,
recipientPubkey: string,
content: string
): Promise<string> {
// This would need implementation using NIP-44 encryption
// For now, throw not implemented
throw new Error('sendEncryptedDM not yet implemented')
}
}
}

View file

@ -140,8 +140,9 @@ export interface ExtensionContext {
/**
* Pay a Lightning invoice (requires sufficient balance)
* If userPubkey is provided, pays from that user's balance instead of app.owner
*/
payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number): Promise<{
payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number, userPubkey?: string): Promise<{
paymentHash: string
feeSats: number
}>
@ -191,6 +192,31 @@ export interface ExtensionContext {
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
*/
@ -217,6 +243,12 @@ export interface Extension {
* 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[]
}
/**

View file

@ -0,0 +1,383 @@
/**
* LNURL-withdraw Extension for Lightning.Pub
*
* Implements LUD-03 (LNURL-withdraw) for creating withdraw links
* that allow anyone to pull funds from a Lightning wallet.
*
* Use cases:
* - Quick vouchers (batch single-use codes)
* - Faucets
* - Gift cards / prepaid cards
* - Tips / donations
*/
import {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
CreateWithdrawLinkRequest,
UpdateWithdrawLinkRequest,
HttpRoute,
HttpRequest,
HttpResponse
} from './types.js'
import { runMigrations } from './migrations.js'
import { WithdrawManager } from './managers/withdrawManager.js'
/**
* LNURL-withdraw Extension
*/
export default class WithdrawExtension implements Extension {
readonly info: ExtensionInfo = {
id: 'withdraw',
name: 'LNURL Withdraw',
version: '1.0.0',
description: 'Create withdraw links for vouchers, faucets, and gifts (LUD-03)',
author: 'Lightning.Pub',
minPubVersion: '1.0.0'
}
private manager!: WithdrawManager
private baseUrl: string = ''
/**
* Initialize the extension
*/
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
// Run migrations
await runMigrations(db)
// Initialize manager
this.manager = new WithdrawManager(db, ctx)
// Register RPC methods
this.registerRpcMethods(ctx)
// Register HTTP routes for LNURL protocol
this.registerHttpRoutes(ctx)
ctx.log('info', 'Extension initialized')
}
/**
* Shutdown the extension
*/
async shutdown(): Promise<void> {
// Cleanup if needed
}
/**
* Set the base URL for LNURL generation
* This should be called by the main application after loading
*/
setBaseUrl(url: string): void {
this.baseUrl = url
this.manager.setBaseUrl(url)
}
/**
* Get HTTP routes for this extension
* These need to be mounted by the main HTTP server
*/
getHttpRoutes(): HttpRoute[] {
return [
// Create withdraw link (HTTP API for ATM/external integrations)
{
method: 'POST',
path: '/api/v1/withdraw/create',
handler: this.handleCreateWithdrawLink.bind(this)
},
// LNURL callback (user submits invoice) - MUST be before :unique_hash routes
{
method: 'GET',
path: '/api/v1/lnurl/cb/:unique_hash',
handler: this.handleLnurlCallback.bind(this)
},
// Initial LNURL request (unique link with use hash)
{
method: 'GET',
path: '/api/v1/lnurl/:unique_hash/:id_unique_hash',
handler: this.handleLnurlUniqueRequest.bind(this)
},
// Initial LNURL request (simple link) - MUST be last (catches all)
{
method: 'GET',
path: '/api/v1/lnurl/:unique_hash',
handler: this.handleLnurlRequest.bind(this)
}
]
}
/**
* Register RPC methods with the extension context
*/
private registerRpcMethods(ctx: ExtensionContext): void {
// Create withdraw link
ctx.registerMethod('withdraw.createLink', async (req, appId, userPubkey) => {
const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest, userPubkey)
const stats = await this.manager.getWithdrawalStats(link.id)
return {
link,
total_withdrawn_sats: stats.total_sats,
withdrawals_count: stats.count
}
})
// Create quick vouchers
ctx.registerMethod('withdraw.createVouchers', async (req, appId) => {
const vouchers = await this.manager.createVouchers(
appId,
req.title,
req.amount,
req.count,
req.description
)
return {
vouchers,
total_amount_sats: req.amount * req.count
}
})
// Get withdraw link
ctx.registerMethod('withdraw.getLink', async (req, appId) => {
const link = await this.manager.get(req.id, appId)
if (!link) throw new Error('Withdraw link not found')
const stats = await this.manager.getWithdrawalStats(link.id)
return {
link,
total_withdrawn_sats: stats.total_sats,
withdrawals_count: stats.count
}
})
// List withdraw links
ctx.registerMethod('withdraw.listLinks', async (req, appId) => {
const links = await this.manager.list(
appId,
req.include_spent || false,
req.limit,
req.offset
)
return { links }
})
// Update withdraw link
ctx.registerMethod('withdraw.updateLink', async (req, appId) => {
const link = await this.manager.update(req.id, appId, req as UpdateWithdrawLinkRequest)
if (!link) throw new Error('Withdraw link not found')
const stats = await this.manager.getWithdrawalStats(link.id)
return {
link,
total_withdrawn_sats: stats.total_sats,
withdrawals_count: stats.count
}
})
// Delete withdraw link
ctx.registerMethod('withdraw.deleteLink', async (req, appId) => {
const success = await this.manager.delete(req.id, appId)
if (!success) throw new Error('Withdraw link not found')
return { success }
})
// List withdrawals
ctx.registerMethod('withdraw.listWithdrawals', async (req, appId) => {
const withdrawals = await this.manager.listWithdrawals(
appId,
req.link_id,
req.limit,
req.offset
)
return { withdrawals }
})
// Get withdrawal stats
ctx.registerMethod('withdraw.getStats', async (req, appId) => {
// Get all links to calculate total stats
const links = await this.manager.list(appId, true)
let totalLinks = links.length
let activeLinks = 0
let spentLinks = 0
let totalWithdrawn = 0
let totalWithdrawals = 0
for (const link of links) {
if (link.used >= link.uses) {
spentLinks++
} else {
activeLinks++
}
const stats = await this.manager.getWithdrawalStats(link.id)
totalWithdrawn += stats.total_sats
totalWithdrawals += stats.count
}
return {
total_links: totalLinks,
active_links: activeLinks,
spent_links: spentLinks,
total_withdrawn_sats: totalWithdrawn,
total_withdrawals: totalWithdrawals
}
})
}
/**
* Register HTTP routes (called by extension context)
*/
private registerHttpRoutes(ctx: ExtensionContext): void {
// HTTP routes are exposed via getHttpRoutes()
// The main application is responsible for mounting them
ctx.log('debug', 'HTTP routes registered for LNURL protocol')
}
// =========================================================================
// HTTP Route Handlers
// =========================================================================
/**
* Handle create withdraw link request (HTTP API)
* POST /api/v1/withdraw/create
*
* Body: {
* title: string
* min_withdrawable: number (sats)
* max_withdrawable: number (sats)
* uses?: number (defaults to 1)
* wait_time?: number (seconds between uses, defaults to 0)
* }
*
* Auth: Bearer token in Authorization header (app_<app_id>)
*
* Returns: {
* link: { lnurl, unique_hash, id, ... }
* }
*/
private async handleCreateWithdrawLink(req: HttpRequest): Promise<HttpResponse> {
try {
const { title, min_withdrawable, max_withdrawable, uses, wait_time } = req.body
// Extract app_id from Authorization header (Bearer app_<app_id>)
const authHeader = req.headers?.authorization || req.headers?.Authorization || ''
let app_id = 'default'
if (authHeader.startsWith('Bearer app_')) {
app_id = authHeader.replace('Bearer app_', '')
}
if (!title || !min_withdrawable) {
return {
status: 400,
body: { status: 'ERROR', reason: 'Missing required fields: title, min_withdrawable' },
headers: { 'Content-Type': 'application/json' }
}
}
const link = await this.manager.create(app_id, {
title,
min_withdrawable,
max_withdrawable: max_withdrawable || min_withdrawable,
uses: uses || 1,
wait_time: wait_time || 0,
is_unique: false // Simple single-use links for ATM
})
// Return in format expected by ATM client
return {
status: 200,
body: {
status: 'OK',
link: {
lnurl: link.lnurl,
unique_hash: link.unique_hash,
id: link.id,
title: link.title,
min_withdrawable: link.min_withdrawable,
max_withdrawable: link.max_withdrawable,
uses: link.uses,
used: link.used
}
},
headers: { 'Content-Type': 'application/json' }
}
} catch (error: any) {
return {
status: 500,
body: { status: 'ERROR', reason: error.message },
headers: { 'Content-Type': 'application/json' }
}
}
}
/**
* Handle initial LNURL request (simple link)
* GET /api/v1/lnurl/:unique_hash
*/
private async handleLnurlRequest(req: HttpRequest): Promise<HttpResponse> {
const { unique_hash } = req.params
const result = await this.manager.handleLnurlRequest(unique_hash)
return {
status: 200,
body: result,
headers: {
'Content-Type': 'application/json'
}
}
}
/**
* Handle initial LNURL request (unique link)
* GET /api/v1/lnurl/:unique_hash/:id_unique_hash
*/
private async handleLnurlUniqueRequest(req: HttpRequest): Promise<HttpResponse> {
const { unique_hash, id_unique_hash } = req.params
const result = await this.manager.handleLnurlRequest(unique_hash, id_unique_hash)
return {
status: 200,
body: result,
headers: {
'Content-Type': 'application/json'
}
}
}
/**
* Handle LNURL callback (user submits invoice)
* GET /api/v1/lnurl/cb/:unique_hash?k1=...&pr=...&id_unique_hash=...
*/
private async handleLnurlCallback(req: HttpRequest): Promise<HttpResponse> {
const { unique_hash } = req.params
const { k1, pr, id_unique_hash } = req.query
if (!k1 || !pr) {
return {
status: 200,
body: { status: 'ERROR', reason: 'Missing k1 or pr parameter' },
headers: { 'Content-Type': 'application/json' }
}
}
const result = await this.manager.handleLnurlCallback(unique_hash, {
k1,
pr,
id_unique_hash
})
return {
status: 200,
body: result,
headers: {
'Content-Type': 'application/json'
}
}
}
}
// Export types for external use
export * from './types.js'
export { WithdrawManager } from './managers/withdrawManager.js'

View file

@ -0,0 +1,717 @@
/**
* Withdraw Link Manager
*
* Handles CRUD operations for withdraw links and processes withdrawals
*/
import {
ExtensionContext,
ExtensionDatabase,
WithdrawLink,
Withdrawal,
CreateWithdrawLinkRequest,
UpdateWithdrawLinkRequest,
WithdrawLinkWithLnurl,
LnurlWithdrawResponse,
LnurlErrorResponse,
LnurlSuccessResponse,
LnurlCallbackParams
} from '../types.js'
import {
generateId,
generateK1,
generateUniqueHash,
generateUseHash,
verifyUseHash,
encodeLnurl,
buildLnurlUrl,
buildUniqueLnurlUrl,
buildCallbackUrl,
satsToMsats
} from '../utils/lnurl.js'
/**
* Database row types
*/
interface WithdrawLinkRow {
id: string
application_id: string
title: string
description: string | null
min_withdrawable: number
max_withdrawable: number
uses: number
used: number
wait_time: number
unique_hash: string
k1: string
is_unique: number
uses_csv: string
open_time: number
creator_pubkey: string | null
webhook_url: string | null
webhook_headers: string | null
webhook_body: string | null
created_at: number
updated_at: number
}
interface WithdrawalRow {
id: string
link_id: string
application_id: string
payment_hash: string
amount_sats: number
fee_sats: number
recipient_node: string | null
webhook_success: number | null
webhook_response: string | null
created_at: number
}
/**
* Convert row to WithdrawLink
*/
function rowToLink(row: WithdrawLinkRow): WithdrawLink {
return {
id: row.id,
application_id: row.application_id,
title: row.title,
description: row.description || undefined,
min_withdrawable: row.min_withdrawable,
max_withdrawable: row.max_withdrawable,
uses: row.uses,
used: row.used,
wait_time: row.wait_time,
unique_hash: row.unique_hash,
k1: row.k1,
is_unique: row.is_unique === 1,
uses_csv: row.uses_csv,
open_time: row.open_time,
creator_pubkey: row.creator_pubkey || undefined,
webhook_url: row.webhook_url || undefined,
webhook_headers: row.webhook_headers || undefined,
webhook_body: row.webhook_body || undefined,
created_at: row.created_at,
updated_at: row.updated_at
}
}
/**
* Convert row to Withdrawal
*/
function rowToWithdrawal(row: WithdrawalRow): Withdrawal {
return {
id: row.id,
link_id: row.link_id,
application_id: row.application_id,
payment_hash: row.payment_hash,
amount_sats: row.amount_sats,
fee_sats: row.fee_sats,
recipient_node: row.recipient_node || undefined,
webhook_success: row.webhook_success === null ? undefined : row.webhook_success === 1,
webhook_response: row.webhook_response || undefined,
created_at: row.created_at
}
}
/**
* WithdrawManager - Handles withdraw link operations
*/
export class WithdrawManager {
private baseUrl: string = ''
constructor(
private db: ExtensionDatabase,
private ctx: ExtensionContext
) {}
/**
* Set the base URL for LNURL generation
*/
setBaseUrl(url: string): void {
this.baseUrl = url.replace(/\/$/, '')
}
/**
* Add LNURL to a withdraw link
*/
private addLnurl(link: WithdrawLink): WithdrawLinkWithLnurl {
const lnurlUrl = buildLnurlUrl(this.baseUrl, link.unique_hash)
return {
...link,
lnurl: encodeLnurl(lnurlUrl),
lnurl_url: lnurlUrl
}
}
// =========================================================================
// CRUD Operations
// =========================================================================
/**
* Create a new withdraw link
*/
async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise<WithdrawLinkWithLnurl> {
// Validation
if (req.uses < 1 || req.uses > 250) {
throw new Error('Uses must be between 1 and 250')
}
if (req.min_withdrawable < 1) {
throw new Error('Min withdrawable must be at least 1 sat')
}
if (req.max_withdrawable < req.min_withdrawable) {
throw new Error('Max withdrawable must be >= min withdrawable')
}
if (req.wait_time < 0) {
throw new Error('Wait time cannot be negative')
}
// Validate webhook JSON if provided
if (req.webhook_headers) {
try {
JSON.parse(req.webhook_headers)
} catch {
throw new Error('webhook_headers must be valid JSON')
}
}
if (req.webhook_body) {
try {
JSON.parse(req.webhook_body)
} catch {
throw new Error('webhook_body must be valid JSON')
}
}
const now = Math.floor(Date.now() / 1000)
const id = generateId()
const usesCsv = Array.from({ length: req.uses }, (_, i) => String(i)).join(',')
const link: WithdrawLink = {
id,
application_id: applicationId,
title: req.title.trim(),
description: req.description?.trim(),
min_withdrawable: req.min_withdrawable,
max_withdrawable: req.max_withdrawable,
uses: req.uses,
used: 0,
wait_time: req.wait_time,
unique_hash: generateUniqueHash(),
k1: generateK1(),
is_unique: req.is_unique || false,
uses_csv: usesCsv,
open_time: now,
creator_pubkey: creatorPubkey,
webhook_url: req.webhook_url,
webhook_headers: req.webhook_headers,
webhook_body: req.webhook_body,
created_at: now,
updated_at: now
}
await this.db.execute(
`INSERT INTO withdraw_links (
id, application_id, title, description,
min_withdrawable, max_withdrawable, uses, used, wait_time,
unique_hash, k1, is_unique, uses_csv, open_time,
creator_pubkey,
webhook_url, webhook_headers, webhook_body,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
link.id, link.application_id, link.title, link.description || null,
link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time,
link.unique_hash, link.k1, link.is_unique ? 1 : 0, link.uses_csv, link.open_time,
link.creator_pubkey || null,
link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null,
link.created_at, link.updated_at
]
)
return this.addLnurl(link)
}
/**
* Create multiple vouchers (single-use withdraw links)
*/
async createVouchers(
applicationId: string,
title: string,
amount: number,
count: number,
description?: string
): Promise<WithdrawLinkWithLnurl[]> {
if (count < 1 || count > 100) {
throw new Error('Count must be between 1 and 100')
}
if (amount < 1) {
throw new Error('Amount must be at least 1 sat')
}
const vouchers: WithdrawLinkWithLnurl[] = []
for (let i = 0; i < count; i++) {
const voucher = await this.create(applicationId, {
title: `${title} #${i + 1}`,
description,
min_withdrawable: amount,
max_withdrawable: amount,
uses: 1,
wait_time: 0,
is_unique: false
})
vouchers.push(voucher)
}
return vouchers
}
/**
* Get a withdraw link by ID
*/
async get(id: string, applicationId: string): Promise<WithdrawLinkWithLnurl | null> {
const rows = await this.db.query<WithdrawLinkRow>(
'SELECT * FROM withdraw_links WHERE id = ? AND application_id = ?',
[id, applicationId]
)
if (rows.length === 0) return null
return this.addLnurl(rowToLink(rows[0]))
}
/**
* Get a withdraw link by unique hash (for LNURL)
*/
async getByHash(uniqueHash: string): Promise<WithdrawLink | null> {
const rows = await this.db.query<WithdrawLinkRow>(
'SELECT * FROM withdraw_links WHERE unique_hash = ?',
[uniqueHash]
)
if (rows.length === 0) return null
return rowToLink(rows[0])
}
/**
* List withdraw links for an application
*/
async list(
applicationId: string,
includeSpent: boolean = false,
limit?: number,
offset?: number
): Promise<WithdrawLinkWithLnurl[]> {
let sql = 'SELECT * FROM withdraw_links WHERE application_id = ?'
const params: any[] = [applicationId]
if (!includeSpent) {
sql += ' AND used < uses'
}
sql += ' ORDER BY created_at DESC'
if (limit) {
sql += ' LIMIT ?'
params.push(limit)
if (offset) {
sql += ' OFFSET ?'
params.push(offset)
}
}
const rows = await this.db.query<WithdrawLinkRow>(sql, params)
return rows.map(row => this.addLnurl(rowToLink(row)))
}
/**
* Update a withdraw link
*/
async update(
id: string,
applicationId: string,
req: UpdateWithdrawLinkRequest
): Promise<WithdrawLinkWithLnurl | null> {
const existing = await this.get(id, applicationId)
if (!existing) return null
// Validation
if (req.uses !== undefined) {
if (req.uses < 1 || req.uses > 250) {
throw new Error('Uses must be between 1 and 250')
}
if (req.uses < existing.used) {
throw new Error('Cannot reduce uses below current used count')
}
}
const minWith = req.min_withdrawable ?? existing.min_withdrawable
const maxWith = req.max_withdrawable ?? existing.max_withdrawable
if (minWith < 1) {
throw new Error('Min withdrawable must be at least 1 sat')
}
if (maxWith < minWith) {
throw new Error('Max withdrawable must be >= min withdrawable')
}
// Handle uses change
let usesCsv = existing.uses_csv
const newUses = req.uses ?? existing.uses
if (newUses !== existing.uses) {
const currentUses = usesCsv.split(',').filter(u => u !== '')
if (newUses > existing.uses) {
// Add more uses
const lastNum = currentUses.length > 0 ? parseInt(currentUses[currentUses.length - 1], 10) : -1
for (let i = lastNum + 1; currentUses.length < (newUses - existing.used); i++) {
currentUses.push(String(i))
}
} else {
// Remove uses (keep first N)
usesCsv = currentUses.slice(0, newUses - existing.used).join(',')
}
usesCsv = currentUses.join(',')
}
const now = Math.floor(Date.now() / 1000)
await this.db.execute(
`UPDATE withdraw_links SET
title = ?, description = ?,
min_withdrawable = ?, max_withdrawable = ?,
uses = ?, wait_time = ?, is_unique = ?, uses_csv = ?,
webhook_url = ?, webhook_headers = ?, webhook_body = ?,
updated_at = ?
WHERE id = ? AND application_id = ?`,
[
req.title ?? existing.title,
req.description ?? existing.description ?? null,
minWith, maxWith,
newUses,
req.wait_time ?? existing.wait_time,
(req.is_unique ?? existing.is_unique) ? 1 : 0,
usesCsv,
req.webhook_url ?? existing.webhook_url ?? null,
req.webhook_headers ?? existing.webhook_headers ?? null,
req.webhook_body ?? existing.webhook_body ?? null,
now,
id, applicationId
]
)
return this.get(id, applicationId)
}
/**
* Delete a withdraw link
*/
async delete(id: string, applicationId: string): Promise<boolean> {
const result = await this.db.execute(
'DELETE FROM withdraw_links WHERE id = ? AND application_id = ?',
[id, applicationId]
)
return (result.changes || 0) > 0
}
// =========================================================================
// LNURL Protocol Handlers
// =========================================================================
/**
* Handle initial LNURL request (user scans QR)
* Returns withdraw parameters
*/
async handleLnurlRequest(
uniqueHash: string,
idUniqueHash?: string
): Promise<LnurlWithdrawResponse | LnurlErrorResponse> {
const link = await this.getByHash(uniqueHash)
if (!link) {
return { status: 'ERROR', reason: 'Withdraw link does not exist.' }
}
if (link.used >= link.uses) {
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
}
// For unique links, require id_unique_hash
if (link.is_unique && !idUniqueHash) {
return { status: 'ERROR', reason: 'This link requires a unique hash.' }
}
// Verify unique hash if provided
if (idUniqueHash) {
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, idUniqueHash)
if (!useNumber) {
return { status: 'ERROR', reason: 'Invalid unique hash.' }
}
}
const callbackUrl = buildCallbackUrl(this.baseUrl, link.unique_hash)
return {
tag: 'withdrawRequest',
callback: idUniqueHash ? `${callbackUrl}?id_unique_hash=${idUniqueHash}` : callbackUrl,
k1: link.k1,
minWithdrawable: satsToMsats(link.min_withdrawable),
maxWithdrawable: satsToMsats(link.max_withdrawable),
defaultDescription: link.title
}
}
/**
* Handle LNURL callback (user submits invoice)
* Pays the invoice and records the withdrawal
*/
async handleLnurlCallback(
uniqueHash: string,
params: LnurlCallbackParams
): Promise<LnurlSuccessResponse | LnurlErrorResponse> {
const link = await this.getByHash(uniqueHash)
if (!link) {
return { status: 'ERROR', reason: 'Withdraw link not found.' }
}
if (link.used >= link.uses) {
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
}
if (link.k1 !== params.k1) {
return { status: 'ERROR', reason: 'Invalid k1.' }
}
// Check wait time
const now = Math.floor(Date.now() / 1000)
if (now < link.open_time) {
const waitSecs = link.open_time - now
return { status: 'ERROR', reason: `Please wait ${waitSecs} seconds.` }
}
// For unique links, verify and consume the use hash
if (params.id_unique_hash) {
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, params.id_unique_hash)
if (!useNumber) {
return { status: 'ERROR', reason: 'Invalid unique hash.' }
}
} else if (link.is_unique) {
return { status: 'ERROR', reason: 'Unique hash required.' }
}
// Prevent double-spending with hash check
try {
await this.createHashCheck(params.id_unique_hash || uniqueHash, params.k1)
} catch {
return { status: 'ERROR', reason: 'Withdrawal already in progress.' }
}
try {
// Pay the invoice from the creator's balance (if created via Nostr RPC)
const payment = await this.ctx.payInvoice(
link.application_id,
params.pr,
link.max_withdrawable,
link.creator_pubkey
)
// Record the withdrawal
await this.recordWithdrawal(link, payment.paymentHash, link.max_withdrawable, payment.feeSats)
// Increment usage
await this.incrementUsage(link, params.id_unique_hash)
// Clean up hash check
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
// Dispatch webhook if configured
if (link.webhook_url) {
this.dispatchWebhook(link, payment.paymentHash, params.pr).catch(err => {
console.error('[Withdraw] Webhook error:', err)
})
}
return { status: 'OK' }
} catch (err: any) {
// Clean up hash check on failure
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
return { status: 'ERROR', reason: `Payment failed: ${err.message}` }
}
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Increment link usage and update open_time
*/
private async incrementUsage(link: WithdrawLink, idUniqueHash?: string): Promise<void> {
const now = Math.floor(Date.now() / 1000)
let usesCsv = link.uses_csv
// Remove used hash from uses_csv if unique
if (idUniqueHash) {
const uses = usesCsv.split(',').filter(u => {
const hash = generateUseHash(link.id, link.unique_hash, u.trim())
return hash !== idUniqueHash
})
usesCsv = uses.join(',')
}
await this.db.execute(
`UPDATE withdraw_links SET
used = used + 1,
open_time = ?,
uses_csv = ?,
updated_at = ?
WHERE id = ?`,
[now + link.wait_time, usesCsv, now, link.id]
)
}
/**
* Record a successful withdrawal
*/
private async recordWithdrawal(
link: WithdrawLink,
paymentHash: string,
amountSats: number,
feeSats: number
): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await this.db.execute(
`INSERT INTO withdrawals (
id, link_id, application_id,
payment_hash, amount_sats, fee_sats,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
generateId(),
link.id,
link.application_id,
paymentHash,
amountSats,
feeSats,
now
]
)
}
/**
* Create hash check to prevent double-spending
*/
private async createHashCheck(hash: string, k1: string): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await this.db.execute(
'INSERT INTO hash_checks (hash, k1, created_at) VALUES (?, ?, ?)',
[hash, k1, now]
)
}
/**
* Delete hash check after completion
*/
private async deleteHashCheck(hash: string): Promise<void> {
await this.db.execute('DELETE FROM hash_checks WHERE hash = ?', [hash])
}
/**
* List withdrawals
*/
async listWithdrawals(
applicationId: string,
linkId?: string,
limit?: number,
offset?: number
): Promise<Withdrawal[]> {
let sql = 'SELECT * FROM withdrawals WHERE application_id = ?'
const params: any[] = [applicationId]
if (linkId) {
sql += ' AND link_id = ?'
params.push(linkId)
}
sql += ' ORDER BY created_at DESC'
if (limit) {
sql += ' LIMIT ?'
params.push(limit)
if (offset) {
sql += ' OFFSET ?'
params.push(offset)
}
}
const rows = await this.db.query<WithdrawalRow>(sql, params)
return rows.map(rowToWithdrawal)
}
/**
* Get withdrawal stats for a link
*/
async getWithdrawalStats(linkId: string): Promise<{ total_sats: number; count: number }> {
const result = await this.db.query<{ total: number; count: number }>(
`SELECT COALESCE(SUM(amount_sats), 0) as total, COUNT(*) as count
FROM withdrawals WHERE link_id = ?`,
[linkId]
)
return {
total_sats: result[0]?.total || 0,
count: result[0]?.count || 0
}
}
/**
* Dispatch webhook notification
*/
private async dispatchWebhook(
link: WithdrawLink,
paymentHash: string,
paymentRequest: string
): Promise<void> {
if (!link.webhook_url) return
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (link.webhook_headers) {
Object.assign(headers, JSON.parse(link.webhook_headers))
}
const body = {
payment_hash: paymentHash,
payment_request: paymentRequest,
lnurlw: link.id,
body: link.webhook_body ? JSON.parse(link.webhook_body) : {}
}
const response = await fetch(link.webhook_url, {
method: 'POST',
headers,
body: JSON.stringify(body)
})
// Update withdrawal record with webhook result
await this.db.execute(
`UPDATE withdrawals SET
webhook_success = ?,
webhook_response = ?
WHERE payment_hash = ?`,
[response.ok ? 1 : 0, await response.text(), paymentHash]
)
} catch (err: any) {
await this.db.execute(
`UPDATE withdrawals SET
webhook_success = 0,
webhook_response = ?
WHERE payment_hash = ?`,
[err.message, paymentHash]
)
}
}
}

View file

@ -0,0 +1,164 @@
/**
* LNURL-withdraw Extension Database Migrations
*/
import { ExtensionDatabase } from '../types.js'
export interface Migration {
version: number
name: string
up: (db: ExtensionDatabase) => Promise<void>
down?: (db: ExtensionDatabase) => Promise<void>
}
export const migrations: Migration[] = [
{
version: 1,
name: 'create_withdraw_links_table',
up: async (db: ExtensionDatabase) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS withdraw_links (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
-- Display
title TEXT NOT NULL,
description TEXT,
-- Amounts (sats)
min_withdrawable INTEGER NOT NULL,
max_withdrawable INTEGER NOT NULL,
-- Usage limits
uses INTEGER NOT NULL DEFAULT 1,
used INTEGER NOT NULL DEFAULT 0,
wait_time INTEGER NOT NULL DEFAULT 0,
-- Security
unique_hash TEXT NOT NULL UNIQUE,
k1 TEXT NOT NULL,
is_unique INTEGER NOT NULL DEFAULT 0,
uses_csv TEXT NOT NULL DEFAULT '',
-- Rate limiting
open_time INTEGER NOT NULL DEFAULT 0,
-- Webhooks
webhook_url TEXT,
webhook_headers TEXT,
webhook_body TEXT,
-- Timestamps
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
// Index for looking up by unique_hash (LNURL)
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdraw_links_unique_hash
ON withdraw_links(unique_hash)
`)
// Index for listing by application
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdraw_links_application
ON withdraw_links(application_id, created_at DESC)
`)
}
},
{
version: 2,
name: 'create_withdrawals_table',
up: async (db: ExtensionDatabase) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS withdrawals (
id TEXT PRIMARY KEY,
link_id TEXT NOT NULL,
application_id TEXT NOT NULL,
-- Payment details
payment_hash TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
-- Recipient
recipient_node TEXT,
-- Webhook result
webhook_success INTEGER,
webhook_response TEXT,
-- Timestamp
created_at INTEGER NOT NULL,
FOREIGN KEY (link_id) REFERENCES withdraw_links(id) ON DELETE CASCADE
)
`)
// Index for listing withdrawals by link
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdrawals_link
ON withdrawals(link_id, created_at DESC)
`)
// Index for looking up by payment hash
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdrawals_payment_hash
ON withdrawals(payment_hash)
`)
}
},
{
version: 3,
name: 'create_hash_checks_table',
up: async (db: ExtensionDatabase) => {
// Temporary table to prevent double-spending during payment processing
await db.execute(`
CREATE TABLE IF NOT EXISTS hash_checks (
hash TEXT PRIMARY KEY,
k1 TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`)
}
},
{
version: 4,
name: 'add_creator_pubkey_column',
up: async (db: ExtensionDatabase) => {
// Store the Nostr pubkey of the user who created the withdraw link
// so that when the LNURL callback fires, we debit the correct user's balance
await db.execute(`
ALTER TABLE withdraw_links ADD COLUMN creator_pubkey TEXT
`)
}
}
]
/**
* Run all pending migrations
*/
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
// Get current version
const versionResult = await db.query<{ value: string }>(
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
).catch(() => [])
const currentVersion = versionResult.length > 0 ? parseInt(versionResult[0].value, 10) : 0
// Run pending migrations
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`[Withdraw] Running migration ${migration.version}: ${migration.name}`)
await migration.up(db)
// Update version
await db.execute(
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[String(migration.version)]
)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -142,15 +142,20 @@ export default class {
return new Promise<void>((res, rej) => {
const interval = setInterval(async () => {
try {
await this.GetInfo()
const info = await this.GetInfo()
if (!info.syncedToChain || !info.syncedToGraph) {
this.log("LND responding but not synced yet, waiting...")
return
}
clearInterval(interval)
this.ready = true
res()
} catch (err) {
this.log(INFO, "LND is not ready yet, will try again in 1 second")
if (Date.now() - now > 1000 * 60) {
rej(new Error("LND not ready after 1 minute"))
}
}
if (Date.now() - now > 1000 * 60 * 10) {
clearInterval(interval)
rej(new Error("LND not synced after 10 minutes"))
}
}, 1000)
})

View file

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

View file

@ -241,6 +241,8 @@ export default class {
const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, {
ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) }
})
// Refresh appUser balance from DB so notification has accurate latest_balance
appUser.user.balance_sats = paid.latest_balance
this.notifyAppUserPayment(appUser, paid.operation)
getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats")
return paid

View file

@ -238,13 +238,17 @@ export class Watchdog {
const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset)
const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex)
const historyMismatch = newLatest > knownMaxIndex
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (historyMismatch) {
getLogger({ component: 'bark' })("History mismatch detected in absolute update, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
this.log("Payment index advanced from", knownMaxIndex, "to", newLatest, "- updating offset (likely LND restart or external payment)")
this.latestPaymentIndexOffset = newLatest
}
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (deny) {
if (historyMismatch) {
getLogger({ component: 'bark' })("Balance mismatch with unexpected payment history, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
}
this.log("Balance mismatch detected in absolute update, but history is ok")
}
this.lnd.UnlockOutgoingOperations()

View file

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

View file

@ -205,20 +205,24 @@ export class NostrPool {
const log = getLogger({ appName: keys.name })
this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`)
const pool = new SimplePool()
await Promise.all(pool.publish(relays, signed).map(async p => {
try {
await p
sent = true
} catch (e: any) {
this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e)
log(e)
try {
await Promise.all(pool.publish(relays, signed).map(async p => {
try {
await p
sent = true
} catch (e: any) {
this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e)
log(e)
}
}))
if (!sent) {
this.log(ERROR, `Failed to send Kind ${event.kind} event to any relay`)
log("failed to send event")
} else {
this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
}
}))
if (!sent) {
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)
}
}