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:
| Flavour | Who's involved | On-chain shape | Immutable? |
|---|---|---|---|
| Business | One company, many internal allocations | Full DAG (editable) | No — creator can refine |
| Partnership | Multiple wallets agreeing on shares | One of 4 templates | Yes — 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 forcapOutflowaccounting 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 BobClean 50/50. The sum of outgoing
shareBpsfrom 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 apayExactcondition are the exception: they pay a fixed base-unit amount, must haveshareBps == 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.
| Kind | Fires when | Example |
|---|---|---|
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:
- Snapshot the source node's
holdingandlifetimeInflow(v4.1). - For each outgoing edge in ascending
idorder:- Evaluate conditions against the snapshot. If any fails, skip.
- Compute
amount: for a normal edge,snapshot.holding * shareBps / 10000(floor); for apayExactedge,min(amount, live source.holding)— it ignoresshareBps(which must be 0) and reads the post-debit live holding, so twopayExactedges from one node each get their full amount in id order. - If
amount == 0, skip. - Transfer
amountto target:- Internal → add to target node's
holdingand bump target'slifetimeInflow. - External → CPI'd SPL transfer to the wallet's ATA.
- Internal → add to target node's
- Decrement the live
source.holdingbyamount, bump edge'slifetimeOutflow.
- Phase 5 invariant:
sum(node.holding) == vault.amountafter 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'sFlowPactPDA exists on devnet and its address is stored inonchain_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.
| Resource | Max | Why |
|---|---|---|
| Nodes | 16 (Root + 15) | On-chain account size is fixed; 16 covers realistic use cases |
| Edges | 80 | v5.4 cap (raised 48→80); ~80-recipient single-token payrolls |
| Outgoing per source | 24 | v5.4 (raised 8→24); keeps flush bounded per call |
| Conditions per edge | 4 | AND-combining more than 4 rarely changes outcome |
| Label bytes (UTF-8) | 32 | Stored on-chain for debugging |
Externals don't count toward MAX_NODES — a pact with 1 root and 20 partner wallets is valid.
Further reading
- docs/USER_GUIDE.md — dashboard walkthrough.
- docs/API.md — HTTP endpoints for custom backends.
- sdk-flow/README.md — 4 partnership
template builders (
createRevenueSplit,createMilestoneAgreement,createVestingAgreement,createRoyaltyWithCap). - splitpact-mcp-server/README.md — MCP server for AI agents.
- app-v4/docs/PROPOSAL_CONTRACT.md — exact proposal schema used by the HTTP API.