Overview
StellarCred is a zero-knowledge credential system built on Stellar. It lets a trusted issuer sign claims about a holder — KYC status, age, income, jurisdiction — and lets that holder later prove those claims on-chain without revealing the underlying data.
Every proof is generated locally in the holder's browser using the UltraHonk proof system (Noir 1.0.0-beta.9 / Barretenberg 0.87.0). The Stellar chain stores only a compact verification record — no personal data touches the ledger.
ProofRegistry.is_verified learns that a holder satisfies a claim, and nothing else. Not their name, date of birth, income figure, or country — only the boolean result.How it works
The issuer calls POST /api/issue (server-side) with the holder's wallet address and the relevant attribute (e.g. date of birth). The server computes a Poseidon2 commitment over (value, salt), signs the commitment with a secp256k1 demo key, and returns the full credential JSON. The credential is stored in the holder'slocalStorage — never on a server.
The holder clicks Generate proof. Their credential inputs are sent to POST /api/witness, which executes the Noir circuit server-side and returns the witness bytes. The browser then loads /bb/index.js (the pre-built Barretenberg browser bundle) and calls UltraHonkBackend.generateProof(witness) — pure client-side WASM proving. The proof is ~14 KB.
The holder submits the proof to ProofRegistry.submit_proof via a wallet-signed Stellar transaction. The registry checks the issuer is trusted via IssuerRegistry, verifies the on-chain public key matches the one in the proof's public inputs, and forwards to CredentialVerifier which runs the BN254 UltraHonk verifier as a Soroban host function. If all pass, a record (verified_at, expiry) is written to persistent storage. Any protocol can then call ProofRegistry.is_verified — a free, read-only simulation.
Credential types
Each credential type has a dedicated Noir circuit. The circuit proves the claim using the commitment, the issuer's signature, and optional public parameters — all without revealing the underlying attribute.
kycKYC CompleteageAge VerifiedincomeAccredited InvestorjurisdictionJurisdiction EligiblefundsProof of FundsaccreditationAccredited Investor (Net Worth)ZK proof system
UltraHonk
StellarCred uses the UltraHonk proving system from the Aztec Barretenberg library. UltraHonk is a PLONK-family argument over the BN254 elliptic curve with a Keccak transcript, matching the on-chain verifier exposed as a Soroban host function in Stellar Protocol 22.
Proofs are generated in the browser via the pre-built Barretenberg WASM bundle (served from /bb/index.js), avoiding any dependency on webpack for the heavy proving machinery.
Poseidon2 commitment
The credential stores a Poseidon2 hash of the attribute value and a random 248-bit salt:
commitment = Poseidon2([value, salt])Poseidon2 is ZK-friendly — it is cheap to prove inside a Noir circuit while being collision-resistant. The salt prevents dictionary attacks on the commitment even for low-entropy values like country codes.
secp256k1 issuer signature
After computing the commitment, the issuer signs it with a secp256k1 ECDSA key using prehash: false — Noir uses the raw 32-byte commitment as the message digest directly. Each Noir circuit verifies this signature inside the proof, binding the claim to a specific registered issuer public key.
Smart contracts
Four Soroban contracts are deployed on Stellar testnet. They are wired at deploy time and communicate through typed contract clients (no shared library dependencies between them).
IssuerRegistryStores trusted issuer addresses, their secp256k1 public keys, and which credential types each is authorised to issue. Admins call register_issuer, rotate_issuer_key (keeps credentials signed with the old key valid until their expiry) and revoke_issuer_key (kills a key immediately); anyone can read is_valid_issuer, is_valid_issuer_key and get_issuer_keys.CredentialVerifierHolds a verification key (VK) per credential type. submit_proof calls the BN254 UltraHonk host function with the VK and the provided proof + public inputs. Returns bool.ProofRegistryThe public API for downstream protocols. Calls IssuerRegistry to check trust, verifies the public key in the proof's public inputs is one of the issuer's live keys, calls CredentialVerifier, and writes (holder, type) → (verified_at, expiry) to persistent storage.GatedPoolDemo protocol. Reads ProofRegistry.is_verified(holder, kyc|age|income) before allowing a deposit. Shows how any protocol can gate actions on ZK proofs without owning the verification logic.Public-input layout
Each circuit outputs the following public inputs (32 bytes per field, big-endian):
field 0 commitment (Poseidon2 hash)
fields 1–32 issuer_x (secp256k1 X, one byte per field in low byte)
fields 33–64 issuer_y (secp256k1 Y, one byte per field in low byte)ProofRegistry extracts fields 1–64 and compares them byte-by-byte against the registered issuer key, preventing a holder from substituting an attacker-controlled key.
Privacy model
Where your credentials live
Your credentials — including the raw attribute value (date of birth, income, balance…) and its random salt — are stored only in this browser's localStorage, under the key stellarcred:credentials. There is no StellarCred account and no server-side credential database: the credential JSON exists only on the device that received it.
Back up and restore
Open the Holder page and click Export backup — the browser downloads a JSON file containing every credential. Keep that file somewhere safe: it contains the raw sensitive attribute values, so treat it like a password. To restore — on a new browser, a new device, or after clearing site data — open the Holder page there, click Import credential JSON, and paste the file's contents. Restored credentials generate and submit proofs exactly like newly issued ones.
To move a single credential to another device, open its details (click the credential on the Holder page) and choose Transfer to another device. You pick a passphrase and the app shows a QR code; the credential is encrypted with that passphrase (AES-256-GCM, key derived via PBKDF2 — see lib/credential-crypto.ts) before it ever becomes a QR code, so the code alone reveals nothing. On the other device, scan the QR and enter the same passphrase to import.
On the Holder page, Export field visibility controls whether the sensitive credential attribute value and holder wallet address are included in credential backups, encrypted transfers, and Guardian recovery exports. Both fields are included by default to preserve existing behavior; clearing a checkbox omits that key rather than replacing it with a marker. An export with an omitted field cannot fully restore or prove that credential. Proof-history JSON and CSV contain proof metadata only. These are local user preferences: there is no admin override or server-side export role.
Credentials belong to the deployment that issued them
Every credential records the network and contract IDs of the StellarCred deployment that issued it. A credential is only ever valid inside that deployment: its issuer is registered in that deployment's IssuerRegistry, and proofs are submitted to its ProofRegistry. Cross-deployment import is therefore never valid — including moving a credential between networks (a testnet credential can never be proven on mainnet). Importing a JSON backup, a QR transfer, or a guardian-recovered credential from a different deployment is rejected at import time with a clear explanation, rather than appearing to succeed and failing later when the proof is submitted. Credentials issued before the deployment was recorded carry no such reference and import unchecked — re-issue them if in doubt.
What is stored, and where
One nuance: while stored credentials never leave your device, generating a proof sends your credential inputs to POST /api/witness (StellarCred's own server), which executes the Noir circuit and returns the witness bytes; the proof itself is then computed in your browser. That route is rate-limited, never logs the sensitive fields, and does not persist the credential — the values exist only in transit for that single request (see How it works above).
On the chain side, submitting a proof writes only the public inputs — the commitment (a hash), the issuer's public key, the credential type, and an expiry timestamp — plus the ~14 KB proof bytes. See Privacy model above for the full breakdown.
Toolchain
Noir1.0.0-beta.9Circuit language & compilerBarretenberg0.87.0UltraHonk prover / verifier (bb CLI + bb.js)Stellar CLI26+Contract deploy & invoke (Protocol 22 / BN254 host fns)soroban-sdk22Rust contract framework@stellar/stellar-sdk13.3.0TypeScript clientNext.js14.2Frontend framework (App Router)Build circuits
# From repo root
cd circuits
bash scripts/build.sh # compiles all 5 circuits + commit helper
# outputs *.json to frontend/public/circuits/
# outputs VKs to fixtures/*/vkDeploy contracts
stellar keys generate --global deployer --network testnet --fund
SOURCE=deployer ./scripts/deploy.sh
# Copy the printed NEXT_PUBLIC_* vars into frontend/.env.localGet started
The fastest way to see StellarCred in action on testnet — no local build needed:
- Install a Stellar wallet (Freighter, Albedo, xBull, and others are supported) and switch it to Testnet
- Fund your address via Stellar Friendbot (
friendbot.stellar.org/?addr=YOUR_ADDRESS) - Click Verify in the nav, connect your wallet, and request a KYC credential
- Go to Wallet, click Generate proof — the browser proves in ~10 seconds
- Click Submit to Stellar, approve in your wallet, and check the Apps page to see your eligibility update live