Compare commits

..

19 commits

Author SHA1 Message Date
Patrick Mulligan
7ea027c5ce 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-03-26 18:45:35 -04:00
Patrick Mulligan
50c391abb6 feat(extensions): add getLnurlPayInfo to ExtensionContext
Enables extensions to get LNURL-pay info for users by pubkey,
supporting Lightning Address (LUD-16) and zap (NIP-57) functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:35 -04:00
Patrick Mulligan
e9fb545b97 docs(extensions): add comprehensive extension loader documentation
Covers architecture, API reference, lifecycle, database isolation,
RPC methods, HTTP routes, event handling, and complete examples.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:35 -04:00
Patrick Mulligan
8a58ecb094 feat(extensions): add extension loader infrastructure
Adds a modular extension system for Lightning.Pub that allows
third-party functionality to be added without modifying core code.

Features:
- ExtensionLoader: discovers and loads extensions from directory
- ExtensionContext: provides extensions with access to Lightning.Pub APIs
- ExtensionDatabase: isolated SQLite database per extension
- Lifecycle management: initialize, shutdown, health checks
- RPC method registration: extensions can add new RPC methods
- Event dispatching: routes payments and Nostr events to extensions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:35 -04:00
Patrick Mulligan
00c36f6e14 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-26 18:45:35 -04:00
Patrick Mulligan
1c363d0787 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-26 18:45:35 -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
20 changed files with 1886 additions and 3141 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

@ -20,17 +20,6 @@ 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
@ -52,7 +41,6 @@ export interface MainHandlerInterface {
applicationId: string
paymentRequest: string
maxFeeSats?: number
userPubkey?: string
}): Promise<{
paymentHash: string
feeSats: number
@ -168,19 +156,16 @@ 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,
userPubkey?: string
maxFeeSats?: number
): Promise<{ paymentHash: string; feeSats: number }> {
return this.mainHandler.paymentManager.payInvoice({
applicationId,
paymentRequest,
maxFeeSats,
userPubkey
maxFeeSats
})
}

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

@ -140,9 +140,8 @@ 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, userPubkey?: string): Promise<{
payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number): Promise<{
paymentHash: string
feeSats: number
}>
@ -192,31 +191,6 @@ 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
*/
@ -243,12 +217,6 @@ 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

@ -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) => {
@ -101,31 +95,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)

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

@ -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

@ -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

@ -205,7 +205,6 @@ 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()
try {
await Promise.all(pool.publish(relays, signed).map(async p => {
try {
await p
@ -221,9 +220,6 @@ export class NostrPool {
} else {
this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
}
} finally {
pool.close(relays)
}
}
private getRelays(initiator: SendInitiator, requestRelays?: string[]) {