Prove you can pay. Without showing what you have.
| 🌍 Live app | proofmatch-stellar.fly.dev — real Groth16 proofs, real Stellar testnet transactions |
| 🎥 Demo video | youtu.be/jBYiM3C75dM |
| 𝕏 Follow | @zkproofmatch |
| 📖 Docs | proofmatch-stellar.fly.dev/docs.html — architecture, how proofs work, contributing guide |
| 📚 API docs | proofmatch-stellar.fly.dev/api.html |
| 🏆 Leaderboard | proofmatch-stellar.fly.dev/leaderboard.html |
Built for the Stellar ZK Hackathon (DoraHacks).
Every day, people are asked to prove they have enough money — to rent an apartment, close a P2P trade, qualify for a loan, join a token-gated community. The only way to do it today is to hand over everything: bank statements, account numbers, transaction history. In crypto it's worse — sharing a wallet address to prove one balance exposes your entire on-chain financial life, forever.
The person asking never needed the number. They needed one bit: "is it enough?"
ProofMatch turns "show me your balance" into a single shareable link. You
pick a threshold tier (≥ 10, ≥ 100, ≥ 1K … XLM or USDC); a zero-knowledge
proof is generated that your Stellar wallet clears it; a Soroban smart
contract verifies the proof on-chain; you share …/v/abc123. Whoever opens
it sees one verified fact — "holds ≥ 1K XLM" — and nothing else. Not the
balance, not the history, not even (unless you opt in) which wallet.
- Solvency proof — "holds ≥ X right now." Groth16 over BLS12-381, verified on-chain. The balance stays a private witness.
- 7-Day Streak proof — "held ≥ X every day for 7 consecutive days." A second circuit (973 constraints) proves all 7 daily balances clear the threshold, defeating borrow-prove-return gaming of snapshot proofs.
- Privacy mode — the proof is generated in your browser (snarkjs in a Web Worker); your balance never reaches the server at all. Only the 192-byte proof is transmitted.
- Multi-wallet aggregation — prove combined holdings across 2–3 wallets.
- Wallet ownership verification — with Freighter connected, the wallet signs a single-use SEP-53 challenge, so the link also proves the presenter controls the wallet, not just that it exists.
- On-chain proof registry — every proof commitment is permanently anchored in a second Soroban contract, so links can be independently corroborated on-chain.
- Embeddable — a 3-line widget (
ProofMatch.verify({...})), an npm SDK (@stellar-zk/proofmatchinsdk/), and a REST API for integrators. - Shareable verification pages with OG cards, QR codes, a public leaderboard, and a local ZK trust score.
The core statement is a Groth16 zk-SNARK over BLS12-381:
I know a private
balancesuch thatbalance ≥ threshold, wherethresholdis public.
- The circuit (
proof_of_funds.circomin proofmatch-circuits) enforces the comparison as a hard constraint — if the balance doesn't meet the threshold, no valid witness exists and proving fails by construction. There is no "false" result to forge. - Both inputs are range-checked to 64 bits via
Num2Bits(64)(193 constraints total), so values ≥ 2⁶⁴ are rejected at the constraint level. - The proof (192 bytes: π = A, B, C) is verified on-chain by a Soroban
contract using Stellar's native
bls12_381_multi_pairing_checkhost function — the verifier learns exactly one bit. - BLS12-381 (not the more common BN254) was chosen specifically because Soroban ships native pairing host functions for it — the same path Stellar's own engineering used for Stellar Private Payments (~41M CPU instructions per pairing check against a 100M budget).
Why Groth16, not Noir? The hackathon's Noir resource states on-chain UltraHonk verification is "right on the borderline of CPU instruction limits" and requires a quickstart image with budget limits disabled — not deployable to real testnet today. Groth16/BLS12-381 runs comfortably within real network limits.
| Repo | Role |
|---|---|
| proofmatch-circuits | Circom circuits (Groth16 / BLS12-381) + trusted setup + proof formatting |
| proofmatch-contracts | Soroban verifier + registry contracts deployed on Stellar testnet |
| proofmatch-app (this) | Web app, REST API, and SDK — the user-facing product |
How they communicate:
- circuits → app: this repo vendors the compiled runtime artifacts
(
circuits/*_js/witness generators,*_0001.zkeyproving keys, verification keys) built from proofmatch-circuits. - app → contracts: every proof is submitted to the deployed verifier
contract (
verify_proof), and its commitment is anchored in the registry contract (store) — both via thestellarCLI on testnet. Contract IDs are configurable viaCONTRACT_ID/REGISTRY_CONTRACT_IDenv vars. - circuits → contracts:
proof_to_cli_args.js(vendored inscripts/) converts snarkjs output into the BLS12-381 host-type hex format the verifier contract expects.
Stellar Horizon API
│ (real balance, never displayed)
▼
balance, threshold → Circom circuit → Groth16 proof (snarkjs, BLS12-381)
(server-side, or in-browser Web Worker in Privacy mode)
│
▼
Soroban verifier contract (Stellar testnet)
bls12_381_multi_pairing_check host function
│
▼
registry contract (commitment anchored on-chain)
│
▼
/v/:id — shareable proof link
- Frontend
web/frontend/— React 19 + TypeScript (strict) + Tailwind v4 + shadcn/ui, built with Vite intoweb/public/(generated output, not committed). Privacy-mode proving runs in a Web Worker (web/frontend/public/prove-worker.js) via snarkjs. - Backend
web/server.js— a single Express server: serves the built frontend, exposes the REST API, fetches balances from Horizon, generates proofs server-side, and invokes the contracts via thestellarCLI. - Circuits — authored in
proofmatch-circuits;
compiled artifacts vendored under
circuits/. - Contracts — the Groth16 verifier and proof registry live in
proofmatch-contracts
(verifier forked unmodified from Stellar's official
soroban-examples/groth16_verifier).
web/frontend/ React + TS source (Vite project — this is what you edit)
web/frontend/public/ passthrough files copied verbatim into every build:
widget.js, prove-worker.js, leaderboard/verify/api pages
web/public/ build output served by Express (gitignored — run a build)
web/server.js Express API + static server + /v/:id share-link renderer
circuits/ vendored proving artifacts from proofmatch-circuits
scripts/ proof_to_cli_args.js (snarkjs → Soroban hex format)
sdk/ @stellar-zk/proofmatch npm package (embeddable SDK)
Requirements: Node.js 22+, snarkjs, stellar-cli 27+, a stellar keys
identity funded on testnet. (circom is only needed if you rebuild the
circuits — see proofmatch-circuits.)
web/public/ is generated build output and is not committed — on a fresh
clone you must build it once before the server will start:
npm ci # server deps (express, stellar-sdk)
npm ci --prefix web/frontend # frontend deps (react, vite, tailwind)
npm run build # vite build → web/public/
npm start # → http://localhost:3000The server runs startup self-checks before binding: missing build output,
an unwritable proof store, or malformed contract IDs refuse to start with an
actionable report; a missing stellar/snarkjs CLI or unconfigured deployer
identity starts in degraded mode (pages and share links work, proof
generation doesn't) and is surfaced on /api/health. Probe an environment
without starting the server:
node web/server.js --check # human-readable report, exit 0/1
node web/server.js --check --json # machine-readable (CI / healthchecks)Real wallet mode — connect Freighter or enter any Stellar G… address, pick a tier (XLM or USDC). The balance is fetched live from Horizon, never displayed, and used only in-memory to generate a witness in a per-request temp directory. Hit "Use demo" to pre-fill the project's own funded testnet account and see a real proof in one click.
After a successful proof, a shareable /v/:id URL is returned. Proofs
are stored for 1 year (the registry contract anchors the commitment on-chain
with a matching TTL).
npm start # Express API on :3000 (keep running)
npm run dev:frontend # Vite dev server on :5173 with HMR,
# proxying /api, /circuits, /v to :3000Edit web/frontend/src/ with instant reload; run npm run build to refresh
what :3000 serves. The Docker image builds the frontend itself in a
multi-stage build, so fly deploy needs no local build step (see DEPLOY.md).
Health check
curl http://localhost:3000/api/health
# → { ok: true, checks: { horizon: "ok", proofsDir: "ok" }, uptimeSeconds: ... }Verify a share link (for third-party integrators — no ZK stack required)
curl -X POST http://localhost:3000/api/verify-link \
-H "Content-Type: application/json" \
-d '{ "url": "http://localhost:3000/v/abc12345" }'
# → { ok: true, verified: true, threshold: "50000000", asset: "XLM",
# txHash: "...", txExplorerUrl: "https://stellar.expert/..." }Full endpoint reference: /api.html.
Add ZK proof verification to any website in three lines:
<script src="https://your-server/widget.js"></script>
<script>
ProofMatch.verify({
minAmount: 5,
asset: "XLM",
onVerified: (result) => {
console.log("Verified!", result.displayAmount, result.shareUrl);
},
onFail: (reason) => console.log("Not verified:", reason),
});
</script>The widget opens a self-contained modal, runs the full proving pipeline
against Stellar testnet, and returns the result via callback. CORS is enabled
on all /api/* routes. Bundled apps can use the npm SDK in sdk/ instead.
- Circuit demo, edge-case tests, recompilation, trusted setup →
proofmatch-circuits.
After rebuilding, copy the new
*_js/,*_0001.zkey, and verification-key artifacts into this repo'scircuits/. - Contract tests and redeployment →
proofmatch-contracts.
Point this app at a new deployment via
CONTRACT_ID/REGISTRY_CONTRACT_IDenv vars. - Frontend type-check + build:
npm run build(runstsc --strictthenvite build).
Real, end to end:
- Groth16 proofs are actually generated (snarkjs) and actually verified on-chain by the deployed Soroban verifier — every proof is a real Stellar testnet transaction you can open on Stellar Expert.
- Balances are fetched live from Horizon, never self-reported (outside the clearly-labeled demo tab).
- Proof commitments are really anchored in the registry contract.
- Privacy mode really proves in the browser — watch the network tab: only the proof leaves the page.
- SEP-53 ownership signatures are really verified server-side (ed25519).
Mocked, simplified, or honestly unfinished:
- "Demo numbers" tab is self-reported — it demonstrates the ZK mechanics only, and is labeled as such in the UI.
- The circuit can't verify where the balance came from. It proves
balance ≥ thresholdfor a private witness; the binding to a real Horizon balance is enforced by the server fetch (or your own browser in privacy mode), not cryptographically inside the circuit. A full fix — hashing a signed balance attestation in-circuit — is deliberate follow-up work: circomlib's Poseidon/EdDSA hardcode BN128 constants and can't run on BLS12-381 without regenerating them. - 7-day history is reconstructed, not snapshotted — daily balances come
from walking Horizon
effectsbackwards; it trusts Horizon's history and runs server-side. - Ownership verification is opt-in, not enforced — anyone can still generate a proof for a bare address; the verify page shows a clear warning when ownership wasn't attested.
- Single-contribution trusted setup — fine for a hackathon, not production. A real deployment needs a multi-party ceremony.
Groth16 verifier — checks the ZK proofs on-chain:
CB33TGWZROSRQRKFVZGZV74SKFGSDC6HOEN366PF2S2WD7ESRCHIR6VB
https://lab.stellar.org/r/testnet/contract/CB33TGWZROSRQRKFVZGZV74SKFGSDC6HOEN366PF2S2WD7ESRCHIR6VB
Proof registry — permanently anchors proof commitments
(store / exists / get — source in
proofmatch-contracts):
CCB7YWB42PZNXNCQVAUNX7LWYDEAAJ6YSLMCK6LPDDNMPMQEMDTMZGKL
https://lab.stellar.org/r/testnet/contract/CCB7YWB42PZNXNCQVAUNX7LWYDEAAJ6YSLMCK6LPDDNMPMQEMDTMZGKL
MIT — see LICENSE.