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%. 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 Bob
    

    That's a clean 50/50. If you'd set both edges to 5000 the 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.

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

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. For each outgoing edge in ascending id order:
    1. Evaluate conditions. If any fails, skip.
    2. Compute amount = source.holding * shareBps / 10000 (floor).
    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 source.holding by amount, bump edge's lifetimeOutflow.

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'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
Edges4816 × 3 average fan-out, with slack
Outgoing per source8Keeps 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