← Writing

A proof can verify and still be wrong

Part 6 of Zero Knowledge, Full Proof. We built an auction house where the bidders are AI agents, with a zero-knowledge layer to keep them private. What the agents do, what the proofs were meant to show, and three places where a valid proof proved the wrong thing.

An Agent Auction room: pixel-art agents on a dark grid, a banner reading Sold! winner Agent ••••80, 20 USDC, an agent's reply saying the auction for the task “CRE Workflow” closed with agent 1680 as the winner, and a table of final bids.

Part 6 of Zero Knowledge, Full Proof, a series on zero-knowledge proofs and privacy on-chain.

On Friday, Chainlink announced the results of Convergence, its hackathon for the Chainlink Runtime Environment, and Agent Auction Protocol was named one of the Top 12 projects out of more than 500 submissions [1], [2]. It was my first hackathon. Yuzhi Zeng, Haorui Chen and I designed it together over February; Yuzhi wrote most of the code, from the contracts to the MCP server, Haorui the animated auction room, and I the cryptography. This post is about the agents, the zero-knowledge layer I built for them, and what I learnt from it: a proof that verifies has not necessarily proved anything useful.

An auction house where the bidders are agents

Our starting point was that AI agents are about to buy things from each other: compute, data, small jobs. Today an agent that needs work done goes to one platform and pays one price. An auction fixes two things at once. It discovers the price, because several capable agents compete, and it can settle without trusting the host, if the rules are enforced somewhere the host cannot touch. Say Alice’s agent needs a GPU for an hour: it finds Bob’s idle one on the protocol, bids against other agents in a private auction, and the result is settled only after it has been checked.

Every participant is an agent with an identity under ERC-8004, the draft Ethereum standard for agent identity and reputation [3]. The identity is a numeric ID owned by a wallet, so an agent can rotate its keys and keep its history. Agents can call our REST API, but the main door is a server that speaks the Model Context Protocol, the standard way for an AI model to call tools [4]; by the deadline it exposed fifteen tools, from discover_auctions and post_bond to join_auction, place_bid and claiming a refund. Any agent framework that speaks MCP can take part, and the server does the signing, the proofs and the nonce bookkeeping on the agent’s behalf. Discovery reads are paid for over x402, an HTTP micropayment protocol [5].

Designing for a model rather than a person changed small things. An agent does not hesitate over a confirm button, so every action is a signed message with a nonce and a deadline, and the rules it plays by are written down where it can read them: the server ships six prompt templates, among them the auction rules, a bidding strategy and the participation loop. Its wallet is a raw key or one on Coinbase’s developer platform. And the object being sold is only the hash of a manifest. We built it to auction NFTs and demoed it with a task, but the auction contract, the settlement and the proofs only ever see that hash, and the README sets out three tiers of tasks, from code that has to pass its tests to work that needs an audit. It is closer to a labour exchange than to an NFT marketplace.

The Agent Auction architecture as the landing page draws it, in three stacked layers. Layer 1, agent layer: MCP gateway over streamable HTTP and an HTTP REST API, where agents discover, join and bid. Layer 2, auction engine: sequencer, append-only event log and room broadcast, on Cloudflare Workers and Durable Objects. Layer 3, blockchain layer: identity through the ERC-8004 registry, payment through AuctionEscrow USDC bonds, privacy through a ZK membership proof with Groth16, agent wallets through Coinbase's CDP server wallet and AgentKit, and settlement through the Chainlink CRE settlement workflow.
The three layers, from our landing page. The privacy box sits on-chain because the registry of roots does; the proofs themselves are checked in one place only, the engine in the middle layer.

An agent posts a USDC bond to the escrow contract, then signs a join and its bids as EIP-712 typed messages, which bind each action to our chain and contract [6]. The engine runs one Cloudflare Durable Object per auction, a single-threaded process that gives every action a sequence number and hash-chains the log, so the order of bids cannot be rewritten afterwards [7]. When the auction closes, the engine records the result on-chain, and the event starts a workflow on Chainlink’s decentralised oracle network. It re-reads the winner from the chain, checks that the full event log matches the hash committed on-chain, and only then tells the escrow to release the winner’s bond and let everyone else claim theirs back [8]. In our demo video, two agents bond 10 USDC each and bid up to 20; Agent ••••80 wins, and the loser claims its bond back through the same tools.

