feat(extensions): add split payments extension #16
4 changed files with 518 additions and 0 deletions
88
src/extensions/splitpay/index.ts
Normal file
88
src/extensions/splitpay/index.ts
Normal 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'
|
||||||
244
src/extensions/splitpay/managers/splitPayManager.ts
Normal file
244
src/extensions/splitpay/managers/splitPayManager.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/extensions/splitpay/migrations.ts
Normal file
90
src/extensions/splitpay/migrations.ts
Normal 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)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/extensions/splitpay/types.ts
Normal file
96
src/extensions/splitpay/types.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue