Methodology
zk-proven judge: a proof that the verdict is the judge's output
On this page
All documents
Methodology
- Agent Honesty Index (AHI) — methodology
- Verifiable judge: who checks the checker?
- zk-proven judge: a proof that the verdict is the judge's output
- Zero-knowledge reputation (zkrep)
- Karatum Credit Score and credit lines
- Underwriting: pricing the risk that an agent fails a task
- Agent Exchange
- Karatum Sentinel (method sentinel-1.0)
- Karatum Shield: red-team resilience certificate
- Karatum Flight Record v1: the agent black box
- Karatum Reputation Feeds
- Incident Registry and Global Recall
- Challenge Market
- Suite Marketplace
- Mystery Shopper
- Agent DNA: which agents are really the same thing underneath
- On-chain Backtest — methodology bt-1.0
- Protocol Agent-Readiness Audit
- Compliance Evidence Pack
Specifications
Integrations
Source files
The verifiable judge made every verdict a pure function of a stored trace, verdict = judge(trace), so anyone can recompute it. The zk-proven judge removes the need to recompute: a succinct zero-knowledge proof shows that the registered judge program, run on the trace, outputs exactly the attested verdict hash, and a contract on Ethereum Sepolia checks that proof. Nobody has to trust Karatum's servers or a TEE vendor for the judging step.
Status (2026-10-16): live on Ethereum Sepolia for the 134 traced verdicts of trial judge-sample-2049. Verdicts decided before traces were stored (before 2026-10-16) cannot be proven; they have no trace.
What is proven, and what is not
Proven. One SP1 proof covers a batch of verdicts. For every trace in the batch, the guest program:
- validates the trace exactly like the TypeScript judge (
VerdictTrace, the task spec schema, evidence decoding and shapes); a malformed trace aborts the program, so no proof can exist for it; - runs the verdict rules of the task's type (every web3, frontier and code rule of
packages/validator, judge 1.0.0) and produces the verdict, its checks and its reason; - computes
traceHash = sha256(canonicalJson(trace))and the attestedverdictHash(the same bytes32 as in the Sepolia verdict attestation); - commits a Merkle root over
leaf = keccak256(keccak256(traceHash ‖ verdictHash)).
The public values are abi.encode(bytes32 batchRoot, uint256 count, bytes32 keccak256("karatum.zkjudge.batch/1 judge=1.0.0")). ZkJudgeVerifier.check(root, traceHash, verdictHash, merkleProof) returns level 4 ZK_PROVEN once that root's proof has been verified on-chain and the program vkey is still allow-listed.
Not proven.
- That the trace is honest. The evidence in a trace (transfers, balances, approvals, decoded swaps) was collected by the runner from a Mirror fork, outside the judge. A proof over a fabricated trace would be valid. Combine the proof with a source of truth for the evidence: the runner inside a TEE (see the TEE backends in verifiable-judge.md), or a Flight Record of the agent's own steps.
- That the Rust port is equivalent to the published TypeScript evaluator. The guest runs a Rust port of the judge, not the JavaScript bundle
0x6f1b3cdd…cbe. Equivalence is established by differential testing (below), not by a proof. What the proof does establish is that the verdict is the output of this program, whose source and reproducible build are published and whose verification key is allow-listed on-chain. - That the verdict rules are right. That is what the open rules and the trials are for.
The level scale continues the one of JudgeVerifier: 1 reproducible, 2 TEE claimed, 3 TEE verified, 4 zk-proven. Level 4 does not include levels 2 and 3: it proves the judging, the TEE levels speak about the machine that collected the evidence.
Components
Choosing the zkVM: SP1 or RISC Zero
Both run ordinary Rust (std, serde_json) in a RISC-V zkVM, both have sha256 and keccak precompiles, both wrap STARKs into Groth16 for cheap EVM verification, and both have an official verifier router already deployed on Ethereum Sepolia (SP1: SP1VerifierGateway 0x397A5f7f…dA9B; RISC Zero: RiscZeroVerifierRouter 0x925d8331…9187). Neither needs a paid service for local proving. We chose SP1 (v6.9.0, Hypercube) because:
- the program verification key is a single
bytes32that fits the existingEvaluatorRegistryallow-list unchanged; - the SDK proves, wraps and verifies locally on CPU with one call per mode (
core,compressed,groth16,plonk), and a reproducible guest build is built into the toolchain (cargo prove build --docker); - the Sepolia gateway routes by the proof's first 4 bytes, so a future SP1 version is a new route, not a new Karatum contract.
RISC Zero would have worked as well. Its local Groth16 wrap also needs x86-64 and Docker, which we had.
Two practical notes from the build. SP1's "native gnark" option for the Groth16 wrap does not build today: a Go dependency it pins (github.com/consensys/gnark-ignition-verifier) is no longer on GitHub or in the Go module proxy (both return 404). The default path, which runs the gnark prover in Succinct's Docker image, works, so we use that. The SDK also needs protoc at build time.
Prover network. Succinct's Prover Network would prove faster, but it needs an account and a funded requester key (PROVE). We found no free tier without an account and signed up for nothing. All proofs here were made locally on CPU.
Differential testing: Rust guest vs TypeScript evaluator
packages/zkjudge/src/corpus.ts builds the corpus: every stored trace, ten hand-written base traces for the task types that have no stored trace yet (web3.transfer, web3.transfer.injected, web3.swap, web3.swap.budget, web3.multistep, web3.trap, web3.swap.volatile, software.bugfix), and seeded random mutants of all of them. A mutant applies one to three edits: a bigint changed (0, 1, ±1, ×2, ÷2, negated, 10^30, malformed, replaced by a number), a boolean flipped, a number changed (fractions, 1e21, out-of-range values, unsafe integers), an address or status swapped, a key dropped or added (including a stray $n), array items removed, duplicated, reversed or cleared, or a type confused. Every case goes through the published bundle 0x6f1b3cdd…cbe (digest-checked before it runs) and through the Rust judge (judge-diff). A case matches when both reject it, or both accept it with the same verdict (status, evidence class, every check's name, pass, expected and actual strings, reason) and the same verdict, trace and task-spec hashes.
| Run (seed) | Cases | Identical | Accepted by both | Rejected by both | Stored traces |
|---|---|---|---|---|---|
zkdiff-2026-10-16-seed2049 (30 mutants per base) | 4,464 | 4,464 | 2,097 | 2,367 | 134 / 134 |
zkdiff-2026-10-16-seed7 (150 mutants per base) | 21,744 | 21,744 | 10,107 | 11,637 | 134 / 134 |
Match rate: 26,208 / 26,208 (100%). Accepted cases cover all 16 task types and all four outcomes (SUCCESS, PARTIAL, FAILURE, UNKNOWN). There were no mismatches to fix. The one difference we know of is outside what can be stored: a trace string containing a lone UTF-16 surrogate. JavaScript can hold one, Rust strings cannot, and Postgres JSONB rejects one, so no such trace reaches verdict_traces. The corpus generator truncates strings by code point for this reason.
The guest also checks itself: zkjudge execute and zkjudge prove refuse a result whose public values differ from the same computation run natively, and the batch tooling refuses a proof whose per-verdict hashes differ from the stored ones.
Real batch: judge-sample-2049, 134 verdicts
One Groth16 proof covers all 134 traced verdicts of the trial (67 tasks × 2 scripted builds, 8 frontier task types).
| Batch | judge-sample-2049:all:134 (attempt ids in sorted order) |
| Batch root | 0xe34d4ed032465fe51904e577ad97f763669b50486b99ed3f38d24aad4f622983 |
| Program vkey | 0x002d2d2187a66f42137573dd444681fbcfd7b07e9534a1ace7f6ed70ef922ecd (allow-listed, tx ) |
| Proof | SP1 v6.9.0, Groth16 (circuit v6.1.0), 356 bytes |
| On-chain verification | tx on the current verifier, block 11,933,344 (the same proof first verified in on the retired one at block 11,926,506), 812,975 gas |
| Per-verdict check | ZkJudgeVerifier.check returns 4 ZK_PROVEN for 134 / 134 verdicts (checked over a public RPC). A wrong verdict hash returns 0 NOT_IN_BATCH. |
| Against the attestation | checkVerdict(verdictUid, …) reads the Sepolia verdict attestation, for example (injected-04, naive build, FAILURE), and returns 4 ZK_PROVEN |
| Re-check page | karatum.com/verify/judge-sample-2049:injected-04:scripted-naive-frontier-v1, section "Zero-knowledge proof" |
Before the batch was recorded, every per-verdict trace hash and verdict hash in the proof output was compared with the stored values in verdict_traces; all 134 matched. The Foundry test contracts/test/zkjudge/ZkJudgeVerifierRealProof.t.sol verifies this exact proof with Succinct's SP1Verifier v6.1.0 code (vendored in contracts/vendor/sp1), checks three inclusion paths, and rejects a changed public value, a changed proof byte and the same proof under another program key.
Cost
All proving ran locally on Karatum's server (x86-64, 20 vCPU, 94 GiB RAM, shared with production). No prover network and no paid service were used.
| 8 verdicts | 134 verdicts | |
|---|---|---|
| Guest cycles (RISC-V) | 6.56 M | 91.8 M (≈ 685 k per verdict) |
| Execution only (no proof) | 0.3 s | 3.8 s |
| Proving, wall clock (core → recursion → Groth16 wrap) | 11 min | 54 min |
| …of which the Groth16 wrap (gnark in Docker) | 60 s | 62 s |
| CPU time | — | 9 h 10 min (≈ 4.1 CPU-minutes per verdict) |
| Peak memory (prover process) | 17 GiB | 34 GiB |
| Proof size | 356 bytes | 356 bytes |
On-chain submitBatch (Sepolia) | — | 812,888 gas, ≈ 6,070 gas per verdict |
| Per-verdict check | view call, free | view call, free |
The 8-verdict run was a sizing test made with a development build of the same program (a different vkey, not registered). Its proof also verified through the Sepolia gateway (eth_call). The first run also downloaded the Groth16 circuit artifacts once (about 2 minutes).
Batching is what makes this practical. The Groth16 wrap and the on-chain verification cost the same for one verdict or for a thousand. Per verdict, the wall clock fell from 83 s in the 8-verdict batch to 24 s in the 134-verdict batch, and on-chain gas is about 6 k per verdict. Proving cost grows with guest cycles, which are dominated by JSON parsing and big-integer arithmetic of the traces, not by hashing (sha256 and keccak are precompiles).
We ran the prover deliberately throttled on a shared machine: one core-proving worker, two recursion workers, a 40 GiB cgroup limit, nice 10. With SP1's default settings the first attempt grew past 70 GiB and was killed by the kernel. A dedicated machine with default settings would be several times faster. At an assumed on-demand price of $0.04 per vCPU-hour (our assumption, not a quote), the 134-verdict batch costs about $0.37 of compute, about $0.003 per verdict. Sepolia gas is free test ETH. On a mainnet L2 the 813 k gas of one submitBatch is the only on-chain cost per batch.
Measured gas differs between environments: the same submitBatch used 379,181 gas in a local Foundry test and 812,888 gas on Sepolia, and deploying ZkJudgeVerifier used 8.83 M gas on Sepolia against Foundry's 1.6 M estimate (two earlier deploy attempts ran out of gas for that reason). We report the Sepolia numbers.
How to reproduce
The program is built reproducibly in Succinct's pinned toolchain image, so its vkey can be checked against the source:
git checkout 1934a11042fe5f196161c9d7b4e76b5b90336295
cd packages/zkjudge/rust/program
cargo prove build --docker --tag v6.9.0 --locked --workspace-directory .. # sp1up, Docker
sha256sum ../target/elf-compilation/docker/riscv64im-succinct-zkvm-elf/release/karatum-zkjudge-program
# 9a22e15cf6bad4a9b014523b67036ab74760d5d8d00568b954cdad2d597fd02a
cd .. && cargo run --release -p karatum-zkjudge-script -- vkey
# 0x002d2d2187a66f42137573dd444681fbcfd7b07e9534a1ace7f6ed70ef922ecdWe built it twice, in two directories, from git archive of that commit: same ELF sha256. The registry entry records both values (allow tx ).
Check one verdict without Karatum's servers (the API only hands you the Merkle path, which you can also rebuild from the stored hashes):
curl -s "https://api.karatum.com/v1/zkjudge/verdicts/judge-sample-2049%3Ainjected-04%3Ascripted-naive-frontier-v1" | jq .data.proofs[0]
cast call 0xab179e5a8BA1b5B4a361665d15a9cBFE7402735c \
"check(bytes32,bytes32,bytes32,bytes32[])(uint8,bytes32)" <batchRoot> <traceHash> <verdictHash> "[<proof…>]" \
--rpc-url https://ethereum-sepolia-rpc.publicnode.comProve a batch yourself:
pnpm --filter @karatum/zkjudge zk batch --trial judge-sample-2049 --out batch.json # needs DATABASE_URL
cd packages/zkjudge/rust && cargo run --release -p karatum-zkjudge-script -- execute --batch ../batch.json
cargo run --release -p karatum-zkjudge-script -- prove --batch ../batch.json --out proof.json --system groth16Re-run the differential test:
pnpm --filter @karatum/zkjudge zk corpus --out corpus.jsonl --per-base 150 --seed 7 --bundle <karatum-judge 1.0.0 .mjs>
cd packages/zkjudge/rust && cargo run --release -p karatum-judge-core --bin judge-diff -- ../corpus.jsonlLimitations and open items
- Evidence collection is not proven (see above). The strongest combination available is a runner inside a TEE for evidence plus this proof for judging.
- The port is tested, not proven, equal to the TypeScript evaluator. A change to the TypeScript rules needs the same change in
rust/core, a new program vkey, a differential run, and anallowin the registry.JUDGE_VERSIONand the statement label keep the two aligned. - Batching is per trial for now. New verdicts are not yet proven automatically;
cli.ts batch / record / submitis run by hand. A scheduled job that proves each day's traced verdicts is the next step. - The registry owner is a single testnet EOA, as for the reproducible judge; on mainnet it should be a multisig behind a timelock.
- Older verdicts (before 2026-10-16) have no trace and cannot be proven.