feat(extensions): add split payments extension
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled

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:
- NIP-57 zap tags (preferred): Nostr wallets split at sender side
- Internal splits (fallback): onPaymentReceived for non-Nostr payments
- Recursion guard (metadata.splitted) prevents double-splitting

RPC methods:
- splitpay.setTargets: configure split recipients and percentages
- splitpay.getTargets: list current configuration
- splitpay.clearTargets: remove all targets
- splitpay.getHistory: view split payment audit trail

Closes #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-04-02 15:20:50 -04:00
parent 68c71599f8
commit aa58f88670
4 changed files with 518 additions and 0 deletions

View file

@ -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<void> {
await runMigrations(db)
this.manager = new SplitPayManager(ctx, db)
this.registerRpcMethods(ctx)
ctx.log('info', 'Extension initialized')
}
async shutdown(): Promise<void> {
// 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'

View file

@ -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<GetTargetsResponse> {
// 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<GetTargetsResponse> {
const rows = await this.db.query<SplitTargetRow>(
`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<void> {
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<GetHistoryResponse> {
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<SplitRecordRow>(
`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<void> {
// 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}`)
}
}
}
}

View file

@ -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<void>
}
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<void> {
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)]
)
}
}
}

View file

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