Extension: Split Payments #13

Open
opened 2026-04-01 16:56:20 +00:00 by padreug · 1 comment
Owner

Summary

Implement a Nostr-native split payments extension for Lightning.Pub, replacing the LNbits splitpayments extension. Automatically distribute a percentage of every incoming payment to one or more recipients.

What LNbits splitpayments Does

Extremely simple extension (~400 lines of Python total):

  1. User configures a list of targets for a source wallet — each target has a wallet/LNURL/Lightning Address and a percentage (must sum to ≤100%)
  2. Background task listens for incoming payments on the source wallet
  3. On each payment received, automatically splits the configured percentages to each target:
    • Internal wallets → feeless internal transfer
    • LNURL/Lightning Address → pays external invoice (deducting fee reserve)
  4. Remaining percentage stays in the source wallet
  5. Splits are tagged with splitted: true in payment extras to prevent infinite recursion

Data model — just one table:

Target {
  id: string
  wallet: string        // wallet ID, LNURL, or Lightning Address
  source: string        // source wallet ID
  percent: float        // 0-100
  alias: string | null  // display name
}

API — just 3 endpoints:

  • GET /api/v1/targets — list targets for wallet
  • PUT /api/v1/targets — set targets (replaces all)
  • DELETE /api/v1/targets — clear all targets

Lightning.Pub Implementation

ExtensionContext API Usage

API Use
onPaymentReceived() Listen for incoming payments to trigger splits
payInvoice() Pay split targets (external recipients)
createInvoice() Create internal invoices for LP-internal recipients
registerMethod() Register RPC methods for target management
getDatabase() Store target configurations
log() Log split operations

RPC Methods

splitpay.setTargets      // Configure split targets for an app
splitpay.getTargets      // List current targets
splitpay.clearTargets    // Remove all targets
splitpay.getHistory      // View split payment history

Data Model

Target {
  id: string
  application_id: string    // which LP app this applies to
  recipient: string         // Nostr pubkey, LNURL, or Lightning Address
  percent: number           // 0-100
  alias: string | null
  creator_pubkey: string    // who configured this split
}

SplitRecord {
  id: string
  source_payment_hash: string
  target_id: string
  amount_sats: number
  payment_hash: string
  status: 'pending' | 'success' | 'failed'
  created_at: number
}

Core Logic

onPaymentReceived(payment):
  if payment.metadata?.splitted → return (prevent recursion)
  targets = db.getTargets(applicationId)
  if !targets or sum(percents) > 100 → return
  
  for each target:
    amount = payment.amountSats * target.percent / 100
    if target is nostr pubkey → payInvoice via CLINK/keysend
    if target is LNURL/address → resolve LNURL, payInvoice
    record split in history

Complexity

VERY LOW — ~300-500 lines of TypeScript. The simplest possible extension. Could be implemented in a few hours.

The entire LNbits extension is ~400 lines of Python across 4 files. The LP version would be comparable or smaller since the ExtensionContext handles payment plumbing.

Nostr-Native Advantages

LNbits Lightning.Pub
Targets identified by wallet ID, LNURL, or Lightning Address Targets can also be Nostr pubkeys — resolve via NIP-57 zap tag or CLINK
HTTP API with wallet admin key Nostr RPC — configure splits from any Nostr client
No payment proof Split receipts as signed events in history
Isolated to one LNbits instance Works across any Lightning.Pub via Nostr

NIP Review

NIP-57: Zap Tags — NATIVE SPLIT STANDARD

NIP-57 Appendix G already defines a zap tag split mechanism on Nostr events:

{
  "tags": [
    ["zap", "<pubkey-1>", "wss://relay", "1"],  // 25%
    ["zap", "<pubkey-2>", "wss://relay", "1"],  // 25%
    ["zap", "<pubkey-3>", "wss://relay", "2"]   // 50%
  ]
}

Weights are relative (not percentages) — clients sum all weights and calculate each recipient's share. This is the existing Nostr standard for split payments.

Implication: The extension could optionally publish a kind 30078 event with zap tags representing the split configuration, making it interoperable with any NIP-57 compliant wallet. Wallets zapping the LP user would automatically split according to the tags.

Other Applicable NIPs

NIP Use Priority
NIP-57 (Zaps) zap tag weights for split configuration; zap receipts as split proof Core
NIP-78 (App Data) Store split config as addressable event (kind 30078) Recommended
NIP-47 (NWC) pay_keysend for direct pubkey payments without invoice Optional
NIP-44 (Encryption) Encrypt split config if recipient list is sensitive Optional
NIP-17 (DMs) Notify recipients of incoming splits Nice-to-have

The simplest path: implement splits purely in the extension using onPaymentReceived() + payInvoice(). Optionally publish a NIP-57 zap tag event so external wallets also respect the splits when zapping.

Reference

