DEV Community

Saustartar
Saustartar

Posted on

The Circuit That Wouldn't Say Its Own Name

Building a zkVerify Proof Gatekeeper without ever learning its input names — and what the circuit itself taught me about trust.

by dudukdulu

I came to zkVerify's Agent Games to build a Proof Gatekeeper. The job: your agent receives a signed task describing a payment request, decides APPROVE or REJECT in 60 seconds, and proves its decision with a Groth16 zero-knowledge proof that the platform re-verifies against its own key. A correct call scores +12. A wrong REJECT costs −3. A wrong APPROVE costs −6. A timeout or an invalid proof costs −2.

I did not build a live arena agent. No public endpoint, no scored round, no VFY spent. What I built is a local gatekeeper that runs the tournament's own official circuit, generates real Groth16 proofs with snarkjs, and verifies them — and found the circuit refuses to name its own inputs, fails closed on a wrong APPROVE, stays silent on a wrong REJECT, and contradicts two claims circulating about it. This is that build, with receipts.

A task is a signed JSON envelope; the decision is a fixed checklist over its fields: the proof's verification key matches the registered condition key; the public balance meets the minimum; the recipient matches the condition; the amount is within the cap; the condition has not expired; the proof reference has not been used before. One mismatch is a rejection. The field guide adds the sentence that matters most, in bold: "A proof can verify perfectly and still authorize the wrong action."

The build: download the circuit, then hit the wall

The official artifacts are public, listed at /api/circuit-info: gatekeeper.wasm (1,755,820 bytes, sha256 be00aefe…92764f) and gatekeeper_final.zkey (309,750 bytes, sha256 adfe95a4…e01af80d). The registered verification-key hash is 0x6b79c6012a6d8651f3e13bc4d2a20370280ab49700c90c5316d4168bfbad77b0. I exported the verification key from the official zkey with snarkjs 0.7.6: Groth16, bn128, 13 public signals, 647 variables. Then I hit the wall.

The official pages publish no input signal names and no input JSON schema. The checklist is published; the wire format is not. A circuit without named inputs is a locked room: you hold the proving key and the wasm, but you cannot feed it a task because you cannot address its inputs by name.

It gets worse. The wasm stores input names only as FNV-1a 64-bit hashes — a one-way function, with no name table anywhere in the artifact. So I tried to recover the names by force: a brute-force over roughly 5.3 million candidates spanning payload names, booleans, hash variants, single letters, array-style names, affixes, abbreviations, numeric names, multi-word domain combinations, two wordlists (10k and 370k entries), tutorial vocabulary, isXxx names, and envelope/preflight terms. It matched 0 of 13. The names are unrecoverable; every label below is my inference, not an official name.

Proving a circuit blind: set inputs by hash

If you cannot name an input, you can still address it — by its hash. I extracted the 13-entry hash table from the wasm data section (segment 18, offset 2032; all 13 inputs are scalars, all at home slots, no collisions). Then I wrote a witness driver that sets each input by calling setInputSignal with the two 32-bit halves of its hash, after writing the value as little-endian 32-bit limbs into shared memory:

for (const e of TABLE) {
    const h = e.hash.slice(2);
    const v = valuesByHash[e.hash] !== undefined ? String(valuesByHash[e.hash]) : "0";
    const arrFr = toArray32(normalize(v), N32);
    for (let j = 0; j < N32; j++) inst.exports.writeSharedRWMemory(j, arrFr[N32 - 1 - j]);
    inst.exports.setInputSignal(parseInt(h.slice(0, 8), 16), parseInt(h.slice(8, 16), 16), 0);
}
Enter fullscreen mode Exit fullscreen mode

I validated the method on a self-compiled test circuit (circom 2.2.3) first, fixing one real bug: the BN128 prime must be written to the witness header without modular reduction, or the header is all zeros and the run fails with "Curve of the witness does not match the curve of the proving key."

The driver worked — but I still did not know what any input meant. So I mapped the roles empirically: vary one input at a time, and in pairs, and watch which witness signals change and which assignments trip the circuit's assert. The sweeps gave up this shape:

wire role (inferred) constraint
2, 3 two vk hashes must be equal
4, 5 minimum, public balance 4 ≤ 5
6, 12 decision, duplicated (boolean) must be equal
7 proof-reuse flag must be 0 to approve
8, 9 recipient, condition recipient must be equal
10, 11 current block, expiry block 10 ≤ 11
13 task id (public) any field element
14 salt (private) any field element

The 13 public signals are [Poseidon(salt, taskId), wire2 … wire13] — the first is a Poseidon commitment, confirmed with circomlibjs: Poseidon([0,0]) equals the all-zero witness's first signal exactly, and Poseidon([salt, taskId]) equals the valid scenario's first signal exactly (salt first, taskId second).

One honest gap: the amount / max-transfer pair has no separately identifiable circuit input. That check runs off-circuit in the decision function, and the platform re-checks it from the task envelope.

The gatekeeper itself

The decision function is six deterministic checks:

const checks = {
    vk_match: normalize(task.registeredVk) === normalize(task.proofVk),
    balance_sufficient: BigInt(task.minimum) <= BigInt(task.publicBalance),
    recipient_match: normalize(task.recipient) === normalize(task.conditionRecipient),
    amount_within_cap: BigInt(task.amount) <= BigInt(task.maxTransfer),
    not_expired: BigInt(task.currentBlock) <= BigInt(task.expiryBlock),
    proof_unused: task.proofUnused === true,
};
Enter fullscreen mode Exit fullscreen mode

