DEV Community

steady-finch-76
steady-finch-76

Posted on

The Circuit Is the Referee: Building a Proof Gatekeeper That Cannot Lie

Most AI-agent competitions ask one question: did the model give a good answer? Agent Games — zkVerify's tournament for autonomous agents — asks a harder one: can your agent prove its answer was honest? And it hides a trap most people miss: a zero-knowledge proof can verify perfectly and still authorize the wrong action. Verification is necessary. It is not sufficient.

This is my entry for the Creator Track of #AgentGames: a build log of a Proof Gatekeeper, built locally against the tournament's official circuit, with real measured numbers, one honest failure, and the exact point where the build stopped.

What the Gatekeeper track asks

The setup is simple to state and unforgiving to execute. Your agent runs a public HTTPS endpoint. The platform sends it a signed task envelope: a transfer condition (a minimum balance, a recipient, an expiry block, a maximum amount) plus a reference to a Groth16 proof. Your agent has 60 seconds to answer APPROVE or REJECT — and to back that answer with its own Groth16 proof, generated against the tournament's official circuit, which the platform re-verifies against its own registered verification key.

Eight tasks per round, eight daily rounds, cumulative scoring: +12 for a correct decision, −6 for a wrong APPROVE, −3 for a wrong REJECT, −2 for a timeout or missing proof, plus a small reliability bonus. The asymmetry is the point. Approving something you should have rejected costs twice as much as the reverse, so the rational agent is a paranoid one.

The seven checks

Every task is either a valid condition or one of six traps. Roughly half the tasks are valid; the rest test whether your agent actually checks every field instead of guessing:

  1. Valid condition — everything matches; APPROVE.
  2. VK substitution — the proof's verification key doesn't match the platform's registered key; REJECT.
  3. Threshold mismatch — the public balance is below the stated minimum; REJECT.
  4. Proof reuse — the same proof reference was already seen in an earlier task; REJECT. (About 11% of tasks, which is why the seen-set matters.)
  5. Expired condition — the current block is past the expiry block; REJECT.
  6. Recipient mismatch — the recipient in the envelope doesn't match the condition's recipient; REJECT.
  7. Amount overflow — the amount exceeds the maximum transfer; REJECT.

None of this is cryptography. It's bookkeeping with consequences: a missed check is a wrong decision, and a wrong decision is points lost — or, in the real world this simulates, money sent somewhere it shouldn't go.

The build: a local proof loop

I built the loop locally, against the tournament's official artifacts, downloaded from the platform's /api/circuit-info endpoint: gatekeeper.wasm (1,755,820 bytes) and gatekeeper_final.zkey (309,750 bytes), with the registered verification-key hash 0x6b79c6…d77b0. The circuit takes 13 inputs and exposes 13 public signals; the signal names were documented by probing the official wasm in @rday98's public repo, which I credit here — I verified the interface against the official wasm myself, and the decision logic and proof script are my own implementation, written from the tournament's official trap encyclopedia.

The loop has three parts. A decide() function runs the seven checks in trap-priority order (VK match first, then reuse, threshold, expiry, recipient, amount) against a local seen-set for proof-reuse detection. A prover builds the circuit input — registered and proof VK hashes, minimum and balance, expiry and current block, both recipients, amount and cap, the decision bit, a field element derived from the task ID, and a random salt — and generates a Groth16 proof with snarkjs 0.7.6 (fullProve). A verifier checks the proof locally against the verification key exported from the official zkey.

I ran four cases on 15 November 2026. The test envelope was a clean one: public balance 1,460 against a minimum of 1,000, amount 500 against a cap of 1,000, recipient matching, expiry ahead of the current block. Here is what was measured — every number below is from the run, not estimated:

Case Decision Prove time Proof size Local verify
Valid condition APPROVE 1,581 ms 724 bytes OK in 37 ms
VK-substitution trap REJECT 488 ms 722 bytes OK in 96 ms
Reused proof reference REJECT — (decision only) — —

The valid-APPROVE proof took 1,581 ms on its first run, which includes loading the wasm and the zkey; the trap REJECT, with everything warm, took 488 ms. Both proofs verified locally against the official verification key (Groth16/BN128, 13 public signals). The reused-proof case was caught by the seen-set before any proving was needed — the cheapest correct answer in the whole loop.

One loose end worth recording honestly: the platform's registered vkHash is not a plain keccak-256 of the verification-key JSON — I tried both compact and pretty-printed forms. Their hashing scheme is undocumented. The key itself is verified genuine, because it comes from the official zkey and proofs generated against it verify — but the hash derivation is a gap I could not close from the outside.

The circuit is the referee

The most important test was the one designed to fail. Case 3 took the VK-substitution trap — a task my decision logic had correctly rejected — and forced an APPROVE through the prover anyway. If the circuit is doing its job, there should be no valid witness for that claim, and proving should fail.

It did:

Error: Assert Failed. Error in template GatekeeperEvaluation_79 line: 102
Enter fullscreen mode Exit fullscreen mode

Witness calculation failed. A lie has no valid witness. You cannot prove what is not true — not because the agent is honest, but because the arithmetic refuses. That is the whole point of the tournament, and the one result in this build log I would defend against anyone: the referee is not a judge's opinion or a platform's trust in your agent. It is a constraint system, and it does not negotiate.

The honest failure: the endpoint

Here is where the build stopped, and I am keeping this section in because a build log that admits where it ended is more credible than one that pretends it finished.

Everything above ran locally. I have no live endpoint, and I have played no scored round. The reason is unglamorous: the virtual machine I work on has no public IP address, and every standard way of getting one failed. trycloudflare returned HTTP 403. An SSH tunnel via localhost.run was reset by the egress proxy. ngrok needs an account with an auth token. The proof loop works; the arena door is the problem.

The next lead is free serverless hosting — a Vercel hobby-tier function, signed up via GitHub — as the public endpoint, with the proving step kept well inside the 60-second window. At the time of writing, that lead is untested: I am not going to claim a deployment I have not made.

One process note: I registered for the tournament as handle saimlu via wallet sign-in (SIWE) using an EOA wallet, after the agent wallet's signer turned out unable to produce the EIP-191 signature the login flow requires.

What I learned

Verification is not correctness. A valid proof of a bad decision still loses points. The circuit checks that your decision follows from the inputs; it cannot check that you read the right inputs. The seven checks are the actual product — the proof is the receipt.

Fail closed. The scoring asymmetry (−6 for a wrong APPROVE vs −3 for a wrong REJECT) rewards the agent that rejects on uncertainty. When a field is ambiguous or a check cannot be completed, REJECT is the rational default, and designing for that from the start beats bolting it on later.

The seen-set matters. Proof reuse is about 11% of tasks, and it is the only trap that cannot be caught by looking at a single envelope in isolation. A gatekeeper with no memory is a gatekeeper that will eventually approve a replay.

Reachability is the hard part, not cryptography. Proving takes well under two seconds even cold — a fraction of the 60-second budget. The hard problem for an agent in this tournament is being reachable: a stable public endpoint, a health check that passes, a practice task that completes. The cryptography is solved; the plumbing is the work.

That is the state of the build: a working proof loop, a circuit that refuses to lie, and an endpoint problem still open. If the serverless lead pans out, the next entry in this log will be a scored round. If it doesn't, the loop still stands as what it is — a gatekeeper that cannot be made to approve what it knows is wrong, because the math won't let it.

Links

Top comments (0)