Documentation

About StellarCred

Zero-knowledge credential infrastructure on Stellar. Prove facts about yourself without the data ever touching the chain.

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.

Core guarantee: A verifier reading 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

01Issue

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.

02Prove

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.

03Verify

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 Complete
identity verified
Attribute
None (random secret proves liveness)
Kept private
The secret value
ageAge Verified
age ≥ 18
Attribute
Date of birth (stored as days since epoch)
Kept private
Exact date of birth
incomeAccredited Investor
income > $200k
Attribute
Annual income in USD
Kept private
Exact income figure
jurisdictionJurisdiction Eligible
country not restricted
Attribute
ISO 3166-1 numeric country code
Kept private
Exact country code
fundsProof of Funds
balance > $10,000
Attribute
Aggregate balance across linked Plaid accounts (verified by bank, never stored)
Kept private
Exact balance of each account
accreditationAccredited Investor (Net Worth)
net worth ≥ $1,000,000
Attribute
Net worth in USD
Kept private
Exact net worth figure

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.

The demo issuer’s signing key lives only in the Next.js server process — never in the browser. In production, each issuer would hold their own secret key in a hardware security module or secrets manager.

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

Stays on device
Raw attribute (age, income, country)
Random salt
Full credential JSON
Issuer signature
Witness bytes
Goes on-chain
Proof bytes (~14 KB)
Public inputs (commitment + issuer pubkey)
Holder address
Credential type (symbol)
Expiry timestamp
The commitment that appears in the public inputs is a hash — it reveals nothing about the underlying value without the salt. Even if the chain is public, an observer learns only that some holder with some credential passed verification at a given time.

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.

Credentials are browser-specific and can be lost permanently. Clearing site data, switching to a different browser or device, or browsing in private/incognito mode erases them — there is no server-side copy to recover them from. Back up (below) before any of those happen.

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

Stored on this device
Raw attribute (age, income, country, …)
Random salt
Full credential JSON + issuer signature
Proof status (provedAt / tx hash)
Never stored on a server
Credential value / salt
Credential JSON (discarded after use)
Passphrase or derived key

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 & compiler
Barretenberg0.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 client
Next.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/*/vk

Deploy contracts

stellar keys generate --global deployer --network testnet --fund
SOURCE=deployer ./scripts/deploy.sh
# Copy the printed NEXT_PUBLIC_* vars into frontend/.env.local

Get started

The fastest way to see StellarCred in action on testnet — no local build needed:

  1. Install a Stellar wallet (Freighter, Albedo, xBull, and others are supported) and switch it to Testnet
  2. Fund your address via Stellar Friendbot (friendbot.stellar.org/?addr=YOUR_ADDRESS)
  3. Click Verify in the nav, connect your wallet, and request a KYC credential
  4. Go to Wallet, click Generate proof — the browser proves in ~10 seconds
  5. Click Submit to Stellar, approve in your wallet, and check the Apps page to see your eligibility update live