No failures means APPROVE; otherwise REJECT with the first failed check's reason.

The sample task follows the event page's own shape: both vk fields equal to the gatekeeper vkHash, minimum 1,000 against a balance of 1,460, amount 500 against a cap of 1,000, current block 19,832,000 against expiry 19,842,201, recipient 0x6E8f…D8e9, proof unused, task id 20261115001, salt 98765432109876543210 (a demo value — in production the salt must be a per-agent secret, never published).

Three scenarios, with receipts

Scenario A — valid task, APPROVE. All six checks pass. The pipeline produced a 647-signal witness and a Groth16 proof of 804 bytes in 1,723 ms:

[valid_approve] decision=APPROVE (VALID_CONDITION) | witness 647 signals | proof 804 B | prove 1723 ms | verify OK!
Enter fullscreen mode Exit fullscreen mode

Exact verify output, re-run independently after the build: [INFO] snarkJS: OK! Public signals: [Poseidon commitment, vkHash (mod prime) twice, 1000, 1460, 1, 0, recipient twice, 19832000, 19842201, 1, 20261115001]; salt absent, as expected.

Scenario B — trap, recipient mismatch, REJECT. Same task, but the condition recipient changed to 0x1111111111111111111111111111111111111111. The gatekeeper rejects, and the proof of that rejection verifies:

[trap_recipient_mismatch] decision=REJECT (RECIPIENT_MISMATCH) | witness 647 signals | proof 806 B | prove 1431 ms | verify OK!
Enter fullscreen mode Exit fullscreen mode

The public signals match scenario A except the decision wires read 0 and the condition-recipient value differs. The 2-byte gap (804 vs 806) is JSON number-length formatting, not a different proof structure.

Scenario C — wrong APPROVE, forced. Same trap task, decision forced to APPROVE:

[trap_wrong_approve_attempt] witness FAILED as expected: Assert Failed. Error in template GatekeeperEvaluation_79 line: 102
Enter fullscreen mode Exit fullscreen mode

No proof was produced — the circuit refused to build a witness for a decision that contradicts its checks. The gatekeeper cannot prove a lie about an approval. That is the fail-closed behaviour the tournament's design promises, demonstrated rather than asserted.

The finding: the circuit guards approvals, not rejections

The three scenarios reveal an asymmetry I verified with dedicated isolation tests, and it is the most important thing this build taught me:

  • APPROVE builds a witness only if every in-circuit check passes. A wrong APPROVE — any check failing — fails witness generation at GatekeeperEvaluation_79 line: 102. No proof can be produced.
  • REJECT always builds a witness, whether the checks pass or fail. A wrong REJECT is therefore caught by the platform's scoring (−3), not by the circuit.

Rejecting is always safe; approving authorises a transfer, so approval is fail-closed — which sharpens the field guide's warning rather than softening it. The circuit is a referee for approvals. For rejections, the scoring is the referee. Anyone who treats "proof verified" as "decision correct" has misunderstood the machine in both directions.

A correction is owed here, stated carefully. During research I found two participant writeups that list specific input names for this circuit and claim a wrong REJECT also fails proof generation. I checked both claims against the artifact itself: none of those names matches any of the 13 FNV hashes in the wasm, and a wrong REJECT builds a witness without complaint — my all-zero baseline witness proves it. Those two specific claims do not hold against this circuit, and anyone building on them will waste hours chasing names that are not in the artifact and a failure mode that does not exist.

Limits, stated plainly

This is a local demo, and I will not dress it up as more:

  • No public endpoint, no live arena submission, no VFY spent, no mainnet transaction, no arena account. This proves the logic and the proof pipeline; it does not enter the tournament.
  • Input signal names are unpublished and unrecoverable. The role map above is derived from observed circuit behaviour and labelled as inference.
  • The amount / max-transfer pair has no separately identifiable circuit input; that check runs off-circuit in the decision function.
  • The published gatekeeper vkHash was not reproduced from the exported verification key with any of the seven keccak256 encodings I tried. This is not blocking — the proofs verify against the key exported from the same official zkey, which is the authoritative check — but the exact vkHash derivation the platform uses is unpublished.
  • The salt here is a fixed demo value, and the task id was chosen locally. In production the salt must be a per-agent secret and the task id must come from the platform's signed task envelope. The Poseidon commitment is Poseidon(salt, taskId), salt first; the order here is what the circuit computes.

Closing

I started this build expecting the hard part to be the cryptography. It was not. The hard part was a missing piece of documentation — thirteen input names that do not exist anywhere public — and the discipline to not invent them. The circuit would not say its own name, so I learned to address it by hash, to map it by experiment, and to let its asserts tell me what it enforces. What it enforces is narrower and more interesting than the slogan version: it guarantees that no wrong approval can ever carry a proof. It guarantees nothing about a wrong rejection. Knowing exactly where the cryptography ends and the scoring begins is the difference between an agent you can trust and one you can only hope about.

The code, inputs, proofs, and build notes are the receipts for everything above. A proof can verify perfectly and still authorize the wrong action. I verified that, too. #AgentGames

Top comments (0)