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%. This is a slice of the source node's current holding at firing time, not of the original deposit. Edges from the same source fire in id-ascending order per flush, so later edges see what earlier ones left behind.Concrete example: Root has 100 USDC. Two outgoing edges, both to externals:
edge 0: shareBps = 5000 → takes 50% of 100 = 50 USDC to Alice edge 1: shareBps = 10000 → takes 100% of the REMAINING 50 = 50 USDC to BobThat's a clean 50/50. If you'd set both edges to
5000the second one would only move 25 USDC (50% of 50), and 25 USDC would stay in Root. -
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 five condition kinds. 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 |
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:
- For each outgoing edge in ascending
idorder:- Evaluate conditions. If any fails, skip.
- Compute
amount = source.holding * shareBps / 10000(floor). - 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
source.holdingbyamount, bump edge'slifetimeOutflow.
Because share is a fraction of the current holding, ordering matters. See the 50/50 example above.
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.
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 | 48 | 16 × 3 average fan-out, with slack |
| Outgoing per source | 8 | 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.