SplitPact concepts

This page explains the mental model behind every pact: what nodes, edges, pills, and conditions are, how the flush algorithm turns a deposit into on-chain transfers, and how a pact moves from draft to live. Read this once; the rest of the docs make more sense after.

What is a pact

A pact is an on-chain account that knows how to split an incoming stream of tokens between recipients. You describe the split as a directed graph once; anyone can deposit into the pact at any time, and anyone can trigger a distribution (a "flush") — no multisig round, no keeper key.

There are two flavours, built on the same primitive:

FlavourWho's involvedOn-chain shapeImmutable?
BusinessOne company, many internal allocationsFull DAG (editable)No — creator can refine
PartnershipMultiple wallets agreeing on sharesOne of 4 templatesYes — locked after partner signatures

Both are the same FlowPact account on Solana; the dashboard just presents two different UX flows. Partnership is implemented on top of the business primitive with a signature-gated preview step before the creator finalizes on-chain.

Nodes

A pact's graph has three kinds of nodes:

  • Root — id 0, auto-created by every new pact. This is where deposits land. There's exactly one per pact.
  • Intermediate — optional "buckets" between Root and recipients. Useful when you want conditional flow in stages (e.g. a marketing pool that only fills after the company pool fills).
  • External — wallets outside the pact (payees). Externals are implicit: you reference them by their Solana address on an edge; you don't create them as first-class nodes.

Nodes have a holding (current balance inside the node) and two lifetime counters:

  • lifetimeInflow — cumulative tokens that have ever entered this node.
  • lifetimeOutflow — cumulative tokens that have ever left this node (only tracked per outgoing edge; node-level outflow is implicit).

Externals don't have holdings — they're terminal wallets, not buckets.

Edges

An edge routes flow from a source node (Root or Intermediate) to a target (Internal or External). It has three knobs:

  • shareBps — basis points (0..10000). 5000 = 50%, 10000 = 100%. Every edge from the same source slices the holding observed at the start of the flush (the "snapshot"), not whatever the prior edge left behind. Edges still fire in id-ascending order — the order is contractual for capOutflow accounting and the simulator parity test — but the share base is the same for every edge in one flush.

    Concrete example: Root has 100 USDC. Two outgoing edges, both to externals, both at 5000:

    edge 0: shareBps = 5000 →  takes 50% of snapshot 100 = 50 USDC to Alice
    edge 1: shareBps = 5000 →  takes 50% of snapshot 100 = 50 USDC to Bob
    

    Clean 50/50. The sum of outgoing shareBps from a node must be ≤ 10000 (enforced at create time). When the sum is < 10000, the unallocated portion stays on the node for a future flush. (Edges carrying a payExact condition are the exception: they pay a fixed base-unit amount, must have shareBps == 0, and are excluded from this sum. See Conditions below.)

  • target — either {kind: "internal", nodeId} or {kind: "external", wallet}.

  • conditions — 0..4 AND-combined gates. An edge only fires when all of its conditions hold.

Conditions

The protocol supports six condition kinds (five gates plus payExact, a payout-amount override). All numeric parameters are decimal strings (u64 or i64), not JS numbers — Solana's u64 doesn't fit in a JS number past 2^53.

KindFires whenExample
afterInflow {min}source.lifetimeInflow ≥ min{kind: "afterInflow", min: "1000000"} — edge unlocks after 1 USDC has ever entered the source
inflowRange {min, max}min ≤ source.lifetimeInflow < max{kind: "inflowRange", min: "1000000", max: "10000000"} — edge only fires for cumulative inflow between 1 and 10 USDC
capOutflow {max}edge's own lifetime outflow < max{kind: "capOutflow", max: "500000"} — at most 0.5 USDC ever flows along this edge
timeGate {after, before}after ≤ now < before (unix seconds){kind: "timeGate", after: "1704067200", before: "1735689600"} — edge active for one year in 2024
whenHoldingAtLeast {min}source.holding ≥ min{kind: "whenHoldingAtLeast", min: "100000000"} — edge requires source to hold 100 USDC right now
payExact {amount}not a gate: overrides the share to pay an EXACT base-unit amount from the live holding instead of shareBps × snapshot{kind: "payExact", amount: "1000000"} — send exactly 1 USDC along this edge (the payroll primitive)

