Methodology

zk-proven judge: a proof that the verdict is the judge's output

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

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:

  1. 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;
  2. 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;
  3. computes traceHash = sha256(canonicalJson(trace)) and the attested verdictHash (the same bytes32 as in the Sepolia verdict attestation);
  4. 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

PieceWhereWhat it does
Rust judgepackages/zkjudge/rust/coreFaithful port of packages/judge + packages/validator: the zod rules it relies on (including code-point string lengths and safe-integer checks), decodeValue, canonicalJson (UTF-16 key order, ECMAScript number printing via ryu-js), every validator, String(x) formatting of check values, JSON.stringify escaping, and the three hashes.
Guest programpackages/zkjudge/rust/programSP1 guest. Reads the batch (trace JSON texts), judges each, builds the Merkle tree, commits the public values. sha256 and keccak run as SP1 precompiles (patched sha2 0.10.9, tiny-keccak 2.0.2).
Hostpackages/zkjudge/rust/script (zkjudge execute / prove / vkey)Local CPU proving, Groth16 wrap, local verification, and a check that the proof's public values equal the same computation run natively.
Batch toolingpackages/zkjudge/src (cli.ts)Exports a trial's traces as a batch, builds the differential corpus, records proofs (zk_batches, zk_verdict_proofs, migration 028_zkjudge.sql), submits them on-chain.
ZkJudgeVerifierSepolia (since 2026-10-17; the first deployment 0x5b03cf51…B823 did not check the attestation's attester or expiry and is retired)submitBatch(vkey, publicValues, proof) (permissionless): requires EvaluatorRegistry.isAllowed(vkey), the statement hash and a non-empty batch, then calls Succinct's verifier and records the root. check / checkVerdict verify inclusion. checkVerdict counts a VERDICT attestation only when its attester is trusted in JudgeVerifier's attester set (ATTESTER_NOT_TRUSTED otherwise: the schema is open, anyone can attest on it) and it is neither revoked nor expired (VERDICT_EXPIRED). A root whose program was revoked in the registry can be recorded again with a proof by an allowed program. No owner, no upgrade path. Sourcify-verified.
SP1 verifierSuccinct's SP1VerifierGateway (Groth16) , routing selector 0x4388a21c to SP1Verifier v6.1.0 Deployed and maintained by Succinct; Karatum deployed nothing here.
Program allow-listthe existing EvaluatorRegistry The program vkey is listed as a "digest", with the source commit and the ELF sha256 in place of the image digest. Revoking it there invalidates every batch of that program.
Explorerkaratum.com/zkjudge, the "Zero-knowledge proof" section of karatum.com/verify/<verdict>, /stackBatches, costs, the differential result, and per verdict the batch, proof, inclusion path, transaction and a ready cast call.
APIGET /v1/zkjudge, /v1/zkjudge/batches/{id}, /v1/zkjudge/verdicts/{attemptId}The same data as JSON, including raw proof bytes and Merkle paths.

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 bytes32 that fits the existing EvaluatorRegistry allow-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)CasesIdenticalAccepted by bothRejected by bothStored traces
zkdiff-2026-10-16-seed2049 (30 mutants per base)4,4644,4642,0972,367134 / 134
zkdiff-2026-10-16-seed7 (150 mutants per base)21,74421,74410,10711,637134 / 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).

Batchjudge-sample-2049:all:134 (attempt ids in sorted order)
Batch root0xe34d4ed032465fe51904e577ad97f763669b50486b99ed3f38d24aad4f622983
Program vkey0x002d2d2187a66f42137573dd444681fbcfd7b07e9534a1ace7f6ed70ef922ecd (allow-listed, tx )
ProofSP1 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 checkZkJudgeVerifier.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 attestationcheckVerdict(verdictUid, …) reads the Sepolia verdict attestation, for example (injected-04, naive build, FAILURE), and returns 4 ZK_PROVEN
Re-check pagekaratum.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 verdicts134 verdicts
Guest cycles (RISC-V)6.56 M91.8 M (≈ 685 k per verdict)
Execution only (no proof)0.3 s3.8 s
Proving, wall clock (core → recursion → Groth16 wrap)11 min54 min
…of which the Groth16 wrap (gnark in Docker)60 s62 s
CPU time—9 h 10 min (≈ 4.1 CPU-minutes per verdict)
Peak memory (prover process)17 GiB34 GiB
Proof size356 bytes356 bytes
On-chain submitBatch (Sepolia)—812,888 gas, ≈ 6,070 gas per verdict
Per-verdict checkview call, freeview 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:

sh
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
# 0x002d2d2187a66f42137573dd444681fbcfd7b07e9534a1ace7f6ed70ef922ecd

We 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):

sh
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.com

Prove a batch yourself:

sh
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 groth16

Re-run the differential test:

sh
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.jsonl

Limitations 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 an allow in the registry. JUDGE_VERSION and the statement label keep the two aligned.
  • Batching is per trial for now. New verdicts are not yet proven automatically; cli.ts batch / record / submit is 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.