Private rotating savings circles on Stellar — the ajo / tanda / susu / tontine, with the payout anonymized by a real Groth16 zero-knowledge proof, verified on-chain.
ajo · esusu · tanda · cundina · susu · tontine · junta · pandero · consórcio · hui · paluwagan · chit fund
Five members fund a shared pot. One member claims it — by proving "I'm a genuine, un-paid member of this circle" without revealing which member. The proof is generated in the browser and verified by a Soroban contract using Stellar's native BLS12-381 pairing host functions. No mock. No stub. No trusted server.
Built for the Stellar Hacks: Real-World ZK hackathon. Testnet only, no real funds.
▶ demo video · 🚀 live app (testnet) · 📖 full product breakdown · 🛠 build log · 🤝 contributing
Every claim below was produced by running this repo against live Stellar testnet infrastructure. Nothing is asserted from a test double.
| What | Where |
|---|---|
| Sharibo contract | CB64IZIBBSPUY63UMIVACKWDKRFNH6WJ2EPAOLM7QR4ZI6IJOT4N2LCF |
| Test token (native XLM SAC) | CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC |
create_circle (circle 0) |
tx fa76e7fe7439199796db55fdde4bcaaad2cb6a98c0f29214d00605f40ca8fdb0 |
| Real Groth16 proof accepted on-chain | tx 2258397474e3ad420d6dd8310cb0976d270c29ec4a4ec2b60a9ae58408088087 — successful: true, ledger 3379702 |
| Tampered proof rejected | Error(Contract, #5) InvalidProof — the pairing check genuinely fails |
| Nullifier replay rejected | Error(Contract, #4) AlreadyClaimed — reproduced every run by npm run e2e |
No toolchain needed — just curl.
1. The accepted proof is a real, successful testnet transaction:
curl -s https://horizon-testnet.stellar.org/transactions/2258397474e3ad420d6dd8310cb0976d270c29ec4a4ec2b60a9ae58408088087 | grep -E '"successful"|"ledger"'
# → "successful": true, "ledger": 33797022. The contract is live and holds real circle state (requires stellar CLI):
stellar contract invoke \
--id CB64IZIBBSPUY63UMIVACKWDKRFNH6WJ2EPAOLM7QR4ZI6IJOT4N2LCF \
--network testnet -- get_circle --circle_id 0
# → Circle { root, contribution, size: 5, round: ≥1, ... } — round ≥ 1 means a real claim has already succeeded3. Or view it in the explorer: contract on stellar.expert — five deposits in, one payout out, to an address that appears nowhere else in the circle.
To reproduce everything from source (circuit build → trusted setup → tests → full e2e round), see Run it.
The standard Circom/Groth16 stack targets the BN254 curve. On Soroban, we measured a single BN254 pairing (pure-Rust ark-bn254, per Stellar's own import_ark_bn254 example) at ~560 million CPU instructions — against a hard 100 million per-transaction cap. Not expensive: impossible.
So Sharibo runs the entire pipeline — circuit, trusted setup, Poseidon parameters, contract, client encoding — on BLS12-381, the curve Stellar accelerates natively (env.crypto().bls12_381().pairing_check(...)). A real claim() with a real proof costs 48.0M / 100M instructions (~48%) — measured, not estimated. That required sourcing Poseidon round constants generated for the BLS12-381 scalar field (cross-checked against soroban-sdk's own BLS12_381_FR_MODULUS_BE) and byte-exact wire formats across circuit ↔ contract ↔ client. Full story: breakdown §6 and §14.
The claimant proves, in zero knowledge:
- Membership —
Poseidon(identityNullifier, identitySecret)is a leaf under the circle's committed Merkle root (which member: hidden). - One claim per round — a nullifier bound to
(circle_id, round); the contract records it, so replay fails withAlreadyClaimed. - Unlinkability — the pot pays out to any address the claimant chooses; in the demo, a keypair that has never touched the circle.
An on-chain observer sees five deposits and one payout — and no way to connect them.
Full structured breakdown — assets, adversaries, and which code enforces each property (and its limits) — in docs/threat-model.md.
- Claim-side privacy only. Funding is fully public, by scope: shielded deposits are a different (harder) problem — roadmap.
- One round demoed, not a full multi-round rotation with on-chain turn ordering.
- Testnet + test token; single-party trusted setup (fine for a demo, not production).
- Poseidon-over-BLS12-381 constants come from a third-party package — modulus cross-checked against Soroban's own constant and structurally reviewed (8 full + 56 partial rounds, x⁵ S-box), but not independently audited. See canonical details in docs/poseidon-provenance.md.
- Nothing is silently faked; every simplification is disclosed here, in code comments, and in NOTES.md. Details: breakdown §18.
| Suite | Coverage | Result |
|---|---|---|
Circuit (circuits/test/) |
valid proof, wrong root, tampered path, nullifier determinism, non-boolean path index | 5/5 |
Contract (contracts/sharibo/src/test.rs) |
happy path with a real proof, underfunded, replay, stale round tag, forged public input (real pairing failure), CPU budget, auth ×2 | 8/8 |
E2E (scripts/e2e.ts, live testnet) |
create → 5× fund → prove → claim to fresh address → assertions → round 2 fund → replay → AlreadyClaimed |
passing |
flowchart LR
%% Browser / Client Layer
subgraph Browser ["Browser Application (packages/client)"]
direction TB
Identities["Member Identities<br/>(Nullifier & Secret)"]
MerkleTree["Merkle Tree Generation<br/>(tree.ts)"]
subgraph Proving ["Proof Generation (prove.ts)"]
direction LR
Circuit["circuits/membership.circom"]
SnarkJS["snarkjs"]
Wasm["WASM Witness Generator"]
Zkey["Proving Key (.zkey)"]
Circuit --> SnarkJS
Wasm --> SnarkJS
Zkey --> SnarkJS
end
Identities --> MerkleTree
MerkleTree -.-> |Inclusion Proof Inputs| Proving
end
%% Network / On-chain Layer
subgraph Network ["Soroban Testnet"]
direction TB
Contract["Soroban Contract<br/>(contracts/sharibo)"]
Pairing["BLS12-381 Pairing Verification<br/>(env.crypto)"]
Contract --> Pairing
end
%% Recipient Layer
subgraph Recipient ["Recipient Address"]
Wallet["Fresh Recipient Wallet"]
end
%% Data Flow Transitions
Proving --> |"claim(circle_id, recipient, ...)"| Contract
Pairing --> |"Success (Verification Passes)"| Wallet
Wallet --> |Payout Transferred| Payout(("Payout"))
style Browser fill:#f5faff,stroke:#1d3557,stroke-width:2px
style Network fill:#fff3e0,stroke:#e65100,stroke-width:2px
style Recipient fill:#f1f8e9,stroke:#33691e,stroke-width:2px
style Payout fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
create_circle(admin, token, root, contribution, size, vk) -> circle_id
│ root = Merkle root of Poseidon(identityNullifier, identitySecret)
│ for every member, computed off-chain by the client
▼
fund(circle_id, from) ×5
│ from.require_auth(); pot += contribution
▼
claim(circle_id, recipient, nullifier_hash, external_nullifier, proof)
│ 1. pot == contribution * size ("round not funded")
│ 2. external_nullifier == SHA256(circle_id, round) mod r ("wrong round tag")
│ 3. nullifier_hash unused for (circle_id, ·) ("already claimed")
│ 4. real Groth16 / BLS12-381 pairing check passes ("invalid proof")
│ → mark nullifier used, pay pot to recipient, pot=0, round+=1
Circuit: circuits/membership.circom. Contract: contracts/sharibo/src/lib.rs. Client SDK: packages/client/. E2E script: scripts/e2e.ts. Browser demo: app/.
- BLS12-381 throughout — not the more common BN254/bn128. Stellar's Soroban host only accelerates BLS12-381 pairing operations; a pure-Rust BN254 pairing check measured ~560M CPU instructions against a 100M budget (see
NOTES.md), so BN254 verification doesn't fit at all. This is the single biggest deviation from a "default" ZK stack and is documented in detail inNOTES.md. - Commitment:
leaf = Poseidon(identityNullifier, identitySecret). - Nullifier:
nullifierHash = Poseidon(identityNullifier, externalNullifier)— Poseidon is used here and for the Merkle tree because it's cheap inside the circuit's constraint system. - Round tag:
externalNullifier = SHA256(circle_id, round) mod r— not Poseidon. This binding happens outside the circuit (in the contract and in the client, not inside the SNARK), where Soroban has a native accelerated SHA-256 and no native Poseidon at all, so nothing is gained by matching the circuit's hash choice there. Deliberate and permanent, not a placeholder — seeNOTES.md. - Public signal order:
[nullifierHash, root, externalNullifier](circuit output first, then declared public inputs, in that order) — this is what circom/snarkjs actually emit, not the[root, externalNullifier, nullifierHash]a naive reading might assume. Circuit, contract, and client all agree on this order. - Field: BLS12-381 scalar field throughout (client, contract, circuit).
Fresh-machine steps, in order. Everything below targets Stellar testnet only.
| Tool | Minimum | Tested |
|---|---|---|
Rust + wasm32v1-none target |
rustc 1.56.0 (edition 2021) | rustc 1.92.0 |
stellar CLI |
v21.0 (protocol 22 required for BLS12-381 host functions; protocol 23 for soroban-sdk = "23") |
23.4.1 |
| Node.js | 20.6.0 (process.loadEnvFile, used in scripts/e2e.ts) |
v24.11.1 |
circom on PATH |
2.1.6 (pragma in circuits/membership.template.circom) |
2.2.3 (built from source) |
snarkjs (0.7.6) is a devDependency in circuits/package.json — no separate global install required; it runs via npx during npm run setup.
Install the Rust target after installing Rust:
rustup target add wasm32v1-nonenpm install # installs the whole workspace (circuits, packages/client, scripts, app)
cp .env.example .env # fill in ADMIN_SECRET_KEY / MEMBER_SECRET_KEY etc.
stellar keys generate admin --network testnet --fund
stellar keys generate member --network testnet --fund
stellar keys show admin # paste into .env as ADMIN_SECRET_KEY / ADMIN_PUBLIC_KEY
stellar keys show member # paste into .env as MEMBER_SECRET_KEY / MEMBER_PUBLIC_KEYcd circuits
npm run compile # circom --prime bls12381 -> build/membership.{r1cs,sym}, build/membership_js/membership.wasm
npm run setup # Powers-of-Tau (bls12381) + Groth16 zkey -> verification_key.json (committed)
npm run prove # proves + verifies circuits/input.example.json locally
npm test # circom_tester suite: valid proof, wrong root, tampered path, nullifier determinism, boolean checks
cd ..cd contracts
cargo test # 8/8: happy path (real proof!), underfunded, double-claim, stale round tag,
# tampered-proof rejection, CPU budget, both auth checks
stellar contract build
stellar contract deploy --wasm target/wasm32v1-none/release/sharibo.wasm --source admin --network testnet
cd ..
# paste the returned contract id into .env as SHARIBO_CONTRACT_IDA test token is needed for the pot — the simplest option on testnet is the native asset:
stellar contract id asset --asset native --network testnet
# paste into .env as TEST_TOKEN_CONTRACT_IDnpm run smoke # checks RPC, Horizon, and circle 0
npm run smoke -- --circle-id 3 # check a specific circleA fast, read-only probe that verifies your deployment is healthy: hits the Soroban RPC health endpoint, the Horizon root, and reads a circle from the contract. No transactions, no keys needed beyond .env contract IDs. Useful after a testnet reset, before a demo, or as a contributor's first successful command.
npm run e2e # full run (default)
npm run e2e -- --skip-replay # stop after the successful claim
npm run e2e -- --reuse-circle 0 # skip circle creation, run against existing circle 0
npm run e2e -- --verbose # echo each RPC/curl interaction
npm run e2e -- --skip-replay --verbose # combine flags freelyRuns a full round against testnet for real: creates a 5-member circle, funds it from 5 fresh friendbot-funded accounts, generates a real Groth16 proof for one member, claims the pot to a freshly generated recipient address, asserts the payout/round-advance, then funds a second round and asserts that replaying the same proof's nullifier is rejected on-chain with AlreadyClaimed.
Flags (node:util parseArgs, no new deps):
| Flag | Effect |
|---|---|
--skip-replay |
Stop after the successful claim (skip round 2 funding + replay check) |
--reuse-circle <id> |
Skip circle creation; run against an existing circle |
--verbose |
Echo each RPC/curl interaction for debugging |
This script shells out to
curlfor friendbot/Horizon calls rather than usingfetch()— seeNOTES.mdif you're curious why. Run it in the foreground (not backgrounded) for the same reason.
cd app
cp .env.example .env # same contract/token ids as above, VITE_-prefixed
npm run dev # runs `sync-circuit` first (copies circuits/build/* into app/public/circuits/)Open the printed localhost URL. The whole flow (identities, funding, proving, claiming) runs against real testnet from a single browser tab.
The Merkle tree depth (levels) is single-sourced in circuits/config.json — a 4-level tree fits up to 16 members, a 5-member circle fits depth 3, a 100-member circle needs depth 7, etc. Everything that needs the depth (the circuit, the circuit tests, and the client SDK's TREE_LEVELS) reads it from that one file; circuits/membership.circom itself is a generated file (see circuits/scripts/gen-circuit.cjs) and is gitignored — the committed source is circuits/membership.template.circom.
To change the depth:
- Edit
circuits/config.json({"levels": N}). - Recompile:
cd circuits && npm run compile(this regeneratesmembership.circomfrom the template + new config, then runscircom). - Re-run the trusted setup — this is not optional. A different
levelsvalue changes the circuit's constraint system, which means a new zkey and verification key:npm run setup. The oldverification_key.jsonno longer matches the circuit and must be regenerated/recommitted. - Sanity-check:
npm test(circuit test suite) andnpm run prove(proves + verifiescircuits/input.example.json— note:input.example.json'spathElements/pathIndicesarrays must also be regenerated for the new depth). - Redeploy affected circles with the new verification key. Any circle created against the old vkey needs a fresh contract deployment (or an admin vkey-rotation path if the contract supports one) — proofs generated against the old tree depth will not verify against the new vkey, and vice versa. There is no in-place migration for open circles across a depth change.
- If the browser app is deployed, re-run
npm run sync-circuit(inapp/) to pick up the newmembership.wasm/membership_final.zkey/verification_key.json.
sharibo/
├── circuits/ membership.template.circom (source) + config.json, compile/setup/prove scripts, circuit tests, verification_key.json
├── contracts/sharibo/ the Soroban contract (lib.rs) + its test suite (test.rs)
├── packages/client/ isomorphic TS SDK: identity.ts, tree.ts, prove.ts, contract.ts, config.ts
├── test-vectors/ cross-implementation Poseidon fixtures shared by the client and circuit test suites
├── scripts/e2e.ts full-round Node script against live testnet
├── scripts/smoke.ts read-only deployment health check (no transactions)
├── app/ React + Vite browser demo
├── README.md this file
├── NOTES.md the raw build/decision log — what was discovered, when, and why
├── full_product_breakdown.md every facet of the system, in detail
└── hackathon_demo_script.md demo video script (motion + voiceover)
Full annotated version (what each file does and why): breakdown §16.
We welcome contributions to Sharibo! See CONTRIBUTING.md for the development workflow, how to run the test suites, and the dependency audit runbook. Please ensure you have read and adhere to our Code of Conduct when participating in this project.
@stellar/stellar-sdk is declared independently in app, packages/client, and scripts, and pinned to a single resolved version via a root overrides entry. Bump stellar-sdk in all three places at once — npm run check:stellar-sdk (also run automatically on npm install) fails the build if the declared ranges ever drift apart.
- Funding-side shielding (hide who funded, not just who claimed).
- Multi-round automation / on-chain turn ordering.
- Multi-party trusted setup ceremony.
- Independent audit of the BLS12-381 Poseidon parameters (or a switch to self-generated / better-provenanced constants).
- Real stablecoin (issued test asset or mainnet equivalent) instead of native testnet XLM.
- Selective disclosure ("view key") — an admin/auditor could prove a circle's total historical contributions (a sum over funding events already visible on-chain) without exposing which individual funded which round. Not built; the shape is in breakdown §19.