Client tooling for SplitStream, the pro-rata contributor payout vault on Stellar — a typed SDK over the deployed splitstream-core contract, and a terminal CLI for simulating, inspecting, claiming and reporting on a cycle's payouts.
Part of SplitStream — this repo is one of three:
| Repo | Role |
|---|---|
| splitstream-core | The Soroban vault contract that holds and settles the funds |
| splitstream-actions | The GitHub→chain bridge that computes each cycle's payout manifest and relays its Merkle root on-chain |
| splitstream-sdk-cli | This repo — the client SDK and CLI contributors claim with and maintainers simulate and report with |
Docs · Testnet Explorer · Contributing · Security
A sprint cycle closes, a pool of funds is owed to open-source contributors who closed issues, and someone has to work out who gets what — provably, reproducibly, and without a maintainer hand-assigning numbers. SplitStream settles that on-chain: a per-cycle Merkle root is posted to a Soroban vault by splitstream-actions, and each contributor claims their own share against it.
This repo is the client half of that system. It talks to the vault, reads the manifests the action writes, and never holds funds or private keys on your behalf.
@splitstream/sdk— a thin, typed client. It never signs:build*methods return unsigned transactions for you to sign with a wallet, a hardware device, or a locally stored testnet key. Browser-safe (nonode:crypto, no CLI deps).splitstream(CLI) — the operational cockpit:simulate,status,claim,report.
The manifest this tooling reads is written by splitstream-actions, and the vault it talks to is splitstream-core. The payout formula, Merkle leaf format, and manifest shape are a frozen cross-repo contract, not this repo's invention.
Requires Node.js >= 20.
npm install
npm run build # builds the SDK, then the CLI
npm test # runs both workspaces' vitest suitesRun the CLI straight from the build output:
node packages/cli/dist/index.js --helpnpm run typecheck type-checks both workspaces without emitting anything.
packages/
sdk/ @splitstream/sdk - typed vault client, Merkle proofs, amount math
cli/ splitstream-cli - commander-based terminal app (bin: splitstream)
Claiming is a two-step pull payment:
credit_claimproves the contributor's allocation against the cycle's Merkle root and credits their vault balance.withdrawthen moves it out.
They are always two separately signed transactions, and splitstream claim offers the second one explicitly rather than merging them.
Before signing, the CLI recomputes the Merkle root from the manifest's own rows. If it disagrees with the published merkleRoot it refuses to submit (the proof would fail on-chain) unless --force is passed, which is reported loudly.
A manifest is the contract between the action that publishes a cycle (splitstream-actions) and this tooling. It lives at manifests/cycle-<id>.json and is fetched from a local checkout, from GitHub at --ref, or read from --manifest. This is the real, current shape the action writes:
{
"cycleId": 4,
"generatedAt": "2026-09-10T00:00:00Z",
"poolAmount": "5000000000",
"totalIssuesClosed": 4,
"entries": [
{ "github": "octocat", "stellar": "GABCDEF...", "issuesClosed": 3, "amount": "3750000000" }
],
"dustRemainder": "3",
"merkleRoot": "hex-encoded-32-byte-root"
}entries— one row per contributor;stellaris theirG...account andissuesClosedis the count that produced the payout. There is noaddressorpointsfield.totalIssuesClosed— the payout denominator (the sum ofentries[].issuesClosed).amountandpoolAmount— decimal strings in base units; they becomebigintat parse time. JSON numbers are never used for token amounts.dustRemainder— the integer-division remainder left in the vault.merkleRoot— lowercase hex.rootis accepted only as a read-compatibility alias;merkleRootis the canonical field.
The rule is deliberately minimal, and it is the exact rule the deployed action uses. A qualifying issue is any issue closed via a merged pull request that contains a recognized closing keyword — Closes #N / Fixes #N / Resolves #N, case-insensitive — in one of the tracked repositories, inside the cycle window.
- No label of any kind is read. Complexity, type, size and
points:*labels are informational only and play no role in payout math. - Every qualifying issue counts equally: one issue, one share.
- Each distinct issue closed by a contributor's PRs increments their count exactly once — the same issue referenced by two of their PRs is not double-counted — and counts are summed across every repository before shares are computed.
- Credit goes to the PR author (the PR closes the issue; the PR author did the work), not the issue author.
Payouts use the frozen formula, in integer arithmetic only:
contributor_amount = floor(poolAmount * contributor_issues_closed / totalIssuesClosed)
The remainder that does not divide evenly is recorded as dustRemainder and stays in the vault, matching the on-chain behaviour the manifest records.
There are exactly two ways to sign, and neither of them is "paste a secret key into the terminal":
local— a keypair fromSPLITSTREAM_DEV_SECRET_KEY. It is labelledINSECURE, testnet-onlywherever it is printed, and it is hard-blocked on mainnet. It is never accepted from a flag or a config file.hardware— signing delegated to a Ledger device via the optional@ledgerhq/hw-app-strand@ledgerhq/hw-transport-node-hidpackages, which keeps the key off the machine. Ledger support is installed on demand; the CLI tells you to runnpm install --save-optional @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-strif the packages are missing.
A mainnet claim therefore requires a hardware wallet, by construction.
import { SplitStreamClient } from '@splitstream/sdk';
const client = new SplitStreamClient({
rpcUrl: process.env.SPLITSTREAM_RPC_URL!,
networkPassphrase: process.env.SPLITSTREAM_NETWORK_PASSPHRASE!,
vaultContractId: process.env.SPLITSTREAM_VAULT_CONTRACT_ID!,
tokenContractId: process.env.SPLITSTREAM_TOKEN_CONTRACT_ID!,
});
const balance = await client.getBalance(address); // bigint, base units
const root = await client.getCycleRoot(3); // lowercase hex, or null
const claimed = await client.hasClaimed(3, address);
const decimals = await client.getTokenDecimals(); // from the token contract
const tx = await client.buildClaimTx(address, 3, amount, proof); // unsigned
const signedXdr = await myWallet.sign(tx); // you sign it
const { hash } = await client.submitSigned(signedXdr);Reads: getBalance, getCycleRoot, hasClaimed, getVesting, getTokenDecimals, getReserveBalance.
Builds: buildClaimTx, buildWithdrawTx, buildClaimVestedTx.
The package also exports the Merkle proof helpers (buildClaimProof, verifyMerkleProof, computeMerkleRoot), amount formatting (formatTokenAmount, parseTokenAmount), manifest parsing (parseManifest) and contract-error decoding (SplitStreamError).
Every command supports -v, --verbose, which prints stack traces and raw RPC detail on failure. Commands that talk to the chain also accept --rpc-url, --network-passphrase, --vault and --token to override the environment.
Dry run over merged pull requests: reads GitHub, counts the distinct issues each contributor closed (the same rule the deployed action uses), and estimates the pro-rata payout plus the cost of the eventual post_cycle_root call. It never contacts RPC and never signs.
splitstream simulate --cycle 3 --pool 100000 --map handles.json
splitstream simulate --repo owner/name --repo owner/other --manifest manifests/cycle-3.json --json| Flag | Meaning |
|---|---|
--repo <owner/name> |
Repository to read merged PRs from (repeatable; defaults to the origin remote) |
--cycle <id>, --manifest <path> |
Cycle to simulate; a manifest supplies the pool and cycle id |
--ref <ref> |
Git ref to read manifests from (default main) |
--since <iso> |
Cycle window lower bound; defaults to the previous cycle manifest's generatedAt |
--pool <amount> |
Pool size in whole tokens (overrides the manifest) |
--decimals <n> |
Token decimals for display and --pool parsing (default 7) |
--map <path> |
JSON object of handle -> Stellar address |
--max-prs <n> |
Maximum merged pull requests to consider (default 300) |
--json |
Machine-readable output |
Generates the per-cycle transparency report as plain markdown (no embedded HTML), suitable for GitHub release notes or a PR description.
splitstream report --cycle 3 --manifest manifests/cycle-3.json
splitstream report --cycle 3 --stdout > report.md| Flag | Meaning |
|---|---|
--cycle <id> |
Required. Cycle to report on |
--manifest, --repo, --ref |
Where to read the manifest from |
--out <path> |
Output path (default SPLITSTREAM_REPORT.md) |
--concurrency <n> |
Parallel contract reads, 1-20 (default 5) |
--stdout |
Print the report instead of writing it to disk |
The report includes the cycle summary, a per-contributor allocation table, the dust remainder when the pool does not divide evenly, and a splitstream status snippet that reproduces every number.
The two-step pull payment described above — credit_claim then withdraw.
splitstream claim --cycle 3 --manifest manifests/cycle-3.json
splitstream claim --cycle 3 --repo owner/name --wallet hardware| Flag | Meaning |
|---|---|
--cycle <id> |
Required. There is no way to guess which cycle to claim |
--manifest, --repo, --ref |
Where to read the manifest from |
--contributor <address|handle> |
Who is claiming; defaults to the signing wallet |
--wallet local|hardware |
Signing method (defaults to a prompt) |
-y, --yes |
Skip the confirmation prompt |
--force |
Proceed even when the recomputed root disagrees with the manifest |
--no-withdraw |
Stop after credit_claim |
Vault reserve, recent cycle distributions and (with --contributor) a single contributor's balance, vesting and claimed/pending cycles.
splitstream status --contributor GABC...XYZ
splitstream status --cycles 10 --json| Flag | Meaning |
|---|---|
--contributor <G...> |
Contributor address to report on |
--cycle <id> |
Show a single cycle instead of a range |
--cycles <n> |
How many recent cycles to show (default 5) |
--manifest, --repo, --ref |
Where to read the last cycle's manifest from |
--json |
Machine-readable output |
Cycle ranges are anchored on the highest cycle id with a manifest in manifests/, so the contract does not need a call to discover them.
Copy .env.example to .env and fill it in — .env is loaded on startup and is never committed.
| Variable | Purpose |
|---|---|
SPLITSTREAM_RPC_URL |
Soroban RPC endpoint, e.g. https://soroban-testnet.stellar.org |
SPLITSTREAM_NETWORK_PASSPHRASE |
Must match the RPC endpoint's network |
SPLITSTREAM_VAULT_CONTRACT_ID |
Vault contract (C...) |
SPLITSTREAM_TOKEN_CONTRACT_ID |
Payout token contract (C...) |
SPLITSTREAM_DEV_SECRET_KEY |
Testnet-only, insecure local signing seed |
GITHUB_TOKEN |
Optional; raises simulate's GitHub rate limit |
SPLITSTREAM_CONFIG |
Optional path to a JSON config file |
Precedence is flags → environment → config file. Contract ids are validated as real Soroban ids (C..., including the StrKey checksum). simulate never touches the chain, so it needs none of the chain variables.
A JSON object, read from SPLITSTREAM_CONFIG, then ./splitstream.config.json, then ~/.config/splitstream/config.json:
{
"rpcUrl": "https://soroban-testnet.stellar.org",
"networkPassphrase": "Test SDF Network ; September 2015",
"vaultContractId": "C...",
"tokenContractId": "C...",
"githubToken": "..."
}A config file containing devSecretKey, secretKey, stellarSecret or a similar field is rejected outright: signing keys are never read from a file.
The leaf format is frozen and shared byte-for-byte with splitstream-core (which verifies it) and splitstream-actions (which builds it):
leaf = sha256( xdr_encode(ScVal(Address(stellar))) || xdr_encode(ScVal(i128(amount))) )
Address XDR (44 bytes): u32(SCV_ADDRESS=18) | u32(SC_ADDRESS_TYPE_ACCOUNT=0) | u32(publickey ED25519=0) | ed25519(32)
i128 XDR (20 bytes): u32(SCV_I128=10) | int64 hi | uint64 lo
Tree construction is a sorted-pair Merkle tree: at each level, children are paired left-to-right and concatenated in ascending byte order before hashing, and an unpaired node is promoted unchanged. Leaves are ordered by ascending Stellar public-key bytes (not by leaf hash, and not by manifest row order), so the root is reproducible from the manifest alone.
packages/sdk/test/merkleGolden.test.ts pins all of this against splitstream-actions' own committed golden fixture and a real-shaped manifest, not merely against this repo's internal consistency.
| Vault contract | CCC2LP2LOYZOLA2JW4C4K7JMR3TRJZIKHDSQYSFJ3R3MCDJLVBT3PZOC |
| Explorer | https://stellar.expert/explorer/testnet/contract/CCC2LP2LOYZOLA2JW4C4K7JMR3TRJZIKHDSQYSFJ3R3MCDJLVBT3PZOC |
| Network | Test SDF Network ; September 2015 (Testnet) |
These are deliberate, documented decisions — each is called out where it bites:
- No
versionfield and notokenDecimalsin the manifest. Token decimals are a property of the SEP-41 token contract, so they are read from the chain at runtime withSplitStreamClient.getTokenDecimals()rather than being asserted by a data file that cannot verify them. - Signing keys are never read from a flag or a config file. A config file containing
devSecretKey,secretKey,stellarSecretor a similar field is rejected outright, andlocalsigning is hard-blocked on mainnet — so a mainnet claim requires a hardware wallet, by construction. credit_claimandwithdraware never merged into one transaction. They are two separately signed calls, andclaimoffers the second explicitly.claimrecomputes the Merkle root before signing and refuses to submit when it disagrees with the publishedmerkleRoot, because the proof would fail on-chain.--forceoverrides this and is reported loudly.merkleRootis the canonical field;rootis accepted only as a read-compatibility alias.simulatenever touches the chain and needs none of the chain variables.
Contributions are welcome — see CONTRIBUTING.md for the build/test workflow and PR expectations, and SECURITY.md for the security model and responsible-disclosure process. Found a bug or have a feature idea? Open an issue.
- 💬 GitHub Issues — bug reports, feature requests, and design discussion
- 🔒 Security — report vulnerabilities privately per SECURITY.md
- 🗣️ Discord — questions, help and release chatter in the SplitStream server
✈️ Telegram — the same conversation in the SplitStream group
Oyinkans0la12 Smart Contract Engineer GitHub |
Contact
GitHub Issues — primary channel for bugs, feature requests, and design discussion 🔒 For vulnerabilities, use a private security advisory per SECURITY.md |
This project is licensed under the MIT License — see LICENSE for details.