The settlement workflow as the landing page lists it: a trigger on the AuctionEnded event at finalized confidence; phase A checks the auction is closed on-chain; phase B cross-checks the winner's agent ID, wallet and final price; phase C fetches the replay bundle from the platform API and verifies it is non-empty; phases D and E have the oracle network sign a report that reaches AuctionEscrow through the KeystoneForwarder; the result releases the winner's bond and lets losers claim refunds.
The settlement workflow on our landing page. By the deadline, phase C did more than the page says: it hashed the bundle and compared it with the hash recorded on-chain.

We built it fast. The repository had 383 commits by the deadline, most made at night, and 143 of them carry the co-author line of an AI coding agent, briefed by the CLAUDE.md and AGENTS.md files that still sit at the top of the repository [9]. That speed is part of this story.

What the zero-knowledge layer was for

If rival agents can see who is bidding and how much, they can learn each other’s strategies and bid against them. We wanted an agent to prove it was entitled to join without saying which agent it was, and to prove its bid was valid without showing it.

I wrote two circuits in Circom, proved with Groth16 [10]. The first, RegistryMembership, has 12,107 constraints. The agent holds a random secret. A leaf in a Merkle tree of depth 20 is the Poseidon hash of a capability ID, the secret and the leaf’s index, and the circuit proves that the agent knows a leaf under a given root. It has three public inputs: the root, a commitment to the capability, and a nullifier, Poseidon(secret, auctionId, 1), which should appear only once per auction. The second, BidRange, has 650 constraints and proves that a committed bid lies between the reserve price and the agent’s budget. Both hash with Poseidon, designed for circuits [11]: a two-input Poseidon hash costs a few hundred constraints, Keccak around 150,000 [12]. A proof takes about two seconds on the agent’s machine and is checked by the engine, off-chain, at no gas cost.

A proof can verify and still be wrong

A Groth16 verifier answers one question: does the prover know a witness that satisfies the circuit for these public inputs? It says nothing about whether those public inputs are the right ones. That is the application’s job, and I found three places where ours did not do it: one in the last week before the deadline, two while rereading the code afterwards.

ProofWhat it showedWhat was missing
Membership"I am in some tree"a check against the agent's registered root
Nullifier"used once in some auction"the auction ID as a public input
Bid range"some committed number is in range"a link to the bid actually placed
Three proofs that passed every test, and what each one needed to mean what we claimed.

The first was the root. The registry contract I had written in February built its tree with Keccak, while the circuit proved membership against a Poseidon root, so the two could never match. On 1 March a check comparing them was added, which would have rejected every proof had proofs been enforced; on 2 March it was removed, with a comment in the code: “These roots will NEVER match … Do NOT reinstate this check.” From then on the engine checked that a proof was valid, but not what it was valid against. Anyone could build a one-leaf tree around a secret of their own and prove membership in it. The proof verified, and the tests passed, because the tests generated honest proofs.

I wrote the fix on the night of 3 March, and it reached the main branch the next evening [13]. By the deadline, with follow-ups from Yuzhi and me, each agent stored its Poseidon root and capability commitment on-chain, only the owner of the ERC-8004 identity could register them, and the engine compared the proof’s root with the stored one and refused the join if either was missing. The fix also made plain something I had glossed over. Every join already carried the agent’s ID, and the engine now needs it to look up the root, so the proof hides the agent’s secret and capability, but not who the agent is. Hiding the agent from the engine would need a single tree shared by all agents, which we did not have time to build.

The second was the context. The nullifier was computed from the auction ID, but the auction ID was a private input, so the verifier could not tell which auction the nullifier was made for. What actually stopped an agent from joining twice was a plain record of each agent and auction, which I added on 5 March, keyed by the agent ID that every join carries. A nullifier is only as useful as the context it is bound to, and binding means a public input the verifier checks.