## Summary Implement a Nostr-native split payments extension for Lightning.Pub, replacing the LNbits [splitpayments](https://github.com/lnbits/splitpayments) extension. Automatically distribute a percentage of every incoming payment to one or more recipients. ## What LNbits splitpayments Does Extremely simple extension (~400 lines of Python total): 1. User configures a list of **targets** for a source wallet — each target has a wallet/LNURL/Lightning Address and a percentage (must sum to ≤100%) 2. Background task listens for incoming payments on the source wallet 3. On each payment received, automatically splits the configured percentages to each target: - Internal wallets → feeless internal transfer - LNURL/Lightning Address → pays external invoice (deducting fee reserve) 4. Remaining percentage stays in the source wallet 5. Splits are tagged with `splitted: true` in payment extras to prevent infinite recursion **Data model — just one table:** ``` Target { id: string wallet: string // wallet ID, LNURL, or Lightning Address source: string // source wallet ID percent: float // 0-100 alias: string | null // display name } ``` **API — just 3 endpoints:** - `GET /api/v1/targets` — list targets for wallet - `PUT /api/v1/targets` — set targets (replaces all) - `DELETE /api/v1/targets` — clear all targets ## Lightning.Pub Implementation ### ExtensionContext API Usage | API | Use | |-----|-----| | `onPaymentReceived()` | Listen for incoming payments to trigger splits | | `payInvoice()` | Pay split targets (external recipients) | | `createInvoice()` | Create internal invoices for LP-internal recipients | | `registerMethod()` | Register RPC methods for target management | | `getDatabase()` | Store target configurations | | `log()` | Log split operations | ### RPC Methods ``` splitpay.setTargets // Configure split targets for an app splitpay.getTargets // List current targets splitpay.clearTargets // Remove all targets splitpay.getHistory // View split payment history ``` ### Data Model ```typescript Target { id: string application_id: string // which LP app this applies to recipient: string // Nostr pubkey, LNURL, or Lightning Address percent: number // 0-100 alias: string | null creator_pubkey: string // who configured this split } SplitRecord { id: string source_payment_hash: string target_id: string amount_sats: number payment_hash: string status: 'pending' | 'success' | 'failed' created_at: number } ``` ### Core Logic ``` onPaymentReceived(payment): if payment.metadata?.splitted → return (prevent recursion) targets = db.getTargets(applicationId) if !targets or sum(percents) > 100 → return for each target: amount = payment.amountSats * target.percent / 100 if target is nostr pubkey → payInvoice via CLINK/keysend if target is LNURL/address → resolve LNURL, payInvoice record split in history ``` ## Complexity **VERY LOW** — ~300-500 lines of TypeScript. The simplest possible extension. Could be implemented in a few hours. The entire LNbits extension is ~400 lines of Python across 4 files. The LP version would be comparable or smaller since the ExtensionContext handles payment plumbing. ## Nostr-Native Advantages | LNbits | Lightning.Pub | |--------|--------------| | Targets identified by wallet ID, LNURL, or Lightning Address | Targets can also be **Nostr pubkeys** — resolve via NIP-57 zap tag or CLINK | | HTTP API with wallet admin key | Nostr RPC — configure splits from any Nostr client | | No payment proof | Split receipts as signed events in history | | Isolated to one LNbits instance | Works across any Lightning.Pub via Nostr | ## NIP Review ### NIP-57: Zap Tags — NATIVE SPLIT STANDARD NIP-57 Appendix G already defines a **`zap` tag split mechanism** on Nostr events: ```json { "tags": [ ["zap", "<pubkey-1>", "wss://relay", "1"], // 25% ["zap", "<pubkey-2>", "wss://relay", "1"], // 25% ["zap", "<pubkey-3>", "wss://relay", "2"] // 50% ] } ``` Weights are relative (not percentages) — clients sum all weights and calculate each recipient's share. This is the existing Nostr standard for split payments. **Implication:** The extension could optionally publish a kind 30078 event with `zap` tags representing the split configuration, making it interoperable with any NIP-57 compliant wallet. Wallets zapping the LP user would automatically split according to the tags. ### Other Applicable NIPs | NIP | Use | Priority | |-----|-----|----------| | **NIP-57** (Zaps) | `zap` tag weights for split configuration; zap receipts as split proof | Core | | **NIP-78** (App Data) | Store split config as addressable event (kind 30078) | Recommended | | **NIP-47** (NWC) | `pay_keysend` for direct pubkey payments without invoice | Optional | | **NIP-44** (Encryption) | Encrypt split config if recipient list is sensitive | Optional | | **NIP-17** (DMs) | Notify recipients of incoming splits | Nice-to-have | ### Recommended Architecture The simplest path: implement splits purely in the extension using `onPaymentReceived()` + `payInvoice()`. Optionally publish a NIP-57 `zap` tag event so external wallets also respect the splits when zapping. ## Reference - LNbits splitpayments: https://github.com/lnbits/splitpayments - NIP-57 Appendix G (zap tag splits): `nips/57.md` - Existing withdraw extension as implementation reference: `lightning-pub/withdraw/`
Author
Owner

Dual-Layer Split Architecture

The internal split mechanism (like LNbits) and NIP-57 zap tags should be combined as two complementary layers:

Layer 1: NIP-57 Zap Tags (Preferred Path)

