Skip to content

Latest commit

Β 

History

51 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

stellar-zkident πŸ”πŸͺͺ

Soroban License CI DID Live Demo

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.

Contents

Why this is real zero-knowledge, not a buzzword

  • Genuine Groth16 proofs verified on-chain, not simulated. Three deployed zk_verifier instances perform real BN254 pairing checks via Soroban Protocol 25's native env.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.md for 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 in zk_verifier, so the two capabilities are never conflated.

Architecture

+----------------------------+
|        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)           |
+----------------------------+

What's built

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.

Deployment

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 GAUZ4T6UT7XMGOL6WYPWWSYPZQ7ZLILCAS2ROYCH5ILHHOWQYUGVRTAB

Usage

import { 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.

πŸš€ Quick start

Prerequisites:

  • Rust with the wasm32v1-none target
  • Node.js 20+
  • circom + snarkjs β€” only needed to regenerate circuits, see circuits/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 dev

Ecosystem

Part of stellar-zklab's Soroban Protocol 25 project suite, alongside:

All three share the same "real vs. not" documentation discipline and the same Protocol 25 BN254/testnet deployment conventions.

Contributing

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.

License

Apache 2.0 β€” see LICENSE.

About

Self-sovereign did:stellar: identity with real Groth16 zero-knowledge credentials and soulbound reputation NFTs on Soroban.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages