Self-sovereign did:stellar: decentralized identity on Soroban β real Groth16 zero-knowledge credentials (age, KYC tier, membership) that prove a claim on-chain without revealing the private data behind it, plus a soulbound reputation (SBT) framework gated on real verification.
π Try the live demo β wired to the real deployed testnet contracts listed under Deployment, not a mockup.
- Why this is real zero-knowledge, not a buzzword
- Architecture
- What's built
- Deployment
- Usage
- Quick start
- Ecosystem
- Contributing
- License
- Genuine Groth16 proofs verified on-chain, not simulated. Three deployed
zk_verifierinstances perform real BN254 pairing checks via Soroban Protocol 25's nativeenv.crypto().bn254()host functions β proving age β₯ 18, KYC tier β₯ N, or Merkle-tree membership without revealing the underlying private data. - A real trusted-setup pipeline, not hand-crafted bytes. Every verification key comes from an actual circom β snarkjs Powers-of-Tau β phase 2 β contribution β export run β see
circuits/README.mdfor the exact reproducible steps. - A hard technical call made and documented, not glossed over. The original circuits were written in Noir, whose default proving system needs a BN254+Grumpkin curve cycle Soroban doesn't support. Rather than ship something that couldn't actually verify on-chain, the circuits were rebuilt in Circom/Groth16 β see Deployment for why that's a real, working tradeoff, not a downgrade.
- Classical and zero-knowledge verification are kept honestly separate.
credential_verifier's Merkle-inclusion check is explicitly labeled as not zero-knowledge (the leaf is derived from the caller's real address) β real ZK privacy lives specifically inzk_verifier, so the two capabilities are never conflated.
+----------------------------+
| did_registry |
+----------------------------+
(register / update / resolve DID documents)
Classical credential path:
+----------------------------+
| asp_registry |
| (Merkle root |
| per ASP) |
+----------------------------+
| get_merkle_root()
v
+----------------------------+
| credential_verifier |
| (Merkle inclusion -- |
| NOT zero-knowledge) |
+----------------------------+
| has_credential()
v
+----------------------------+
| reputation_nft |
| (soulbound, gated |
| on real verification) |
+----------------------------+
Real zero-knowledge path (separate from the above):
+----------------------------+
| Circom circuit + |
| Groth16 proof |
| (generated off-chain) |
+----------------------------+
| vrfy_prf()
v
+----------------------------+
| zk_verifier |
| (one instance per |
| circuit: age_proof, |
| kyc_tier_proof, |
| membership_proof -- |
| real BN254 pairing |
| check) |
+----------------------------+
Status of each piece, so anyone reading knows exactly what's real, what's tested, and what's classical vs. genuinely zero-knowledge.
contracts/did_registry β real
Full DID document CRUD: register, add verification keys, update, deactivate, resolve. No shortcuts.
contracts/reputation_nft β real, gated by a verified credential
mint() used to accept an admin's say-so alone β it stored credential_verifier's address at initialize() but never called it, so reputation could be minted for anyone regardless of whether they'd ever verified anything. It now calls credential_verifier.has_credential(subject, credential_type) for real (via a raw env.invoke_contract, not a crate dependency β see credential_verifier's own note on why) and rejects the mint if that comes back false. get_reputation() and a soulbound transfer() that correctly always reverts (non-transferable by design) are unchanged.
contracts/asp_registry β real
Stores a Merkle root per registered Attestation Service Provider, plus get_merkle_root() for other contracts to read it. Actually consumed by credential_verifier β see below.
contracts/credential_verifier β real Merkle membership verification, not a ZK proof
verify_proof() performs genuine cryptographic verification: it computes a leaf as sha256(b"zkident:credential-leaf:v1:" || strkey(user) || credential_type), walks a caller-supplied sibling path up to a root, and rejects unless that root matches the ASP's currently registered root β fetched directly from asp_registry (stored at initialize(), never taken as caller input, so a proof can't be checked against an attacker-controlled fake registry). This proves on-chain that user is one of the leaves a specific ASP committed to. It is not zero-knowledge: the leaf is derived from the caller's real address, so membership is not hidden. What matters is that has_credential() can no longer be made true by submitting an arbitrary string; it now requires a real path to the ASP's real root. Genuine zero-knowledge verification is a separate, real capability β see contracts/zk_verifier below.
contracts/zk_verifier β real, genuine zero-knowledge verification, deployed three times over
Three real Groth16 BN254 verifier instances (reusing stellar-zkstream's already-proven verifier contract unmodified), one per real circuit in circuits/: proving age β₯ 18, KYC tier β₯ N, and Merkle-tree membership β each without revealing the underlying private data (birth date, actual tier, or which leaf/path). This is a real, complete Groth16 trusted-setup pipeline (circom β snarkjs powers-of-tau β phase 2 β contribution β export), not hand-crafted bytes β see circuits/README.md. 12 tests pass, including 3 that feed real generated proofs for these exact circuits through the real contract's vrfy_prf() and 2 that confirm a tampered public input is correctly rejected.
circuits/ β real Circom circuits, rebuilt from the original Noir source
Originally written in Noir, which defaults to the UltraHonk proving system β that needs a BN254+Grumpkin curve cycle Soroban has no native support for (there's an active, unfinished official proposal to build this; see circuits/README.md). Rebuilt in Circom/Groth16 instead, which only needs the BN254 pairing checks Soroban already supports natively β the same approach stellar-zkstream already proved works end to end.
All seven contracts are live on Stellar testnet (core four deployed/redeployed 2026-09-05, the three ZK verifiers deployed 2026-09-05 β see deployments/testnet.json β independently checkable on stellar.expert):
| Contract | Address |
|---|---|
asp_registry |
CACMQJV7SRSRKQDBXJWORGYKVWMXL6LDGJZXGY6A3DYPV3RXFHF4AR52 |
credential_verifier |
CDLRSLHALMX6OU5IHWY6CKTROK3SYENEA75K6OWSZCPAW4EOTR2OZGSF |
did_registry |
CDGDZX4OGVCWEYANDRSWKSK6LLYOGFRJDZQNFNNYPTQPAKELKR4TXLB6 |
reputation_nft |
CDA34SUCSQDOCCY5B6HJJH4CQ5PUDWII6CY3BDONGKT5E3KTEWZJ47GD |
zk_verifier (age_proof) |
CCILFFFLU6UKPXU3QD47IJULLGSPPFDS3PIUPV2MBOR5QA6OREI22NUV |
zk_verifier (kyc_tier_proof) |
CCLKJTSGJ6WJR76TKS4H4FWTH472WILJ7SGC4OWUYUAMCUGCK2E7NCYC |
zk_verifier (membership_proof) |
CCHJVP2UCG6KIOPLYIIEJ5KYESA4LGEP2QSF3JZNRFCWSK66RDIVOHTW |
credential_verifier is initialized with asp_registry's real deployed address above, and reputation_nft with credential_verifier's β these aren't independently deployed instances that merely coexist, they're actually wired to each other on-chain. Each zk_verifier instance is initialized with its own real Groth16 verification key from circuits/build/ β three separate instances, not one contract juggling three keys. scripts/deploy.sh and scripts/deploy_zk_verifiers.sh reproduce this from scratch β see docs/DEPLOYMENT_GUIDE.md.
A real reputation score is minted on this deployment (2026-09-10). Subject GAUZ4T6UT7XMGOL6WYPWWSYPZQ7ZLILCAS2ROYCH5ILHHOWQYUGVRTAB (the deployer, acting as its own demo identity) holds a real, on-chain kyc_tier_2 credential record on credential_verifier and a real minted reputation NFT (token #0, score 72) on reputation_nft β query either yourself:
stellar contract invoke --id CDLRSLHALMX6OU5IHWY6CKTROK3SYENEA75K6OWSZCPAW4EOTR2OZGSF --source deployer --network testnet -- has_credential --user GAUZ4T6UT7XMGOL6WYPWWSYPZQ7ZLILCAS2ROYCH5ILHHOWQYUGVRTAB --credential_type kyc_tier_2
stellar contract invoke --id CDA34SUCSQDOCCY5B6HJJH4CQ5PUDWII6CY3BDONGKT5E3KTEWZJ47GD --source deployer --network testnet -- get_reputation --subject GAUZ4T6UT7XMGOL6WYPWWSYPZQ7ZLILCAS2ROYCH5ILHHOWQYUGVRTABimport { StellarZkIdentClient } from '@stellar-zklab/zkident-sdk';
import freighter from '@stellar/freighter-api';
const zkident = new StellarZkIdentClient({
didRegistryId: 'CDGDZX4OGVCWEYANDRSWKSK6LLYOGFRJDZQNFNNYPTQPAKELKR4TXLB6', // live on testnet, see Deployment above
credentialVerifierId: 'CDLRSLHALMX6OU5IHWY6CKTROK3SYENEA75K6OWSZCPAW4EOTR2OZGSF',
ageProofVerifierId: 'CCILFFFLU6UKPXU3QD47IJULLGSPPFDS3PIUPV2MBOR5QA6OREI22NUV',
signTransaction: async (xdr, opts) => {
const { signedTxXdr } = await freighter.signTransaction(xdr, opts);
return signedTxXdr;
},
});
// Self-service DID registration β no admin key needed.
await zkident.registerDid(userAddress, didDocumentJson);
// Real Groth16 verification: proves age >= 18 on-chain without revealing birth date.
// proof/publicInputs come from circuits/gen_inputs.mjs + snarkjs β see circuits/README.md.
const isOver18 = await zkident.verifyAgeProof(proof, publicInputs);See sdk/README.md for the full API and circuits/README.md for how to generate a real proof for any of the three circuits.
Reputation score card (added 2026-09-10). The live demo now shows a minimalist card β address, one bold score number, mint date β reading reputation_nft's real get_reputation, modeled on Human Passport's single-score-card pattern rather than a raw JSON dump. No wallet needed to view it; enter any address to check. The demo subject's score wasn't real before this: getting a non-empty card required actually exercising the full real pipeline for the first time β a real verify_proof call (persisting a genuine credential record for kyc_tier_2), then a real mint() gated on that record via credential_verifier.has_credential() β not a fixture inserted directly into storage. See Deployment below.
Prerequisites:
- Rust with the
wasm32v1-nonetarget - Node.js 20+
circom+snarkjsβ only needed to regenerate circuits, seecircuits/README.md
# Run the real contract test suite (12 tests for zk_verifier alone β see What's built above)
cargo test --all --features testutils
# Run the frontend against the real deployed contracts (connects Freighter, real did_registry calls)
cd frontend && npm install && npm run devPart of stellar-zklab's Soroban Protocol 25 project suite, alongside:
soroban-yield-vaultβ real Blend Protocol V2 yield vault with Yearn V3 share math (live demo)stellar-zkstreamβ privacy-preserving payment streaming; this repo'szk_verifiercontract is reused from there unmodified (live demo)
All three share the same "real vs. not" documentation discipline and the same Protocol 25 BN254/testnet deployment conventions.
See CONTRIBUTING.md for the phased roadmap covering contracts, circuits, SDK, and frontend work. Check the issue tracker for known gaps before starting something new.
Apache 2.0 β see LICENSE.