feat(extensions): add LNURL-withdraw extension #6
11 changed files with 1965 additions and 7 deletions
|
|
@ -20,6 +20,17 @@ export interface MainHandlerInterface {
|
||||||
// Application management
|
// Application management
|
||||||
applicationManager: {
|
applicationManager: {
|
||||||
getById(id: string): Promise<any>
|
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
|
// Payment operations
|
||||||
|
|
@ -41,6 +52,7 @@ export interface MainHandlerInterface {
|
||||||
applicationId: string
|
applicationId: string
|
||||||
paymentRequest: string
|
paymentRequest: string
|
||||||
maxFeeSats?: number
|
maxFeeSats?: number
|
||||||
|
userPubkey?: string
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
paymentHash: string
|
paymentHash: string
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -156,16 +168,19 @@ export class ExtensionContextImpl implements ExtensionContext {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pay a Lightning invoice
|
* Pay a Lightning invoice
|
||||||
|
* If userPubkey is provided, pays from that user's balance instead of app.owner
|
||||||
*/
|
*/
|
||||||
async payInvoice(
|
async payInvoice(
|
||||||
applicationId: string,
|
applicationId: string,
|
||||||
paymentRequest: string,
|
paymentRequest: string,
|
||||||
maxFeeSats?: number
|
maxFeeSats?: number,
|
||||||
|
userPubkey?: string
|
||||||
): Promise<{ paymentHash: string; feeSats: number }> {
|
): Promise<{ paymentHash: string; feeSats: number }> {
|
||||||
return this.mainHandler.paymentManager.payInvoice({
|
return this.mainHandler.paymentManager.payInvoice({
|
||||||
applicationId,
|
applicationId,
|
||||||
paymentRequest,
|
paymentRequest,
|
||||||
maxFeeSats
|
maxFeeSats,
|
||||||
|
userPubkey
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
155
src/extensions/mainHandlerAdapter.ts
Normal file
155
src/extensions/mainHandlerAdapter.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* MainHandler Adapter for Extension System
|
||||||
|
*
|
||||||
|
* Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface
|
||||||
|
* required by the extension system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MainHandlerInterface } from './context.js'
|
||||||
|
import { LnurlPayInfo } from './types.js'
|
||||||
|
import type Main from '../services/main/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an adapter that wraps mainHandler for extension use
|
||||||
|
*/
|
||||||
|
export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface {
|
||||||
|
return {
|
||||||
|
applicationManager: {
|
||||||
|
async getById(id: string) {
|
||||||
|
// The applicationManager stores apps internally
|
||||||
|
// We need to access it through the storage layer
|
||||||
|
try {
|
||||||
|
const app = await mainHandler.storage.applicationStorage.GetApplication(id)
|
||||||
|
if (!app) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: app.app_id,
|
||||||
|
name: app.name,
|
||||||
|
nostr_public: app.nostr_public_key || '',
|
||||||
|
balance: app.owner?.balance_sats || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// GetApplication throws if not found
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async PayAppUserInvoice(appId, req) {
|
||||||
|
return mainHandler.applicationManager.PayAppUserInvoice(appId, req)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
paymentManager: {
|
||||||
|
async createInvoice(params: {
|
||||||
|
applicationId: string
|
||||||
|
amountSats: number
|
||||||
|
memo?: string
|
||||||
|
expiry?: number
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}) {
|
||||||
|
// Get the app to find the user ID
|
||||||
|
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
|
||||||
|
if (!app) {
|
||||||
|
throw new Error(`Application not found: ${params.applicationId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create invoice using the app owner's user ID
|
||||||
|
const result = await mainHandler.paymentManager.NewInvoice(
|
||||||
|
app.owner.user_id,
|
||||||
|
{
|
||||||
|
amountSats: params.amountSats,
|
||||||
|
memo: params.memo || ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expiry: params.expiry || 3600
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.invoice.split(':')[0] || result.invoice, // Extract ID if present
|
||||||
|
paymentRequest: result.invoice,
|
||||||
|
paymentHash: '', // Not directly available from NewInvoice response
|
||||||
|
expiry: Date.now() + (params.expiry || 3600) * 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async payInvoice(params: {
|
||||||
|
applicationId: string
|
||||||
|
paymentRequest: string
|
||||||
|
maxFeeSats?: number
|
||||||
|
userPubkey?: string
|
||||||
|
}) {
|
||||||
|
// Get the app to find the user ID and app reference
|
||||||
|
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
|
||||||
|
if (!app) {
|
||||||
|
throw new Error(`Application not found: ${params.applicationId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.userPubkey) {
|
||||||
|
// Resolve the Nostr user's ApplicationUser to get their identifier
|
||||||
|
const appUser = await mainHandler.storage.applicationStorage.GetOrCreateNostrAppUser(app, params.userPubkey)
|
||||||
|
console.log(`[MainHandlerAdapter] Paying via PayAppUserInvoice from Nostr user ${params.userPubkey.slice(0, 8)}... (identifier: ${appUser.identifier})`)
|
||||||
|
|
||||||
|
// Use applicationManager.PayAppUserInvoice so notifyAppUserPayment fires
|
||||||
|
// This sends LiveUserOperation events via Nostr for real-time balance updates
|
||||||
|
const result = await mainHandler.applicationManager.PayAppUserInvoice(
|
||||||
|
params.applicationId,
|
||||||
|
{
|
||||||
|
invoice: params.paymentRequest,
|
||||||
|
amount: 0, // Use invoice amount
|
||||||
|
user_identifier: appUser.identifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: result.preimage || '',
|
||||||
|
feeSats: result.network_fee || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: pay from app owner's balance (no Nostr user context)
|
||||||
|
const result = await mainHandler.paymentManager.PayInvoice(
|
||||||
|
app.owner.user_id,
|
||||||
|
{
|
||||||
|
invoice: params.paymentRequest,
|
||||||
|
amount: 0
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: result.preimage || '',
|
||||||
|
feeSats: result.network_fee || 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
|
||||||
|
metadata?: string
|
||||||
|
description?: string
|
||||||
|
}): Promise<LnurlPayInfo> {
|
||||||
|
// This would need implementation based on how Lightning.Pub handles LNURL-pay
|
||||||
|
// For now, throw not implemented
|
||||||
|
throw new Error('getLnurlPayInfoByPubkey not yet implemented')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendNostrEvent(event: any): Promise<string | null> {
|
||||||
|
// The mainHandler doesn't directly expose nostrSend
|
||||||
|
// This would need to be implemented through the nostrMiddleware
|
||||||
|
// For now, return null (not implemented)
|
||||||
|
console.warn('[MainHandlerAdapter] sendNostrEvent not fully implemented')
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendEncryptedDM(
|
||||||
|
applicationId: string,
|
||||||
|
recipientPubkey: string,
|
||||||
|
content: string
|
||||||
|
): Promise<string> {
|
||||||
|
// This would need implementation using NIP-44 encryption
|
||||||
|
// For now, throw not implemented
|
||||||
|
throw new Error('sendEncryptedDM not yet implemented')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -140,8 +140,9 @@ export interface ExtensionContext {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pay a Lightning invoice (requires sufficient balance)
|
* 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
|
paymentHash: string
|
||||||
feeSats: number
|
feeSats: number
|
||||||
}>
|
}>
|
||||||
|
|
|
||||||
383
src/extensions/withdraw/index.ts
Normal file
383
src/extensions/withdraw/index.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension for Lightning.Pub
|
||||||
|
*
|
||||||
|
* Implements LUD-03 (LNURL-withdraw) for creating withdraw links
|
||||||
|
* that allow anyone to pull funds from a Lightning wallet.
|
||||||
|
*
|
||||||
|
* Use cases:
|
||||||
|
* - Quick vouchers (batch single-use codes)
|
||||||
|
* - Faucets
|
||||||
|
* - Gift cards / prepaid cards
|
||||||
|
* - Tips / donations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Extension,
|
||||||
|
ExtensionInfo,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
CreateWithdrawLinkRequest,
|
||||||
|
UpdateWithdrawLinkRequest,
|
||||||
|
HttpRoute,
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse
|
||||||
|
} from './types.js'
|
||||||
|
import { runMigrations } from './migrations.js'
|
||||||
|
import { WithdrawManager } from './managers/withdrawManager.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension
|
||||||
|
*/
|
||||||
|
export default class WithdrawExtension implements Extension {
|
||||||
|
readonly info: ExtensionInfo = {
|
||||||
|
id: 'withdraw',
|
||||||
|
name: 'LNURL Withdraw',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Create withdraw links for vouchers, faucets, and gifts (LUD-03)',
|
||||||
|
author: 'Lightning.Pub',
|
||||||
|
minPubVersion: '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager!: WithdrawManager
|
||||||
|
private baseUrl: string = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the extension
|
||||||
|
*/
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Run migrations
|
||||||
|
await runMigrations(db)
|
||||||
|
|
||||||
|
// Initialize manager
|
||||||
|
this.manager = new WithdrawManager(db, ctx)
|
||||||
|
|
||||||
|
// Register RPC methods
|
||||||
|
this.registerRpcMethods(ctx)
|
||||||
|
|
||||||
|
// Register HTTP routes for LNURL protocol
|
||||||
|
this.registerHttpRoutes(ctx)
|
||||||
|
|
||||||
|
ctx.log('info', 'Extension initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the extension
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
// Cleanup if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base URL for LNURL generation
|
||||||
|
* This should be called by the main application after loading
|
||||||
|
*/
|
||||||
|
setBaseUrl(url: string): void {
|
||||||
|
this.baseUrl = url
|
||||||
|
this.manager.setBaseUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HTTP routes for this extension
|
||||||
|
* These need to be mounted by the main HTTP server
|
||||||
|
*/
|
||||||
|
getHttpRoutes(): HttpRoute[] {
|
||||||
|
return [
|
||||||
|
// Create withdraw link (HTTP API for ATM/external integrations)
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/withdraw/create',
|
||||||
|
handler: this.handleCreateWithdrawLink.bind(this)
|
||||||
|
},
|
||||||
|
// LNURL callback (user submits invoice) - MUST be before :unique_hash routes
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/lnurl/cb/:unique_hash',
|
||||||
|
handler: this.handleLnurlCallback.bind(this)
|
||||||
|
},
|
||||||
|
// Initial LNURL request (unique link with use hash)
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/lnurl/:unique_hash/:id_unique_hash',
|
||||||
|
handler: this.handleLnurlUniqueRequest.bind(this)
|
||||||
|
},
|
||||||
|
// Initial LNURL request (simple link) - MUST be last (catches all)
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/lnurl/:unique_hash',
|
||||||
|
handler: this.handleLnurlRequest.bind(this)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register RPC methods with the extension context
|
||||||
|
*/
|
||||||
|
private registerRpcMethods(ctx: ExtensionContext): void {
|
||||||
|
// Create withdraw link
|
||||||
|
ctx.registerMethod('withdraw.createLink', async (req, appId, userPubkey) => {
|
||||||
|
const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest, userPubkey)
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
total_withdrawn_sats: stats.total_sats,
|
||||||
|
withdrawals_count: stats.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create quick vouchers
|
||||||
|
ctx.registerMethod('withdraw.createVouchers', async (req, appId) => {
|
||||||
|
const vouchers = await this.manager.createVouchers(
|
||||||
|
appId,
|
||||||
|
req.title,
|
||||||
|
req.amount,
|
||||||
|
req.count,
|
||||||
|
req.description
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
vouchers,
|
||||||
|
total_amount_sats: req.amount * req.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get withdraw link
|
||||||
|
ctx.registerMethod('withdraw.getLink', async (req, appId) => {
|
||||||
|
const link = await this.manager.get(req.id, appId)
|
||||||
|
if (!link) throw new Error('Withdraw link not found')
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
total_withdrawn_sats: stats.total_sats,
|
||||||
|
withdrawals_count: stats.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// List withdraw links
|
||||||
|
ctx.registerMethod('withdraw.listLinks', async (req, appId) => {
|
||||||
|
const links = await this.manager.list(
|
||||||
|
appId,
|
||||||
|
req.include_spent || false,
|
||||||
|
req.limit,
|
||||||
|
req.offset
|
||||||
|
)
|
||||||
|
return { links }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update withdraw link
|
||||||
|
ctx.registerMethod('withdraw.updateLink', async (req, appId) => {
|
||||||
|
const link = await this.manager.update(req.id, appId, req as UpdateWithdrawLinkRequest)
|
||||||
|
if (!link) throw new Error('Withdraw link not found')
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
total_withdrawn_sats: stats.total_sats,
|
||||||
|
withdrawals_count: stats.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete withdraw link
|
||||||
|
ctx.registerMethod('withdraw.deleteLink', async (req, appId) => {
|
||||||
|
const success = await this.manager.delete(req.id, appId)
|
||||||
|
if (!success) throw new Error('Withdraw link not found')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
// List withdrawals
|
||||||
|
ctx.registerMethod('withdraw.listWithdrawals', async (req, appId) => {
|
||||||
|
const withdrawals = await this.manager.listWithdrawals(
|
||||||
|
appId,
|
||||||
|
req.link_id,
|
||||||
|
req.limit,
|
||||||
|
req.offset
|
||||||
|
)
|
||||||
|
return { withdrawals }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get withdrawal stats
|
||||||
|
ctx.registerMethod('withdraw.getStats', async (req, appId) => {
|
||||||
|
// Get all links to calculate total stats
|
||||||
|
const links = await this.manager.list(appId, true)
|
||||||
|
|
||||||
|
let totalLinks = links.length
|
||||||
|
let activeLinks = 0
|
||||||
|
let spentLinks = 0
|
||||||
|
let totalWithdrawn = 0
|
||||||
|
let totalWithdrawals = 0
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
if (link.used >= link.uses) {
|
||||||
|
spentLinks++
|
||||||
|
} else {
|
||||||
|
activeLinks++
|
||||||
|
}
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
totalWithdrawn += stats.total_sats
|
||||||
|
totalWithdrawals += stats.count
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_links: totalLinks,
|
||||||
|
active_links: activeLinks,
|
||||||
|
spent_links: spentLinks,
|
||||||
|
total_withdrawn_sats: totalWithdrawn,
|
||||||
|
total_withdrawals: totalWithdrawals
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register HTTP routes (called by extension context)
|
||||||
|
*/
|
||||||
|
private registerHttpRoutes(ctx: ExtensionContext): void {
|
||||||
|
// HTTP routes are exposed via getHttpRoutes()
|
||||||
|
// The main application is responsible for mounting them
|
||||||
|
ctx.log('debug', 'HTTP routes registered for LNURL protocol')
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HTTP Route Handlers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle create withdraw link request (HTTP API)
|
||||||
|
* POST /api/v1/withdraw/create
|
||||||
|
*
|
||||||
|
* Body: {
|
||||||
|
* title: string
|
||||||
|
* min_withdrawable: number (sats)
|
||||||
|
* max_withdrawable: number (sats)
|
||||||
|
* uses?: number (defaults to 1)
|
||||||
|
* wait_time?: number (seconds between uses, defaults to 0)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Auth: Bearer token in Authorization header (app_<app_id>)
|
||||||
|
*
|
||||||
|
* Returns: {
|
||||||
|
* link: { lnurl, unique_hash, id, ... }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private async handleCreateWithdrawLink(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
try {
|
||||||
|
const { title, min_withdrawable, max_withdrawable, uses, wait_time } = req.body
|
||||||
|
|
||||||
|
// Extract app_id from Authorization header (Bearer app_<app_id>)
|
||||||
|
const authHeader = req.headers?.authorization || req.headers?.Authorization || ''
|
||||||
|
let app_id = 'default'
|
||||||
|
if (authHeader.startsWith('Bearer app_')) {
|
||||||
|
app_id = authHeader.replace('Bearer app_', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title || !min_withdrawable) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { status: 'ERROR', reason: 'Missing required fields: title, min_withdrawable' },
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await this.manager.create(app_id, {
|
||||||
|
title,
|
||||||
|
min_withdrawable,
|
||||||
|
max_withdrawable: max_withdrawable || min_withdrawable,
|
||||||
|
uses: uses || 1,
|
||||||
|
wait_time: wait_time || 0,
|
||||||
|
is_unique: false // Simple single-use links for ATM
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return in format expected by ATM client
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
status: 'OK',
|
||||||
|
link: {
|
||||||
|
lnurl: link.lnurl,
|
||||||
|
unique_hash: link.unique_hash,
|
||||||
|
id: link.id,
|
||||||
|
title: link.title,
|
||||||
|
min_withdrawable: link.min_withdrawable,
|
||||||
|
max_withdrawable: link.max_withdrawable,
|
||||||
|
uses: link.uses,
|
||||||
|
used: link.used
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: { status: 'ERROR', reason: error.message },
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial LNURL request (simple link)
|
||||||
|
* GET /api/v1/lnurl/:unique_hash
|
||||||
|
*/
|
||||||
|
private async handleLnurlRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { unique_hash } = req.params
|
||||||
|
|
||||||
|
const result = await this.manager.handleLnurlRequest(unique_hash)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: result,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial LNURL request (unique link)
|
||||||
|
* GET /api/v1/lnurl/:unique_hash/:id_unique_hash
|
||||||
|
*/
|
||||||
|
private async handleLnurlUniqueRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { unique_hash, id_unique_hash } = req.params
|
||||||
|
|
||||||
|
const result = await this.manager.handleLnurlRequest(unique_hash, id_unique_hash)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: result,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle LNURL callback (user submits invoice)
|
||||||
|
* GET /api/v1/lnurl/cb/:unique_hash?k1=...&pr=...&id_unique_hash=...
|
||||||
|
*/
|
||||||
|
private async handleLnurlCallback(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { unique_hash } = req.params
|
||||||
|
const { k1, pr, id_unique_hash } = req.query
|
||||||
|
|
||||||
|
if (!k1 || !pr) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { status: 'ERROR', reason: 'Missing k1 or pr parameter' },
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.manager.handleLnurlCallback(unique_hash, {
|
||||||
|
k1,
|
||||||
|
pr,
|
||||||
|
id_unique_hash
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: result,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types for external use
|
||||||
|
export * from './types.js'
|
||||||
|
export { WithdrawManager } from './managers/withdrawManager.js'
|
||||||
717
src/extensions/withdraw/managers/withdrawManager.ts
Normal file
717
src/extensions/withdraw/managers/withdrawManager.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
/**
|
||||||
|
* Withdraw Link Manager
|
||||||
|
*
|
||||||
|
* Handles CRUD operations for withdraw links and processes withdrawals
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
WithdrawLink,
|
||||||
|
Withdrawal,
|
||||||
|
CreateWithdrawLinkRequest,
|
||||||
|
UpdateWithdrawLinkRequest,
|
||||||
|
WithdrawLinkWithLnurl,
|
||||||
|
LnurlWithdrawResponse,
|
||||||
|
LnurlErrorResponse,
|
||||||
|
LnurlSuccessResponse,
|
||||||
|
LnurlCallbackParams
|
||||||
|
} from '../types.js'
|
||||||
|
import {
|
||||||
|
generateId,
|
||||||
|
generateK1,
|
||||||
|
generateUniqueHash,
|
||||||
|
generateUseHash,
|
||||||
|
verifyUseHash,
|
||||||
|
encodeLnurl,
|
||||||
|
buildLnurlUrl,
|
||||||
|
buildUniqueLnurlUrl,
|
||||||
|
buildCallbackUrl,
|
||||||
|
satsToMsats
|
||||||
|
} from '../utils/lnurl.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row types
|
||||||
|
*/
|
||||||
|
interface WithdrawLinkRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
min_withdrawable: number
|
||||||
|
max_withdrawable: number
|
||||||
|
uses: number
|
||||||
|
used: number
|
||||||
|
wait_time: number
|
||||||
|
unique_hash: string
|
||||||
|
k1: string
|
||||||
|
is_unique: number
|
||||||
|
uses_csv: string
|
||||||
|
open_time: number
|
||||||
|
creator_pubkey: string | null
|
||||||
|
webhook_url: string | null
|
||||||
|
webhook_headers: string | null
|
||||||
|
webhook_body: string | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WithdrawalRow {
|
||||||
|
id: string
|
||||||
|
link_id: string
|
||||||
|
application_id: string
|
||||||
|
payment_hash: string
|
||||||
|
amount_sats: number
|
||||||
|
fee_sats: number
|
||||||
|
recipient_node: string | null
|
||||||
|
webhook_success: number | null
|
||||||
|
webhook_response: string | null
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert row to WithdrawLink
|
||||||
|
*/
|
||||||
|
function rowToLink(row: WithdrawLinkRow): WithdrawLink {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description || undefined,
|
||||||
|
min_withdrawable: row.min_withdrawable,
|
||||||
|
max_withdrawable: row.max_withdrawable,
|
||||||
|
uses: row.uses,
|
||||||
|
used: row.used,
|
||||||
|
wait_time: row.wait_time,
|
||||||
|
unique_hash: row.unique_hash,
|
||||||
|
k1: row.k1,
|
||||||
|
is_unique: row.is_unique === 1,
|
||||||
|
uses_csv: row.uses_csv,
|
||||||
|
open_time: row.open_time,
|
||||||
|
creator_pubkey: row.creator_pubkey || undefined,
|
||||||
|
webhook_url: row.webhook_url || undefined,
|
||||||
|
webhook_headers: row.webhook_headers || undefined,
|
||||||
|
webhook_body: row.webhook_body || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert row to Withdrawal
|
||||||
|
*/
|
||||||
|
function rowToWithdrawal(row: WithdrawalRow): Withdrawal {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
link_id: row.link_id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
payment_hash: row.payment_hash,
|
||||||
|
amount_sats: row.amount_sats,
|
||||||
|
fee_sats: row.fee_sats,
|
||||||
|
recipient_node: row.recipient_node || undefined,
|
||||||
|
webhook_success: row.webhook_success === null ? undefined : row.webhook_success === 1,
|
||||||
|
webhook_response: row.webhook_response || undefined,
|
||||||
|
created_at: row.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WithdrawManager - Handles withdraw link operations
|
||||||
|
*/
|
||||||
|
export class WithdrawManager {
|
||||||
|
private baseUrl: string = ''
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base URL for LNURL generation
|
||||||
|
*/
|
||||||
|
setBaseUrl(url: string): void {
|
||||||
|
this.baseUrl = url.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add LNURL to a withdraw link
|
||||||
|
*/
|
||||||
|
private addLnurl(link: WithdrawLink): WithdrawLinkWithLnurl {
|
||||||
|
const lnurlUrl = buildLnurlUrl(this.baseUrl, link.unique_hash)
|
||||||
|
return {
|
||||||
|
...link,
|
||||||
|
lnurl: encodeLnurl(lnurlUrl),
|
||||||
|
lnurl_url: lnurlUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CRUD Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new withdraw link
|
||||||
|
*/
|
||||||
|
async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise<WithdrawLinkWithLnurl> {
|
||||||
|
// Validation
|
||||||
|
if (req.uses < 1 || req.uses > 250) {
|
||||||
|
throw new Error('Uses must be between 1 and 250')
|
||||||
|
}
|
||||||
|
if (req.min_withdrawable < 1) {
|
||||||
|
throw new Error('Min withdrawable must be at least 1 sat')
|
||||||
|
}
|
||||||
|
if (req.max_withdrawable < req.min_withdrawable) {
|
||||||
|
throw new Error('Max withdrawable must be >= min withdrawable')
|
||||||
|
}
|
||||||
|
if (req.wait_time < 0) {
|
||||||
|
throw new Error('Wait time cannot be negative')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate webhook JSON if provided
|
||||||
|
if (req.webhook_headers) {
|
||||||
|
try {
|
||||||
|
JSON.parse(req.webhook_headers)
|
||||||
|
} catch {
|
||||||
|
throw new Error('webhook_headers must be valid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.webhook_body) {
|
||||||
|
try {
|
||||||
|
JSON.parse(req.webhook_body)
|
||||||
|
} catch {
|
||||||
|
throw new Error('webhook_body must be valid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
const usesCsv = Array.from({ length: req.uses }, (_, i) => String(i)).join(',')
|
||||||
|
|
||||||
|
const link: WithdrawLink = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
title: req.title.trim(),
|
||||||
|
description: req.description?.trim(),
|
||||||
|
min_withdrawable: req.min_withdrawable,
|
||||||
|
max_withdrawable: req.max_withdrawable,
|
||||||
|
uses: req.uses,
|
||||||
|
used: 0,
|
||||||
|
wait_time: req.wait_time,
|
||||||
|
unique_hash: generateUniqueHash(),
|
||||||
|
k1: generateK1(),
|
||||||
|
is_unique: req.is_unique || false,
|
||||||
|
uses_csv: usesCsv,
|
||||||
|
open_time: now,
|
||||||
|
creator_pubkey: creatorPubkey,
|
||||||
|
webhook_url: req.webhook_url,
|
||||||
|
webhook_headers: req.webhook_headers,
|
||||||
|
webhook_body: req.webhook_body,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO withdraw_links (
|
||||||
|
id, application_id, title, description,
|
||||||
|
min_withdrawable, max_withdrawable, uses, used, wait_time,
|
||||||
|
unique_hash, k1, is_unique, uses_csv, open_time,
|
||||||
|
creator_pubkey,
|
||||||
|
webhook_url, webhook_headers, webhook_body,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
link.id, link.application_id, link.title, link.description || null,
|
||||||
|
link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time,
|
||||||
|
link.unique_hash, link.k1, link.is_unique ? 1 : 0, link.uses_csv, link.open_time,
|
||||||
|
link.creator_pubkey || null,
|
||||||
|
link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null,
|
||||||
|
link.created_at, link.updated_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.addLnurl(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create multiple vouchers (single-use withdraw links)
|
||||||
|
*/
|
||||||
|
async createVouchers(
|
||||||
|
applicationId: string,
|
||||||
|
title: string,
|
||||||
|
amount: number,
|
||||||
|
count: number,
|
||||||
|
description?: string
|
||||||
|
): Promise<WithdrawLinkWithLnurl[]> {
|
||||||
|
if (count < 1 || count > 100) {
|
||||||
|
throw new Error('Count must be between 1 and 100')
|
||||||
|
}
|
||||||
|
if (amount < 1) {
|
||||||
|
throw new Error('Amount must be at least 1 sat')
|
||||||
|
}
|
||||||
|
|
||||||
|
const vouchers: WithdrawLinkWithLnurl[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const voucher = await this.create(applicationId, {
|
||||||
|
title: `${title} #${i + 1}`,
|
||||||
|
description,
|
||||||
|
min_withdrawable: amount,
|
||||||
|
max_withdrawable: amount,
|
||||||
|
uses: 1,
|
||||||
|
wait_time: 0,
|
||||||
|
is_unique: false
|
||||||
|
})
|
||||||
|
vouchers.push(voucher)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vouchers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a withdraw link by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<WithdrawLinkWithLnurl | null> {
|
||||||
|
const rows = await this.db.query<WithdrawLinkRow>(
|
||||||
|
'SELECT * FROM withdraw_links WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return this.addLnurl(rowToLink(rows[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a withdraw link by unique hash (for LNURL)
|
||||||
|
*/
|
||||||
|
async getByHash(uniqueHash: string): Promise<WithdrawLink | null> {
|
||||||
|
const rows = await this.db.query<WithdrawLinkRow>(
|
||||||
|
'SELECT * FROM withdraw_links WHERE unique_hash = ?',
|
||||||
|
[uniqueHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToLink(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdraw links for an application
|
||||||
|
*/
|
||||||
|
async list(
|
||||||
|
applicationId: string,
|
||||||
|
includeSpent: boolean = false,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<WithdrawLinkWithLnurl[]> {
|
||||||
|
let sql = 'SELECT * FROM withdraw_links WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (!includeSpent) {
|
||||||
|
sql += ' AND used < uses'
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
if (offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<WithdrawLinkRow>(sql, params)
|
||||||
|
return rows.map(row => this.addLnurl(rowToLink(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a withdraw link
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
req: UpdateWithdrawLinkRequest
|
||||||
|
): Promise<WithdrawLinkWithLnurl | null> {
|
||||||
|
const existing = await this.get(id, applicationId)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (req.uses !== undefined) {
|
||||||
|
if (req.uses < 1 || req.uses > 250) {
|
||||||
|
throw new Error('Uses must be between 1 and 250')
|
||||||
|
}
|
||||||
|
if (req.uses < existing.used) {
|
||||||
|
throw new Error('Cannot reduce uses below current used count')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minWith = req.min_withdrawable ?? existing.min_withdrawable
|
||||||
|
const maxWith = req.max_withdrawable ?? existing.max_withdrawable
|
||||||
|
|
||||||
|
if (minWith < 1) {
|
||||||
|
throw new Error('Min withdrawable must be at least 1 sat')
|
||||||
|
}
|
||||||
|
if (maxWith < minWith) {
|
||||||
|
throw new Error('Max withdrawable must be >= min withdrawable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle uses change
|
||||||
|
let usesCsv = existing.uses_csv
|
||||||
|
const newUses = req.uses ?? existing.uses
|
||||||
|
if (newUses !== existing.uses) {
|
||||||
|
const currentUses = usesCsv.split(',').filter(u => u !== '')
|
||||||
|
if (newUses > existing.uses) {
|
||||||
|
// Add more uses
|
||||||
|
const lastNum = currentUses.length > 0 ? parseInt(currentUses[currentUses.length - 1], 10) : -1
|
||||||
|
for (let i = lastNum + 1; currentUses.length < (newUses - existing.used); i++) {
|
||||||
|
currentUses.push(String(i))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove uses (keep first N)
|
||||||
|
usesCsv = currentUses.slice(0, newUses - existing.used).join(',')
|
||||||
|
}
|
||||||
|
usesCsv = currentUses.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdraw_links SET
|
||||||
|
title = ?, description = ?,
|
||||||
|
min_withdrawable = ?, max_withdrawable = ?,
|
||||||
|
uses = ?, wait_time = ?, is_unique = ?, uses_csv = ?,
|
||||||
|
webhook_url = ?, webhook_headers = ?, webhook_body = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
req.title ?? existing.title,
|
||||||
|
req.description ?? existing.description ?? null,
|
||||||
|
minWith, maxWith,
|
||||||
|
newUses,
|
||||||
|
req.wait_time ?? existing.wait_time,
|
||||||
|
(req.is_unique ?? existing.is_unique) ? 1 : 0,
|
||||||
|
usesCsv,
|
||||||
|
req.webhook_url ?? existing.webhook_url ?? null,
|
||||||
|
req.webhook_headers ?? existing.webhook_headers ?? null,
|
||||||
|
req.webhook_body ?? existing.webhook_body ?? null,
|
||||||
|
now,
|
||||||
|
id, applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.get(id, applicationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a withdraw link
|
||||||
|
*/
|
||||||
|
async delete(id: string, applicationId: string): Promise<boolean> {
|
||||||
|
const result = await this.db.execute(
|
||||||
|
'DELETE FROM withdraw_links WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LNURL Protocol Handlers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial LNURL request (user scans QR)
|
||||||
|
* Returns withdraw parameters
|
||||||
|
*/
|
||||||
|
async handleLnurlRequest(
|
||||||
|
uniqueHash: string,
|
||||||
|
idUniqueHash?: string
|
||||||
|
): Promise<LnurlWithdrawResponse | LnurlErrorResponse> {
|
||||||
|
const link = await this.getByHash(uniqueHash)
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link does not exist.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.used >= link.uses) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unique links, require id_unique_hash
|
||||||
|
if (link.is_unique && !idUniqueHash) {
|
||||||
|
return { status: 'ERROR', reason: 'This link requires a unique hash.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify unique hash if provided
|
||||||
|
if (idUniqueHash) {
|
||||||
|
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, idUniqueHash)
|
||||||
|
if (!useNumber) {
|
||||||
|
return { status: 'ERROR', reason: 'Invalid unique hash.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = buildCallbackUrl(this.baseUrl, link.unique_hash)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: 'withdrawRequest',
|
||||||
|
callback: idUniqueHash ? `${callbackUrl}?id_unique_hash=${idUniqueHash}` : callbackUrl,
|
||||||
|
k1: link.k1,
|
||||||
|
minWithdrawable: satsToMsats(link.min_withdrawable),
|
||||||
|
maxWithdrawable: satsToMsats(link.max_withdrawable),
|
||||||
|
defaultDescription: link.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle LNURL callback (user submits invoice)
|
||||||
|
* Pays the invoice and records the withdrawal
|
||||||
|
*/
|
||||||
|
async handleLnurlCallback(
|
||||||
|
uniqueHash: string,
|
||||||
|
params: LnurlCallbackParams
|
||||||
|
): Promise<LnurlSuccessResponse | LnurlErrorResponse> {
|
||||||
|
const link = await this.getByHash(uniqueHash)
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link not found.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.used >= link.uses) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.k1 !== params.k1) {
|
||||||
|
return { status: 'ERROR', reason: 'Invalid k1.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check wait time
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (now < link.open_time) {
|
||||||
|
const waitSecs = link.open_time - now
|
||||||
|
return { status: 'ERROR', reason: `Please wait ${waitSecs} seconds.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unique links, verify and consume the use hash
|
||||||
|
if (params.id_unique_hash) {
|
||||||
|
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, params.id_unique_hash)
|
||||||
|
if (!useNumber) {
|
||||||
|
return { status: 'ERROR', reason: 'Invalid unique hash.' }
|
||||||
|
}
|
||||||
|
} else if (link.is_unique) {
|
||||||
|
return { status: 'ERROR', reason: 'Unique hash required.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-spending with hash check
|
||||||
|
try {
|
||||||
|
await this.createHashCheck(params.id_unique_hash || uniqueHash, params.k1)
|
||||||
|
} catch {
|
||||||
|
return { status: 'ERROR', reason: 'Withdrawal already in progress.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pay the invoice from the creator's balance (if created via Nostr RPC)
|
||||||
|
const payment = await this.ctx.payInvoice(
|
||||||
|
link.application_id,
|
||||||
|
params.pr,
|
||||||
|
link.max_withdrawable,
|
||||||
|
link.creator_pubkey
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record the withdrawal
|
||||||
|
await this.recordWithdrawal(link, payment.paymentHash, link.max_withdrawable, payment.feeSats)
|
||||||
|
|
||||||
|
// Increment usage
|
||||||
|
await this.incrementUsage(link, params.id_unique_hash)
|
||||||
|
|
||||||
|
// Clean up hash check
|
||||||
|
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
|
||||||
|
|
||||||
|
// Dispatch webhook if configured
|
||||||
|
if (link.webhook_url) {
|
||||||
|
this.dispatchWebhook(link, payment.paymentHash, params.pr).catch(err => {
|
||||||
|
console.error('[Withdraw] Webhook error:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'OK' }
|
||||||
|
} catch (err: any) {
|
||||||
|
// Clean up hash check on failure
|
||||||
|
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
|
||||||
|
return { status: 'ERROR', reason: `Payment failed: ${err.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment link usage and update open_time
|
||||||
|
*/
|
||||||
|
private async incrementUsage(link: WithdrawLink, idUniqueHash?: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
let usesCsv = link.uses_csv
|
||||||
|
|
||||||
|
// Remove used hash from uses_csv if unique
|
||||||
|
if (idUniqueHash) {
|
||||||
|
const uses = usesCsv.split(',').filter(u => {
|
||||||
|
const hash = generateUseHash(link.id, link.unique_hash, u.trim())
|
||||||
|
return hash !== idUniqueHash
|
||||||
|
})
|
||||||
|
usesCsv = uses.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdraw_links SET
|
||||||
|
used = used + 1,
|
||||||
|
open_time = ?,
|
||||||
|
uses_csv = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[now + link.wait_time, usesCsv, now, link.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a successful withdrawal
|
||||||
|
*/
|
||||||
|
private async recordWithdrawal(
|
||||||
|
link: WithdrawLink,
|
||||||
|
paymentHash: string,
|
||||||
|
amountSats: number,
|
||||||
|
feeSats: number
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO withdrawals (
|
||||||
|
id, link_id, application_id,
|
||||||
|
payment_hash, amount_sats, fee_sats,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
generateId(),
|
||||||
|
link.id,
|
||||||
|
link.application_id,
|
||||||
|
paymentHash,
|
||||||
|
amountSats,
|
||||||
|
feeSats,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create hash check to prevent double-spending
|
||||||
|
*/
|
||||||
|
private async createHashCheck(hash: string, k1: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
await this.db.execute(
|
||||||
|
'INSERT INTO hash_checks (hash, k1, created_at) VALUES (?, ?, ?)',
|
||||||
|
[hash, k1, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete hash check after completion
|
||||||
|
*/
|
||||||
|
private async deleteHashCheck(hash: string): Promise<void> {
|
||||||
|
await this.db.execute('DELETE FROM hash_checks WHERE hash = ?', [hash])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdrawals
|
||||||
|
*/
|
||||||
|
async listWithdrawals(
|
||||||
|
applicationId: string,
|
||||||
|
linkId?: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<Withdrawal[]> {
|
||||||
|
let sql = 'SELECT * FROM withdrawals WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (linkId) {
|
||||||
|
sql += ' AND link_id = ?'
|
||||||
|
params.push(linkId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
if (offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<WithdrawalRow>(sql, params)
|
||||||
|
return rows.map(rowToWithdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get withdrawal stats for a link
|
||||||
|
*/
|
||||||
|
async getWithdrawalStats(linkId: string): Promise<{ total_sats: number; count: number }> {
|
||||||
|
const result = await this.db.query<{ total: number; count: number }>(
|
||||||
|
`SELECT COALESCE(SUM(amount_sats), 0) as total, COUNT(*) as count
|
||||||
|
FROM withdrawals WHERE link_id = ?`,
|
||||||
|
[linkId]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
total_sats: result[0]?.total || 0,
|
||||||
|
count: result[0]?.count || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch webhook notification
|
||||||
|
*/
|
||||||
|
private async dispatchWebhook(
|
||||||
|
link: WithdrawLink,
|
||||||
|
paymentHash: string,
|
||||||
|
paymentRequest: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!link.webhook_url) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.webhook_headers) {
|
||||||
|
Object.assign(headers, JSON.parse(link.webhook_headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
lnurlw: link.id,
|
||||||
|
body: link.webhook_body ? JSON.parse(link.webhook_body) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(link.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update withdrawal record with webhook result
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdrawals SET
|
||||||
|
webhook_success = ?,
|
||||||
|
webhook_response = ?
|
||||||
|
WHERE payment_hash = ?`,
|
||||||
|
[response.ok ? 1 : 0, await response.text(), paymentHash]
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdrawals SET
|
||||||
|
webhook_success = 0,
|
||||||
|
webhook_response = ?
|
||||||
|
WHERE payment_hash = ?`,
|
||||||
|
[err.message, paymentHash]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/extensions/withdraw/migrations.ts
Normal file
164
src/extensions/withdraw/migrations.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension Database Migrations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
export interface Migration {
|
||||||
|
version: number
|
||||||
|
name: string
|
||||||
|
up: (db: ExtensionDatabase) => Promise<void>
|
||||||
|
down?: (db: ExtensionDatabase) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrations: Migration[] = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
name: 'create_withdraw_links_table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS withdraw_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Amounts (sats)
|
||||||
|
min_withdrawable INTEGER NOT NULL,
|
||||||
|
max_withdrawable INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- Usage limits
|
||||||
|
uses INTEGER NOT NULL DEFAULT 1,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wait_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Security
|
||||||
|
unique_hash TEXT NOT NULL UNIQUE,
|
||||||
|
k1 TEXT NOT NULL,
|
||||||
|
is_unique INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uses_csv TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
open_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Webhooks
|
||||||
|
webhook_url TEXT,
|
||||||
|
webhook_headers TEXT,
|
||||||
|
webhook_body TEXT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for looking up by unique_hash (LNURL)
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdraw_links_unique_hash
|
||||||
|
ON withdraw_links(unique_hash)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for listing by application
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdraw_links_application
|
||||||
|
ON withdraw_links(application_id, created_at DESC)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
name: 'create_withdrawals_table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS withdrawals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
link_id TEXT NOT NULL,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Payment details
|
||||||
|
payment_hash TEXT NOT NULL,
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Recipient
|
||||||
|
recipient_node TEXT,
|
||||||
|
|
||||||
|
-- Webhook result
|
||||||
|
webhook_success INTEGER,
|
||||||
|
webhook_response TEXT,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (link_id) REFERENCES withdraw_links(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for listing withdrawals by link
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdrawals_link
|
||||||
|
ON withdrawals(link_id, created_at DESC)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for looking up by payment hash
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdrawals_payment_hash
|
||||||
|
ON withdrawals(payment_hash)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
name: 'create_hash_checks_table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
// Temporary table to prevent double-spending during payment processing
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hash_checks (
|
||||||
|
hash TEXT PRIMARY KEY,
|
||||||
|
k1 TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
name: 'add_creator_pubkey_column',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
// Store the Nostr pubkey of the user who created the withdraw link
|
||||||
|
// so that when the LNURL callback fires, we debit the correct user's balance
|
||||||
|
await db.execute(`
|
||||||
|
ALTER TABLE withdraw_links ADD COLUMN creator_pubkey TEXT
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all pending migrations
|
||||||
|
*/
|
||||||
|
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Get current version
|
||||||
|
const versionResult = await db.query<{ value: string }>(
|
||||||
|
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
|
||||||
|
).catch(() => [])
|
||||||
|
|
||||||
|
const currentVersion = versionResult.length > 0 ? parseInt(versionResult[0].value, 10) : 0
|
||||||
|
|
||||||
|
// Run pending migrations
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (migration.version > currentVersion) {
|
||||||
|
console.log(`[Withdraw] Running migration ${migration.version}: ${migration.name}`)
|
||||||
|
await migration.up(db)
|
||||||
|
|
||||||
|
// Update version
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
[String(migration.version)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
264
src/extensions/withdraw/types.ts
Normal file
264
src/extensions/withdraw/types.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension Types
|
||||||
|
* Implements LUD-03 (LNURL-withdraw) for Lightning.Pub
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export base extension types
|
||||||
|
export {
|
||||||
|
Extension,
|
||||||
|
ExtensionInfo,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
ApplicationInfo,
|
||||||
|
RpcMethodHandler
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Data Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A withdraw link that can be used to pull funds
|
||||||
|
*/
|
||||||
|
export interface WithdrawLink {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
// Display
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
// Amounts (in sats)
|
||||||
|
min_withdrawable: number
|
||||||
|
max_withdrawable: number
|
||||||
|
|
||||||
|
// Usage limits
|
||||||
|
uses: number // Total allowed uses
|
||||||
|
used: number // Times used so far
|
||||||
|
wait_time: number // Seconds between uses
|
||||||
|
|
||||||
|
// Security
|
||||||
|
unique_hash: string // For LNURL URL
|
||||||
|
k1: string // Challenge for callback
|
||||||
|
is_unique: boolean // Generate unique code per use
|
||||||
|
uses_csv: string // Comma-separated list of available use IDs
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
open_time: number // Unix timestamp when next use is allowed
|
||||||
|
|
||||||
|
// Creator identity (for Nostr RPC-created links)
|
||||||
|
creator_pubkey?: string // Nostr pubkey of the user who created this link
|
||||||
|
|
||||||
|
// Webhook notifications
|
||||||
|
webhook_url?: string
|
||||||
|
webhook_headers?: string // JSON string
|
||||||
|
webhook_body?: string // JSON string
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdrawal record - tracks each successful withdrawal
|
||||||
|
*/
|
||||||
|
export interface Withdrawal {
|
||||||
|
id: string
|
||||||
|
link_id: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
// Payment details
|
||||||
|
payment_hash: string
|
||||||
|
amount_sats: number
|
||||||
|
fee_sats: number
|
||||||
|
|
||||||
|
// Recipient (if known)
|
||||||
|
recipient_node?: string
|
||||||
|
|
||||||
|
// Webhook result
|
||||||
|
webhook_success?: boolean
|
||||||
|
webhook_response?: string
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash check - prevents double-spending during payment
|
||||||
|
*/
|
||||||
|
export interface HashCheck {
|
||||||
|
hash: string
|
||||||
|
k1: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LNURL Protocol Types (LUD-03)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw response (first call)
|
||||||
|
* Returned when user scans the QR code
|
||||||
|
*/
|
||||||
|
export interface LnurlWithdrawResponse {
|
||||||
|
tag: 'withdrawRequest'
|
||||||
|
callback: string // URL to call with invoice
|
||||||
|
k1: string // Challenge
|
||||||
|
minWithdrawable: number // Millisats
|
||||||
|
maxWithdrawable: number // Millisats
|
||||||
|
defaultDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL error response
|
||||||
|
*/
|
||||||
|
export interface LnurlErrorResponse {
|
||||||
|
status: 'ERROR'
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL success response
|
||||||
|
*/
|
||||||
|
export interface LnurlSuccessResponse {
|
||||||
|
status: 'OK'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RPC Request/Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new withdraw link
|
||||||
|
*/
|
||||||
|
export interface CreateWithdrawLinkRequest {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
min_withdrawable: number // sats
|
||||||
|
max_withdrawable: number // sats
|
||||||
|
uses: number // 1-250
|
||||||
|
wait_time: number // seconds between uses
|
||||||
|
is_unique?: boolean // generate unique code per use
|
||||||
|
webhook_url?: string
|
||||||
|
webhook_headers?: string // JSON
|
||||||
|
webhook_body?: string // JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing withdraw link
|
||||||
|
*/
|
||||||
|
export interface UpdateWithdrawLinkRequest {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
min_withdrawable?: number
|
||||||
|
max_withdrawable?: number
|
||||||
|
uses?: number
|
||||||
|
wait_time?: number
|
||||||
|
is_unique?: boolean
|
||||||
|
webhook_url?: string
|
||||||
|
webhook_headers?: string
|
||||||
|
webhook_body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get withdraw link by ID
|
||||||
|
*/
|
||||||
|
export interface GetWithdrawLinkRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdraw links
|
||||||
|
*/
|
||||||
|
export interface ListWithdrawLinksRequest {
|
||||||
|
include_spent?: boolean // Include fully used links
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete withdraw link
|
||||||
|
*/
|
||||||
|
export interface DeleteWithdrawLinkRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create quick vouchers (batch of single-use links)
|
||||||
|
*/
|
||||||
|
export interface CreateVouchersRequest {
|
||||||
|
title: string
|
||||||
|
amount: number // sats per voucher
|
||||||
|
count: number // number of vouchers (1-100)
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get withdraw link with LNURL
|
||||||
|
*/
|
||||||
|
export interface WithdrawLinkWithLnurl extends WithdrawLink {
|
||||||
|
lnurl: string // bech32 encoded LNURL
|
||||||
|
lnurl_url: string // raw callback URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdrawals for a link
|
||||||
|
*/
|
||||||
|
export interface ListWithdrawalsRequest {
|
||||||
|
link_id?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdraw link response with stats
|
||||||
|
*/
|
||||||
|
export interface WithdrawLinkResponse {
|
||||||
|
link: WithdrawLinkWithLnurl
|
||||||
|
total_withdrawn_sats: number
|
||||||
|
withdrawals_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vouchers response
|
||||||
|
*/
|
||||||
|
export interface VouchersResponse {
|
||||||
|
vouchers: WithdrawLinkWithLnurl[]
|
||||||
|
total_amount_sats: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP Handler Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL callback parameters
|
||||||
|
*/
|
||||||
|
export interface LnurlCallbackParams {
|
||||||
|
k1: string // Challenge from initial response
|
||||||
|
pr: string // Payment request (BOLT11 invoice)
|
||||||
|
id_unique_hash?: string // For unique links
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP route handler
|
||||||
|
*/
|
||||||
|
export interface HttpRoute {
|
||||||
|
method: 'GET' | 'POST'
|
||||||
|
path: string
|
||||||
|
handler: (req: HttpRequest) => Promise<HttpResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpRequest {
|
||||||
|
params: Record<string, string>
|
||||||
|
query: Record<string, string>
|
||||||
|
body?: any
|
||||||
|
headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpResponse {
|
||||||
|
status: number
|
||||||
|
body: any
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
131
src/extensions/withdraw/utils/lnurl.ts
Normal file
131
src/extensions/withdraw/utils/lnurl.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* LNURL Encoding Utilities
|
||||||
|
*
|
||||||
|
* LNURL is a bech32-encoded URL with hrp "lnurl"
|
||||||
|
* See: https://github.com/lnurl/luds
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { bech32 } from 'bech32'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a URL as LNURL (bech32)
|
||||||
|
*/
|
||||||
|
export function encodeLnurl(url: string): string {
|
||||||
|
const words = bech32.toWords(Buffer.from(url, 'utf8'))
|
||||||
|
return bech32.encode('lnurl', words, 2000) // 2000 char limit for URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an LNURL to a URL
|
||||||
|
*/
|
||||||
|
export function decodeLnurl(lnurl: string): string {
|
||||||
|
const { prefix, words } = bech32.decode(lnurl, 2000)
|
||||||
|
if (prefix !== 'lnurl') {
|
||||||
|
throw new Error('Invalid LNURL prefix')
|
||||||
|
}
|
||||||
|
return Buffer.from(bech32.fromWords(words)).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a URL-safe random ID
|
||||||
|
*/
|
||||||
|
export function generateId(length: number = 22): string {
|
||||||
|
const bytes = crypto.randomBytes(Math.ceil(length * 3 / 4))
|
||||||
|
return bytes.toString('base64url').slice(0, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a k1 challenge (32 bytes hex)
|
||||||
|
*/
|
||||||
|
export function generateK1(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique hash for a link
|
||||||
|
*/
|
||||||
|
export function generateUniqueHash(): string {
|
||||||
|
return generateId(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique hash for a specific use of a link
|
||||||
|
* This creates a deterministic hash based on link ID, unique_hash, and use number
|
||||||
|
*/
|
||||||
|
export function generateUseHash(linkId: string, uniqueHash: string, useNumber: string): string {
|
||||||
|
const data = `${linkId}${uniqueHash}${useNumber}`
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a use hash matches one of the available uses
|
||||||
|
*/
|
||||||
|
export function verifyUseHash(
|
||||||
|
linkId: string,
|
||||||
|
uniqueHash: string,
|
||||||
|
usesCsv: string,
|
||||||
|
providedHash: string
|
||||||
|
): string | null {
|
||||||
|
const uses = usesCsv.split(',').filter(u => u.trim() !== '')
|
||||||
|
|
||||||
|
for (const useNumber of uses) {
|
||||||
|
const expectedHash = generateUseHash(linkId, uniqueHash, useNumber.trim())
|
||||||
|
if (expectedHash === providedHash) {
|
||||||
|
return useNumber.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LNURL callback URL for a withdraw link
|
||||||
|
*/
|
||||||
|
export function buildLnurlUrl(baseUrl: string, uniqueHash: string): string {
|
||||||
|
// Remove trailing slash from baseUrl
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/api/v1/lnurl/${uniqueHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LNURL callback URL for a unique withdraw link
|
||||||
|
*/
|
||||||
|
export function buildUniqueLnurlUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
uniqueHash: string,
|
||||||
|
useHash: string
|
||||||
|
): string {
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/api/v1/lnurl/${uniqueHash}/${useHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the callback URL for the second step (where user sends invoice)
|
||||||
|
*/
|
||||||
|
export function buildCallbackUrl(baseUrl: string, uniqueHash: string): string {
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/api/v1/lnurl/cb/${uniqueHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sats to millisats
|
||||||
|
*/
|
||||||
|
export function satsToMsats(sats: number): number {
|
||||||
|
return sats * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Millisats to sats
|
||||||
|
*/
|
||||||
|
export function msatsToSats(msats: number): number {
|
||||||
|
return Math.floor(msats / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a BOLT11 invoice (basic check)
|
||||||
|
*/
|
||||||
|
export function isValidBolt11(invoice: string): boolean {
|
||||||
|
const lower = invoice.toLowerCase()
|
||||||
|
return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt')
|
||||||
|
}
|
||||||
101
src/index.ts
101
src/index.ts
|
|
@ -1,4 +1,8 @@
|
||||||
import 'dotenv/config'
|
import '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 NewServer from '../proto/autogenerated/ts/express_server.js'
|
||||||
import GetServerMethods from './services/serverMethods/index.js'
|
import GetServerMethods from './services/serverMethods/index.js'
|
||||||
import serverOptions from './auth.js';
|
import serverOptions from './auth.js';
|
||||||
|
|
@ -8,9 +12,15 @@ import { initMainHandler, initSettings } from './services/main/init.js';
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
|
import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
|
||||||
import { AppInfo } from './services/nostr/nostrPool.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
|
//@ts-ignore
|
||||||
const { nprofileEncode } = nip19
|
const { nprofileEncode } = nip19
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
const log = getLogger({})
|
const log = getLogger({})
|
||||||
|
|
@ -25,6 +35,42 @@ const start = async () => {
|
||||||
|
|
||||||
const { mainHandler, localProviderClient, wizard, adminManager } = keepOn
|
const { mainHandler, localProviderClient, wizard, adminManager } = keepOn
|
||||||
const serverMethods = GetServerMethods(mainHandler)
|
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")
|
log("initializing nostr middleware")
|
||||||
const relays = settingsManager.getSettings().nostrRelaySettings.relays
|
const relays = settingsManager.getSettings().nostrRelaySettings.relays
|
||||||
const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength
|
const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength
|
||||||
|
|
@ -45,7 +91,8 @@ const start = async () => {
|
||||||
{
|
{
|
||||||
relays, maxEventContentLength, apps
|
relays, maxEventContentLength, apps
|
||||||
},
|
},
|
||||||
(e, p) => mainHandler.liquidityProvider.onEvent(e, p)
|
(e, p) => mainHandler.liquidityProvider.onEvent(e, p),
|
||||||
|
{ extensionLoader: extensionLoader || undefined }
|
||||||
)
|
)
|
||||||
exitHandler(() => { Stop(); mainHandler.Stop() })
|
exitHandler(() => { Stop(); mainHandler.Stop() })
|
||||||
log("starting server")
|
log("starting server")
|
||||||
|
|
@ -58,8 +105,58 @@ const start = async () => {
|
||||||
wizard.AddConnectInfo(appNprofile, relays)
|
wizard.AddConnectInfo(appNprofile, relays)
|
||||||
}
|
}
|
||||||
adminManager.setAppNprofile(appNprofile)
|
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))
|
const Server = NewServer(serverMethods, serverOptions(mainHandler))
|
||||||
Server.Listen(settingsManager.getSettings().serviceSettings.servicePort)
|
Server.Listen(mainPort)
|
||||||
}
|
}
|
||||||
start()
|
start()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,15 @@ import * as Types from '../proto/autogenerated/ts/types.js'
|
||||||
import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js';
|
import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js';
|
||||||
import { ERROR, getLogger } from "./services/helpers/logger.js";
|
import { ERROR, getLogger } from "./services/helpers/logger.js";
|
||||||
import { NdebitData, NofferData, NmanageRequest } from "@shocknet/clink-sdk";
|
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 ExportedCalls = { Stop: () => void, Send: NostrSend, Ping: () => Promise<void>, Reset: (settings: NostrSettings) => void }
|
||||||
type ClientEventCallback = (e: { requestId: string }, fromPub: string) => 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 log = getLogger({})
|
||||||
const nostrTransport = NewNostrTransport(serverMethods, {
|
const nostrTransport = NewNostrTransport(serverMethods, {
|
||||||
NostrUserAuthGuard: async (appId, pub) => {
|
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)
|
log(ERROR, "authIdentifier does not match", j.authIdentifier || "--", event.pub)
|
||||||
return
|
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 => {
|
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 }) })
|
nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) })
|
||||||
}, event.startAtNano, event.startAtMs)
|
}, event.startAtNano, event.startAtMs)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const PayInvoiceReq = (invoice: string, amount: number, feeLimit: number)
|
||||||
maxParts: 3,
|
maxParts: 3,
|
||||||
timeoutSeconds: 50,
|
timeoutSeconds: 50,
|
||||||
|
|
||||||
allowSelfPayment: false,
|
allowSelfPayment: true,
|
||||||
amp: false,
|
amp: false,
|
||||||
amtMsat: 0n,
|
amtMsat: 0n,
|
||||||
cltvLimit: 0,
|
cltvLimit: 0,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue