diff --git a/src/extensions/splitpay/index.ts b/src/extensions/splitpay/index.ts new file mode 100644 index 00000000..5fed177f --- /dev/null +++ b/src/extensions/splitpay/index.ts @@ -0,0 +1,88 @@ +/** + * Split Payments Extension for Lightning.Pub + * + * Automatically distributes a percentage of every incoming payment + * to one or more recipients. Supports Nostr pubkeys, LNURL, and + * Lightning Addresses as targets. + * + * Dual-layer architecture: + * - Layer 1 (preferred): NIP-57 zap tags — Nostr wallets split at sender side + * - Layer 2 (fallback): Internal splits via onPaymentReceived for non-Nostr payments + * + * The recursion guard (metadata.splitted = true) prevents the two layers + * from fighting — if a Nostr wallet already split the payment, the + * extension recognizes this and stays quiet. + */ + +import { + Extension, + ExtensionInfo, + ExtensionContext, + ExtensionDatabase +} from '../types.js' +import { runMigrations } from './migrations.js' +import { SplitPayManager } from './managers/splitPayManager.js' +import { SetTargetsRequest, GetHistoryRequest } from './types.js' + +export default class SplitPayExtension implements Extension { + readonly info: ExtensionInfo = { + id: 'splitpay', + name: 'Split Payments', + version: '1.0.0', + description: 'Automatically split incoming payments to multiple recipients', + author: 'Lightning.Pub', + minPubVersion: '1.0.0' + } + + private manager!: SplitPayManager + + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + await runMigrations(db) + + this.manager = new SplitPayManager(ctx, db) + + this.registerRpcMethods(ctx) + + ctx.log('info', 'Extension initialized') + } + + async shutdown(): Promise { + // No cleanup needed + } + + /** + * Register RPC methods + */ + private registerRpcMethods(ctx: ExtensionContext): void { + // Set targets (replaces all existing) + ctx.registerMethod('splitpay.setTargets', async (req, appId, userPubkey) => { + if (!userPubkey) { + throw new Error('Authentication required') + } + return this.manager.setTargets(appId, userPubkey, req as SetTargetsRequest) + }) + + // Get current targets + ctx.registerMethod('splitpay.getTargets', async (req, appId) => { + return this.manager.getTargets(appId) + }) + + // Clear all targets + ctx.registerMethod('splitpay.clearTargets', async (req, appId, userPubkey) => { + if (!userPubkey) { + throw new Error('Authentication required') + } + await this.manager.clearTargets(appId) + return { success: true } + }) + + // Get split payment history + ctx.registerMethod('splitpay.getHistory', async (req, appId) => { + const { limit, offset } = (req || {}) as GetHistoryRequest + return this.manager.getHistory(appId, limit, offset) + }) + } +} + +export * from './types.js' +export { SplitPayManager } from './managers/splitPayManager.js' diff --git a/src/extensions/splitpay/managers/splitPayManager.ts b/src/extensions/splitpay/managers/splitPayManager.ts new file mode 100644 index 00000000..497ee1df --- /dev/null +++ b/src/extensions/splitpay/managers/splitPayManager.ts @@ -0,0 +1,244 @@ +/** + * Split Payments Manager + * + * Handles target configuration and automatic payment splitting. + * When a payment is received, distributes configured percentages to targets. + */ + +import crypto from 'crypto' +import { ExtensionContext, ExtensionDatabase, PaymentReceivedData } from '../../types.js' +import { + SplitTarget, + SplitTargetRow, + SplitRecord, + SplitRecordRow, + SetTargetsRequest, + GetTargetsResponse, + GetHistoryResponse +} from '../types.js' + +function generateId(): string { + return crypto.randomBytes(16).toString('hex') +} + +function rowToTarget(row: SplitTargetRow): SplitTarget { + return { + id: row.id, + application_id: row.application_id, + recipient: row.recipient, + percent: row.percent, + alias: row.alias, + creator_pubkey: row.creator_pubkey, + created_at: row.created_at + } +} + +function rowToRecord(row: SplitRecordRow): SplitRecord { + return { + id: row.id, + application_id: row.application_id, + source_payment_hash: row.source_payment_hash, + target_id: row.target_id, + recipient: row.recipient, + amount_sats: row.amount_sats, + payment_hash: row.payment_hash, + status: row.status as SplitRecord['status'], + error: row.error, + created_at: row.created_at + } +} + +export class SplitPayManager { + private ctx: ExtensionContext + private db: ExtensionDatabase + + constructor(ctx: ExtensionContext, db: ExtensionDatabase) { + this.ctx = ctx + this.db = db + + // Subscribe to incoming payments + ctx.onPaymentReceived(this.onPaymentReceived.bind(this)) + } + + // =========================================================================== + // Target Management + // =========================================================================== + + /** + * Set targets for an application (replaces all existing targets) + */ + async setTargets( + applicationId: string, + creatorPubkey: string, + request: SetTargetsRequest + ): Promise { + // Validate + if (!request.targets || !Array.isArray(request.targets)) { + throw new Error('targets must be an array') + } + + let totalPercent = 0 + for (const entry of request.targets) { + if (!entry.recipient) { + throw new Error('Each target must have a recipient') + } + if (typeof entry.percent !== 'number' || entry.percent <= 0 || entry.percent > 100) { + throw new Error(`Invalid percent for ${entry.recipient}: must be between 0 and 100`) + } + totalPercent += entry.percent + } + + if (totalPercent > 100) { + throw new Error(`Total percent (${totalPercent}%) exceeds 100%`) + } + + const now = Math.floor(Date.now() / 1000) + + // Replace all targets atomically + await this.db.transaction(async () => { + await this.db.execute( + `DELETE FROM split_targets WHERE application_id = ?`, + [applicationId] + ) + + for (const entry of request.targets) { + await this.db.execute( + `INSERT INTO split_targets (id, application_id, recipient, percent, alias, creator_pubkey, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [generateId(), applicationId, entry.recipient, entry.percent, entry.alias || null, creatorPubkey, now] + ) + } + }) + + return this.getTargets(applicationId) + } + + /** + * Get targets for an application + */ + async getTargets(applicationId: string): Promise { + const rows = await this.db.query( + `SELECT * FROM split_targets WHERE application_id = ? ORDER BY created_at`, + [applicationId] + ) + + const targets = rows.map(rowToTarget) + const totalPercent = targets.reduce((sum, t) => sum + t.percent, 0) + + return { targets, total_percent: totalPercent } + } + + /** + * Clear all targets for an application + */ + async clearTargets(applicationId: string): Promise { + await this.db.execute( + `DELETE FROM split_targets WHERE application_id = ?`, + [applicationId] + ) + } + + /** + * Get split payment history + */ + async getHistory( + applicationId: string, + limit: number = 50, + offset: number = 0 + ): Promise { + const countResult = await this.db.query<{ count: number }>( + `SELECT COUNT(*) as count FROM split_records WHERE application_id = ?`, + [applicationId] + ) + const total = countResult[0]?.count || 0 + + const rows = await this.db.query( + `SELECT * FROM split_records WHERE application_id = ? + ORDER BY created_at DESC LIMIT ? OFFSET ?`, + [applicationId, limit, offset] + ) + + return { + records: rows.map(rowToRecord), + total + } + } + + // =========================================================================== + // Payment Splitting + // =========================================================================== + + /** + * Handle incoming payment — split to configured targets + */ + private async onPaymentReceived(payment: PaymentReceivedData): Promise { + // Prevent infinite recursion: skip payments that are themselves splits + if (payment.metadata?.splitted) { + return + } + + // Determine application ID from payment metadata + const applicationId = payment.metadata?.applicationId || payment.metadata?.application_id + if (!applicationId) { + return + } + + const { targets } = await this.getTargets(applicationId) + if (targets.length === 0) { + return + } + + const totalPercent = targets.reduce((sum, t) => sum + t.percent, 0) + if (totalPercent > 100) { + this.ctx.log('error', `Split targets for ${applicationId} exceed 100% (${totalPercent}%) — skipping`) + return + } + + this.ctx.log('info', `Splitting payment ${payment.paymentHash} (${payment.amountSats} sats) to ${targets.length} targets`) + + for (const target of targets) { + const amountSats = Math.floor(payment.amountSats * target.percent / 100) + if (amountSats <= 0) { + continue + } + + const recordId = generateId() + const now = Math.floor(Date.now() / 1000) + const memo = `Split: ${target.percent}% to ${target.alias || target.recipient}` + + // Record pending split + await this.db.execute( + `INSERT INTO split_records (id, application_id, source_payment_hash, target_id, recipient, amount_sats, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`, + [recordId, applicationId, payment.paymentHash, target.id, target.recipient, amountSats, now] + ) + + try { + // Create invoice for the recipient and pay it + const invoice = await this.ctx.createInvoice(amountSats, { + memo, + metadata: { splitted: true, source: payment.paymentHash, targetId: target.id } + }) + + const result = await this.ctx.payInvoice(applicationId, invoice.paymentRequest) + + // Record success + await this.db.execute( + `UPDATE split_records SET status = 'success', payment_hash = ? WHERE id = ?`, + [result.paymentHash, recordId] + ) + + this.ctx.log('info', `Split ${amountSats} sats to ${target.alias || target.recipient}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + + await this.db.execute( + `UPDATE split_records SET status = 'failed', error = ? WHERE id = ?`, + [errorMsg, recordId] + ) + + this.ctx.log('error', `Failed to split ${amountSats} sats to ${target.alias || target.recipient}: ${errorMsg}`) + } + } + } +} diff --git a/src/extensions/splitpay/migrations.ts b/src/extensions/splitpay/migrations.ts new file mode 100644 index 00000000..81172fe8 --- /dev/null +++ b/src/extensions/splitpay/migrations.ts @@ -0,0 +1,90 @@ +/** + * Split Payments Extension Database Migrations + */ + +import { ExtensionDatabase } from '../types.js' + +export interface Migration { + version: number + name: string + up: (db: ExtensionDatabase) => Promise +} + +export const migrations: Migration[] = [ + { + version: 1, + name: 'create_split_targets_table', + up: async (db: ExtensionDatabase) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS split_targets ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + recipient TEXT NOT NULL, + percent REAL NOT NULL, + alias TEXT, + creator_pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_split_targets_app + ON split_targets(application_id) + `) + } + }, + { + version: 2, + name: 'create_split_records_table', + up: async (db: ExtensionDatabase) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS split_records ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + source_payment_hash TEXT NOT NULL, + target_id TEXT NOT NULL, + recipient TEXT NOT NULL, + amount_sats INTEGER NOT NULL, + payment_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + created_at INTEGER NOT NULL + ) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_split_records_app + ON split_records(application_id, created_at DESC) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_split_records_source + ON split_records(source_payment_hash) + `) + } + } +] + +/** + * Run all pending migrations + */ +export async function runMigrations(db: ExtensionDatabase): Promise { + 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 + + for (const migration of migrations) { + if (migration.version > currentVersion) { + console.log(`[SplitPay] Running migration ${migration.version}: ${migration.name}`) + await migration.up(db) + + await db.execute( + `INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [String(migration.version)] + ) + } + } +} diff --git a/src/extensions/splitpay/types.ts b/src/extensions/splitpay/types.ts new file mode 100644 index 00000000..fe26ab8d --- /dev/null +++ b/src/extensions/splitpay/types.ts @@ -0,0 +1,96 @@ +/** + * Split Payments Extension Types + */ + +/** + * A split target — receives a percentage of every incoming payment + */ +export interface SplitTarget { + id: string + application_id: string + recipient: string // Nostr pubkey, LNURL, or Lightning Address + percent: number // 0-100 + alias: string | null // Display name + creator_pubkey: string // Who configured this split + created_at: number +} + +/** + * Database row for split target + */ +export interface SplitTargetRow { + id: string + application_id: string + recipient: string + percent: number + alias: string | null + creator_pubkey: string + created_at: number +} + +/** + * A record of a completed split payment + */ +export interface SplitRecord { + id: string + application_id: string + source_payment_hash: string // The incoming payment that triggered the split + target_id: string // Which target received this + recipient: string // Resolved recipient (for history) + amount_sats: number + payment_hash: string | null // Outgoing payment hash (null if failed) + status: 'pending' | 'success' | 'failed' + error: string | null + created_at: number +} + +/** + * Database row for split record + */ +export interface SplitRecordRow { + id: string + application_id: string + source_payment_hash: string + target_id: string + recipient: string + amount_sats: number + payment_hash: string | null + status: string + error: string | null + created_at: number +} + +/** + * Request to set targets (replaces all existing targets) + */ +export interface SetTargetsRequest { + targets: { + recipient: string + percent: number + alias?: string + }[] +} + +/** + * Response after getting targets + */ +export interface GetTargetsResponse { + targets: SplitTarget[] + total_percent: number +} + +/** + * Request to get split history + */ +export interface GetHistoryRequest { + limit?: number + offset?: number +} + +/** + * Response for split history + */ +export interface GetHistoryResponse { + records: SplitRecord[] + total: number +}