payExact (v5.3) is the one exception to the snapshot rule: it pays min(amount, live source.holding) against the post-debit remaining holding, so two payExact edges from one node each receive their full amount in id order. It ignores shareBps (which must be 0), is excluded from the Σ ≤ 10000 budget, and may not co-occur with capOutflow or a second payExact on the same edge. This is what keeps payroll dust sub-cent at any size.

Pills (the canvas visualisation)

The editor draws conditions as n8n-style "pills" between a source node and its target:

Root ──→ [ AFTER INFLOW ≥ 1 USDC ] ──→ Alice

At compile-time the chain source → pill → pill → target collapses into one canonical edge with conditions: [pill1, pill2, ...]. So pills are a UX affordance; on-chain they're just the conditions array on the edge.

The flush algorithm

A flush is what turns deposited tokens into outgoing transfers. Anyone can trigger a flush on any internal node by calling flushNode on the program — no special authority.

Per-node flush, in order:

  1. Snapshot the source node's holding and lifetimeInflow (v4.1).
  2. For each outgoing edge in ascending id order:
    1. Evaluate conditions against the snapshot. If any fails, skip.
    2. Compute amount: for a normal edge, snapshot.holding * shareBps / 10000 (floor); for a payExact edge, min(amount, live source.holding) — it ignores shareBps (which must be 0) and reads the post-debit live holding, so two payExact edges from one node each get their full amount in id order.
    3. If amount == 0, skip.
    4. Transfer amount to target:
      • Internal → add to target node's holding and bump target's lifetimeInflow.
      • External → CPI'd SPL transfer to the wallet's ATA.
    5. Decrement the live source.holding by amount, bump edge's lifetimeOutflow.
  3. Phase 5 invariant: sum(node.holding) == vault.amount after the loop. The transaction reverts if it ever fails.

The sum-of-shares guard at create time means step 2.5 cannot under-flow the live holding: at most the snapshot is drained.

The simulator package (@splitpact/flow-simulator) runs the same algorithm in pure TypeScript and is bit-for-bit aligned with the on-chain implementation, so you can preview what a pact will do before deploying.

Deposits via the vault address (v4.2)

The pact's vault is a regular SPL token account. Anyone can paste its address into a wallet UI / payment processor and send tokens to it; the network has no way to refuse the transfer. But the program never runs on a plain transfer, so Root.holding doesn't update — and the solvency invariant sum(holdings) == vault.amount would trip on the next flush_node.

sync_vault (v4.2) is the reconciliation hook that closes that gap. It compares the live vault.amount to sum(node.holding), takes the protocol fee from the difference, and credits the net to Root. Permissionless: any signer may invoke. Idempotent: zero-delta calls are no-ops.

In practice the dashboard's Distribute button always emits sync_vault first, so a Pay → vault → Distribute flow lands the funds where they were always supposed to go without exposing the caller to the deposit ix at all. The standard deposit instruction remains the most efficient path when the sender is a wallet that can call programs directly (no extra fee CPI on the vault → treasury side).

Lifecycle

A pact moves through these states in Postgres + on-chain:

draft  ─►  awaiting_signatures  ─►  ready  ─►  finalized (on-chain)
                                                 │
                                                 └─►  deposits + flushes
  • draft — editable. Business pacts sit here until the creator clicks Preview; partnership pacts sit here until the creator clicks Publish Preview.
  • awaiting_signatures — partnership only. Every partner wallet has to signMessage an approval. Hash of the canonical payload is part of the signed message; any further edit invalidates signatures.
  • ready — partnership only. All partners signed; the creator can now finalize.
  • finalized — on-chain transaction landed. The pact's FlowPact PDA exists on devnet and its address is stored in onchain_pact_address.

Post-finalize, the draft row is read-only. All real activity (deposits, flushes) happens against the on-chain account.

Capacity limits (protocol)

Hard-coded in the program; the dashboard uses the same caps.

ResourceMaxWhy
Nodes16 (Root + 15)On-chain account size is fixed; 16 covers realistic use cases
Edges80v5.4 cap (raised 48→80); ~80-recipient single-token payrolls
Outgoing per source24v5.4 (raised 8→24); keeps flush bounded per call
Conditions per edge4AND-combining more than 4 rarely changes outcome
Label bytes (UTF-8)32Stored on-chain for debugging

Externals don't count toward MAX_NODES — a pact with 1 root and 20 partner wallets is valid.

Further reading


← All docs