The third was the bid. Our engine ran two kinds of auction. In a sealed-bid auction, an agent committed to its bid with a BidRange proof and revealed it later, and the engine checked the revealed bid against the commitment, so the proof was tied to the bid [13]. In an English auction, every bid was sent in plain text and broadcast to the room. The engine still required a BidRange proof there, and it verified, but nothing tied the proof’s commitment to the amount actually bid, or its reserve price to the auction’s. The proof showed that some number was in some range. On top of that, the settlement workflow never re-checked a single proof: it trusted the engine’s log, and the log trusted the proofs.

None of this broke the demo, which is exactly the problem. The line I added to our auction pages’ privacy panel, sixteen hours before the deadline, said that agents prove their qualifications without revealing identity and that bids stay private until settlement. The first half was not quite true for any of our auctions, since every join carried the agent’s ID, and in English auctions the second was not true either.

A standard, the day after

Twelve hours after we submitted, just after midnight on 10 March in Singapore, Virtuals and the Ethereum Foundation’s dAI team announced ERC-8183, “Agentic Commerce” [14], [15]. It defines a job: a client locks a budget in escrow, a provider submits the work, and an evaluator attests before the money moves, with ERC-8004 recommended for reputation. It is close to the shape we had sketched on our own in February, with the Chainlink workflow roughly in the evaluator’s seat, though our escrow held bonds rather than a budget. Later that morning I added a section to our README placing the two side by side. ERC-8183 covers a job once a provider has been chosen; an auction is how to choose one when several agents compete. The standard leaves bidding to optional hooks, and privacy out altogether.

That is the part we tried to add, and the part this post is about. What I would do differently is short. Make every value a proof depends on a public input: the root, the auction, the chain, the action, the deadline. Check each one against a source of truth, not against what the prover sent. Verify where the money moves, not only where the bids arrive. And encrypt whatever the proof claims to hide, because a zero-knowledge proof hides its witness and nothing else.

References

  1. Chainlink. Agent Auction Protocol. Convergence: A Chainlink Hackathon, winners gallery, Top 12 projects. chain.link/hackathon/winners/agent-auction-protocol.
  2. Chainlink. Over 500 submissions. $120,000 in prizes. 34 winners. X, 27 March 2026. x.com/chainlink.
  3. Marco De Rossi, Davide Crapis, Jordan Ellis and Erik Reppel. ERC-8004: Trustless Agents. Draft, 13 August 2025. eips.ethereum.org/EIPS/eip-8004.
  4. Anthropic. Introducing the Model Context Protocol. 25 November 2024. anthropic.com.
  5. Coinbase. x402: an open standard for internet-native payments. x402.org.
  6. Remco Bloemen, Leonid Logvinov and Jacob Evans. EIP-712: Typed structured data hashing and signing. 2017. eips.ethereum.org/EIPS/eip-712.
  7. Cloudflare. Durable Objects. Developer documentation. developers.cloudflare.com/durable-objects.
  8. Chainlink. Chainlink Runtime Environment (CRE). Documentation. docs.chain.link/cre.
  9. Yuzhi Zeng, Martina Lee and Haorui Chen. Agent Auction Protocol. GitHub repository, as submitted on 9 March 2026. github.com/whatthehackinsg/agent-auction.
  10. Jens Groth. On the size of pairing-based non-interactive arguments. In EUROCRYPT 2016, pp. 305–326. IACR ePrint 2016/260.
  11. Lorenzo Grassi, Dmitry Khovratovich, Christian Rechberger, Arnab Roy and Markus Schofnegger. Poseidon: a new hash function for zero-knowledge proof systems. In 30th USENIX Security Symposium, pp. 519–535, 2021. IACR ePrint 2019/458.
  12. Vocdoni. keccak256-circom. GitHub repository. github.com/vocdoni/keccak256-circom.
  13. Martina Lee. fix critical ZK security issues + sealed-bid commit-reveal (Phases 1-5). Commit 917ad30 in the Agent Auction repository, 4 March 2026. github.com.
  14. Davide Crapis, Bryan Lim, Tay Weixiong and Chooi Zuhwa. ERC-8183: Agentic Commerce. Draft, 2026. eips.ethereum.org/EIPS/eip-8183.
  15. Virtuals Protocol. Introducing ERC-8183: the commerce layer for AI agents. X, 9 March 2026. x.com/virtuals_io.

More writing →