The extension publishes the split configuration as zap tags on the user's profile or events:

{
  "tags": [
    ["zap", "<artist-pubkey>", "wss://relay", "7"],   // 70%
    ["zap", "<manager-pubkey>", "wss://relay", "2"],   // 20%
    ["zap", "<venue-pubkey>", "wss://relay", "1"]      // 10%
  ]
}

Nostr-aware wallets (Amethyst, Damus, Primal, etc.) read these tags and split the zap at the sender side. Each recipient gets their own independent zap with a proper zap receipt signed by their own LNURL provider. No double-hop through the source wallet.

Layer 2: Internal Splits (Fallback)

For payments that arrive outside the Nostr ecosystem — raw BOLT11 invoices, LNURL-pay from non-Nostr wallets, CLINK payments, keysend — the extension catches them via onPaymentReceived() and splits internally, just like LNbits does.

How They Interact

Payment arrives at Lightning.Pub
  │
  ├─ Was it already split by sender? (metadata.splitted = true)
  │   └─ YES → Do nothing (Nostr wallet already split via zap tags)
  │
  └─ NO → Internal split
      ├─ Calculate percentages
      ├─ Pay each target via payInvoice() / keysend
      └─ Tag outgoing payments with splitted = true (prevent recursion)
Payment source What happens
Nostr wallet that reads zap tags Sender splits before paying — each recipient gets a direct zap. Extension does nothing.
Non-Nostr Lightning wallet Extension catches via onPaymentReceived() and splits internally
LNURL-pay from any wallet Internal split (LNURL doesn't carry zap tag context)
CLINK / keysend Internal split

Why Both Layers

  • Zap tags are more efficient: no double-hop (sender → source wallet → recipient). Each recipient gets paid directly by the sender.
  • Zap tags produce better receipts: each recipient gets a proper kind 9735 zap receipt from their own provider, not an internal transfer memo.
  • Internal splits are the safety net: not every wallet speaks Nostr. The fallback ensures 100% of payments get split, regardless of how they arrive.
  • The recursion guard is the bridge: metadata.splitted = true prevents the two layers from fighting. If a Nostr wallet already split the payment, the extension recognizes this and stays quiet.

Implementation Note

When the extension publishes zap tags, it should use NIP-57's weight system (not raw percentages). Weights are more flexible — [1, 1, 2] means 25%/25%/50% — and are the standard that Nostr clients already understand.

## Dual-Layer Split Architecture The internal split mechanism (like LNbits) and NIP-57 zap tags should be combined as two complementary layers: ### Layer 1: NIP-57 Zap Tags (Preferred Path) The extension publishes the split configuration as `zap` tags on the user's profile or events: ```json { "tags": [ ["zap", "<artist-pubkey>", "wss://relay", "7"], // 70% ["zap", "<manager-pubkey>", "wss://relay", "2"], // 20% ["zap", "<venue-pubkey>", "wss://relay", "1"] // 10% ] } ``` Nostr-aware wallets (Amethyst, Damus, Primal, etc.) read these tags and split the zap **at the sender side**. Each recipient gets their own independent zap with a proper zap receipt signed by their own LNURL provider. No double-hop through the source wallet. ### Layer 2: Internal Splits (Fallback) For payments that arrive outside the Nostr ecosystem — raw BOLT11 invoices, LNURL-pay from non-Nostr wallets, CLINK payments, keysend — the extension catches them via `onPaymentReceived()` and splits internally, just like LNbits does. ### How They Interact ``` Payment arrives at Lightning.Pub │ ├─ Was it already split by sender? (metadata.splitted = true) │ └─ YES → Do nothing (Nostr wallet already split via zap tags) │ └─ NO → Internal split ├─ Calculate percentages ├─ Pay each target via payInvoice() / keysend └─ Tag outgoing payments with splitted = true (prevent recursion) ``` | Payment source | What happens | |---|---| | Nostr wallet that reads `zap` tags | Sender splits before paying — each recipient gets a direct zap. Extension does nothing. | | Non-Nostr Lightning wallet | Extension catches via `onPaymentReceived()` and splits internally | | LNURL-pay from any wallet | Internal split (LNURL doesn't carry zap tag context) | | CLINK / keysend | Internal split | ### Why Both Layers - **Zap tags are more efficient**: no double-hop (sender → source wallet → recipient). Each recipient gets paid directly by the sender. - **Zap tags produce better receipts**: each recipient gets a proper kind 9735 zap receipt from their own provider, not an internal transfer memo. - **Internal splits are the safety net**: not every wallet speaks Nostr. The fallback ensures 100% of payments get split, regardless of how they arrive. - **The recursion guard is the bridge**: `metadata.splitted = true` prevents the two layers from fighting. If a Nostr wallet already split the payment, the extension recognizes this and stays quiet. ### Implementation Note When the extension publishes `zap` tags, it should use NIP-57's weight system (not raw percentages). Weights are more flexible — `[1, 1, 2]` means 25%/25%/50% — and are the standard that Nostr clients already understand.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: aiolabs/lightning-pub#13
No description provided.