Methodology

Zero-knowledge reputation (zkrep)

Testnet pilotSource: docs/methodology/zk-reputation.md

Status: v1, live on testnets since 2026-10-16 (Base Sepolia, Ethereum Sepolia). Code: packages/zkrep (circuit, prover SDK, off-chain verifier, Trust Handshake zk mode), contracts/src/zkrep (ZkRepRegistry, generated Groth16 verifier), services/zkrep (root publisher, recall drill), services/api/src/routes/zkrep.ts (/v1/zkrep), explorer page /zkrep.

1. The statement

An agent proves, without saying which agent it is:

I hold a VALID Karatum certificate for task class X with tier ≥ t and K ≥ k that expires after now, it is in the reputation tree Karatum published as root R (so it was not recalled when R was published), and my nullifier for your scope S is N. This proof answers message M.

Public signals, in circuit order (snarkjs puts the output first): [nullifier, root, taskClass, minTier, minKarats, nowTs, scope, messageHash].

2. What is hidden and what is not

ItemHidden?Notes
Which agent (wallet, build, ERC-8004 id, certificate id)hiddenThe proof shows membership in the tree, not which leaf.
Exact tier and KhiddenOnly the verifier's thresholds are proven. A prover should prove exactly what was asked, never its real values.
Certificate expiryhiddenProven to be later than nowTs.
Identity secrethiddenKaratum only ever sees Poseidon(secret).
Task classpublicAsked by the verifier. A certificate covers its whole class (web3.swap is answered by web3).
Root (so the day's tree)publicNeeded for freshness and recall.
Nullifier for the verifier's scopepublicStable per (agent, scope): the verifier recognises a returning agent, and one agent cannot pose as two in one scope. Different scopes give unlinkable nullifiers.
Proof time nowTspublicBounds replay (the registry accepts proofs at most maxProofAge = 1 h old).

What zero knowledge does not hide:

  • The anonymity set is the set of leaves that satisfy the same thresholds. If only three agents in today's tree have a web3 certificate with tier ≥ A, a proof of "tier ≥ A" names one of three. On 2026-10-16 the tree holds only the two demo agents plus the epoch-nonce leaf: the demo shows the mechanism, not meaningful anonymity. The set grows as agents register identities (POST /v1/zkrep/identities).
  • Karatum knows who is in the tree (it builds the tree from certificates and wallet bindings). It does not learn who made a given proof: provers download the whole public leaf list and compute their Merkle path locally; the witness endpoint returns only the caller's own openings and never a path or index.
  • Timing and network metadata (IP addresses, the moment a relayer submits) are outside the proof. The demo relayer sees the proofs it relays; an agent that cares submits its own transactions or uses its own relayer.
  • Demo agents' secrets are public on purpose (the /zkrep page proves with them in your browser). Anyone can prove as a demo agent; their proofs mean nothing beyond the demo.

3. Construction

Stack: Circom 2.2.2 + Groth16 (BN254) + snarkjs 0.7.6, Poseidon hashes. Chosen over Noir/UltraHonk and SP1 because: the statement is small and fixed (a Merkle membership and three comparisons), Groth16 verification is the cheapest on the EVM (~235k gas for the pairing check, ~306k for accept in total), proofs are 256 bytes, snarkjs proves in the browser without native code, and Semaphore-style Poseidon trees are the best-reviewed pattern for anonymous group membership. The cost is a circuit-specific trusted setup (section 6). SP1 (used by the zk judge) would prove the same statement without a circuit setup, but proving takes minutes on a server, not a second on the agent's own machine.

  • idCommitment = Poseidon1(secret); the agent derives secret from its wallet: keccak256("karatum.zkrep.identity.v1" ‖ sig) mod r, where sig is its EIP-191 signature of a fixed message (RFC 6979 signers are deterministic, so the secret is recoverable from the wallet key; agents with non-deterministic signers use randomIdentity() and keep the secret).
  • leaf = Poseidon6(idCommitment, taskClass, tier, K, expiresAt, status), status = 1 (VALID) enforced in the circuit; taskClass = uint256(keccak256("karatum.task.<class>")) >> 4 (the Guard task-type id, truncated to the field).
  • Tree: binary Poseidon2 Merkle tree, fixed depth 20 (about one million leaves), empty leaves 0, leaves sorted ascending.
  • Constraints: range checks (Num2Bits) on tier, K and the thresholds (8 bits) and on expiresAt, nowTs (64 bits), then tier ≥ minTier, K ≥ minKarats, expiresAt > nowTs; membership; nullifier = Poseidon3(D, secret, scope) with D = keccak256("karatum.zkrep.nullifier.v1") >> 8; messageHash² binds the message. 5,903 constraints after --O2.
  • Proving time measured on 2026-10-16: 1.1–1.3 s in Node (server CPU), proofs in the browser on /zkrep print their own time. Artifacts: karatum_rep.wasm 3.3 MB, karatum_rep_final.zkey 3.8 MB, downloaded once.

Scopes. On chain, scope = keccak256(abi.encode(chainId, registry, consumer, appScope)) >> 8 where consumer is the caller of accept: an application cannot spend another application's nullifiers, and a proof made for one chain or registry is useless on another. Off chain (Trust Handshake), scope = keccak256("karatum.zkrep.scope:" + aud) >> 8: one pseudonym per verifier identity.

4. Roots, freshness and recall

The publisher (services/zkrep, systemd timer every 15 minutes) builds the leaf set from the same inputs as the Guard root: wallet → build bindings (Guard operator bindings, else builds.wallet), certificates whose effective status is VALID and unexpired, minus every wallet and build that stands recalled in the incident registry, restricted to wallets that registered an identity, one identity per build (the most recently registered wins, so an operator cannot multiply pseudonyms by binding more wallets). It publishes ZkRepRegistry.publishRoot(root, validUntil = now + 72 h, leafCount, immediate, uri) on both chains when:

ChangeReasonimmediatePrevious root
a leaf disappeared (recall, revocation, expiry, certificate lapse)recalltruestops counting in the same block
leaves only addedchangefalseacceptable for 1 h more (grace)
unchanged, root 24 h old or expiring within 48 hdaily / refreshfalse1 h grace

Every root contains one epoch-nonce leaf keccak256("karatum.zkrep.epoch-nonce:<time>") >> 8. Nobody knows a Poseidon preimage for it, so it cannot be proven; it makes consecutive roots distinct (the registry maps each root to one epoch).

ZkRepRegistry.rootState(root): CURRENT, GRACE, SUPERSEDED, REVOKED (attester revokeRoot), EXPIRED (the latest root's validUntil passed: Karatum stopped publishing; every proof then fails closed), UNKNOWN.

Recall latency. From the recall to "no proof of the recalled agent is accepted" = the time until the next root lands. The drill (scripts/ops/zkrep/drill.sh) runs the publisher in-process right after the recall; the timer would otherwise pick it up within 15 minutes. The measured numbers of the latest drill are on /zkrep and in GET /v1/zkrep (latestDrill).

Drill KAR-2026-0017 (2026-10-16, Base Sepolia, DRILL-labelled, demo agent B): B's proof accepted in block 48202340 (0xc784f687…c738); recall announced in KaratumIncidentLog in block 48202340; the next root (epoch 4, immediate) landed in block 48202352, 12 blocks (about 24 s) later; a proof B had made before the recall was then sent and reverted with ProofRejected(ROOT_NOT_ACCEPTED) in block 48202383 (0xf4b76207…021c); B had no leaf in the new tree; demo agent A was accepted against the new root (0xc52ed036…d7ae1); after the recall was lifted, B was back in epoch 5 and accepted again (0x7be49307…f8).

5. Integrations

  • Contracts. accept(proof, statement, appScope): valid, fresh, first use of the nullifier in the caller's scope (anti-sybil), emits ProofAccepted. check/verify: stateless. The Groth16 verifier gets a 400k gas cap so an invalid curve point cannot burn the caller's gas.
  • messageHash binding is the consumer's job. The circuit binds the proof to messageHash, and accept, check and verify return or emit it, but they do NOT check it against anything: the registry cannot know what the proof was meant to authorize. A consumer that accepts a proof as authorization for an action (a payment, a vote, a login) MUST recompute the expected messageHash from that action (for example keccak256(abi.encode(chainId, address(this), action, nonce)) >> 8) and refuse the statement unless s.messageHash equals it. Otherwise anyone who sees a valid proof in the mempool or in a log can replay it for a different action in the same scope before the agent does (the nullifier stops a second USE in that scope, not a front-run of the first). Only the Guard path checks it inside the registry (messageHash = executor in proveForGuard); the Handshake verifier recomputes it from its stored challenge.
  • Guard. ZkRepRegistry implements IKaratumCertRegistry for pseudonymous executors. The agent proves once per root in the Guard scope with messageHash = executor (proveForGuard, permissionless: a relayer can submit but cannot redirect it). A KaratumSafeGuard deployed with ZkRepRegistry as its registry (address below) gates the Safe on that executor exactly as the public Guard does, with the proven tier and K as lower bounds. One executor per agent (a new one retires the old; an executor held by one agent cannot be claimed by another). Each certificate records the nullifier of the agent that proved it (zkCertNullifier(executor, taskType)); status counts it only while that agent still holds the executor, so when a retired executor address is claimed by another agent, the previous holder's certificates for other task types stay REVOKED instead of reviving under the new holder. The executor's status lapses to SUPERSEDED as soon as its root is no longer acceptable, so a recall (immediate root) blocks it in that block. Tested end to end in contracts/test/zkrep/ZkRepRegistry.t.sol with the real KaratumSafeGuard and real proofs.
  • Trust Handshake zk mode (packages/zkrep/src/handshake.ts). New presentation type karatum-zkrep+groth16, wire form zkrep.<base64url(JSON {typ, v, nonce, aud, ctx?, proof})> (SD-JWT presentations always contain ~, zk ones never do). messageHash = keccak256("karatum.zkrep.handshake.v1|nonce|aud|ctx") >> 8, recomputed by the verifier from its STORED challenge; scope = aud. The nonce is consumed only after the proof verified. Policies that a zk proof cannot answer (minSample, minEvidenceClass, noIncidentWithinDays, allowWallets, denyWallets, maxCredentialAgeSec) are refused with ZK_POLICY_UNSUPPORTED, never silently dropped. noRecall is answered by root freshness.
  • Moca AIR. AIR verifies its own credentials with zero-knowledge proofs inside the AIR stack; zkrep does not depend on it. An AIR verifier that wants Karatum's statement without the agent's identity can accept a zkrep proof next to (or instead of) the Karatum credential issued into the AIR account by services/air-issuer, using the same ZkRepRegistry root as source of truth. No AIR integration code is shipped in v1.

6. Trusted setup

Groth16 needs a per-circuit setup; whoever knows the full toxic waste can forge proofs.

  • Phase 1: PSE Perpetual Powers of Tau, contribution #80, 2^13 (ppot_0080_13.ptau, sha256 ccee28086e4b81d81a6e16fdee054d1dbd5276362e2662d4205d31de45cb930f). Sound if any one of its contributors was honest.
  • Phase 2: one Karatum contribution (64 bytes from /dev/urandom, never written to disk), then a public random beacon: the hash of Ethereum mainnet block 26206125 (0x601eb12c…a27877), read after the contribution finished, 2^10 iterations. snarkjs zkey verify against the r1cs and the ptau: ZKey Ok!. Hashes: packages/zkrep/artifacts/manifest.json and sha256.txt; reproduce with packages/zkrep/scripts/build-circuit.sh.
  • What this means. Phase 2 has one real contributor (Karatum) plus a beacon. If Karatum kept its entropy it could forge proofs. That adds no trust beyond what a verifier already places in Karatum: Karatum is also the issuer that decides which leaves enter the tree, so it could already make any agent "certified". It does matter for anyone who wants zkrep to be trust-minimised against Karatum; for that, more phase-2 contributions are needed (anyone can run snarkjs zkey contribute on the published zkey) and the verifier contract must be redeployed with the resulting key. Planned before any mainnet use.
  • The generated verifier (contracts/src/zkrep/KaratumRepVerifier.sol) is snarkjs output under GPL-3.0, as generated.

7. Deployments (testnets)

Base Sepolia (84532)Ethereum Sepolia (11155111)
ZkRepRegistry0x3B58b94157669AEf88020900B5Ce13803C461f3a0x9B5E60E445B9aF9579265C8a002A5b75635568A9
KaratumRepVerifier0xb79e195Bc01cDd5BCF863BDF55E683a080141eb4same address
KaratumSafeGuard (zk registry)0xF1a12E1BB42bf26157ce608315244e9c9Eb2560E0x09072D965f3a295fF9Fc0CF186CB3Bac70d7B6c3

Redeployed 2026-10-17 (review fixes: certificates bound to the proving nullifier, renounceOwnership disabled, nested-execution fix in the Safe guard); the verifier is unchanged and reused. The first registry and zk guard (0x4dA132c9…676E and 0xcD5b2FA4…2868, same address on both chains) are retired.

Record: contracts/deployments/zkrep.json. Owner 0xa4AB…Af43 (testnet OWNER), attester 0xB4e7…27A2 (dedicated zkrep key), grace 1 h, max root TTL 7 days, max proof age 1 h.

8. Tests

  • Circuit + SDK (packages/zkrep/src/zkrep.test.ts): real proofs; tampered signals, wrong thresholds/task/scope/message, stale and future proofs, unknown roots, recalled leaves (no path), one-use nullifiers, handshake zk mode (allow, replay, wrong audience, unsupported policy, stale root).
  • Contracts (contracts/test/zkrep/ZkRepRegistry.t.sol, real proofs from scripts/fixtures.ts): verifier, check codes, scopes, sybil, grace vs immediate rotation, revocation, expiry, Guard path including the real KaratumSafeGuard.
  • Publisher (services/zkrep/src/publish.test.ts), API (services/api/src/routes/zkrep.test.ts).