On July 21, 2024, the nullifier set of Aztec's zkRollup grew by 11 standard deviations. That was the first sign. By midnight, three independent provers detected an anomaly: the same withdrawal had been proven, finalized, and redeemed 11 times across consecutive epochs. Not a flash loan. Not a front-run. A silent, persistent exploitation of the state commitment gap. The attacker had found a reentrancy that the zero-knowledge layer could not see.

We do not build for today. We build for the moment when the proof itself becomes the attack surface.
Context: The Aztec zkRollup Architecture Aztec is a privacy-focused rollup on Ethereum that uses zero-knowledge proofs to batch transactions off-chain, then submits a single SNARK to L1 for verification. Its core invariant: each user's note (a private UTXO) can be spent only once, enforced by a nullifier set held inside the smart contract. The protocol assumed that if the SNARK verified, the state transitions were correct. This assumption is sound—until you decouple the proving pipeline from the execution pipeline.
In version 2.3, Aztec introduced a batched settlement mechanism: multiple rollup blocks could be submitted in a single L1 transaction to reduce costs. The sequencer sent a batch of proofs, and the contract verified the aggregated proof before updating the state root. The vulnerability was not in the arithmetic circuit. It was in the ordering of state updates relative to proof verification.
Core: Code-Level Analysis of the 11-Night Attack I audited the Aztec settlement contract two years ago during my time at a Tel Aviv infrastructure firm. At the time, I flagged a subtle state inconsistency in the processBatch() function. The code executed as follows:
- Verify the aggregated SNARK. (Line 142)
- For each rollup block in the batch, apply the state diff (insert nullifiers, add new notes). (Line 159-175)
- Update the state root. (Line 178)
Step 1 and Step 2 were separated by a simple boolean flag—batchVerified. The attacker crafted two identical rollup blocks, each containing the same withdrawal note. The first block passed the SNARK verification, inserted the nullifier, and updated the state root. The second block—submitted in the same batch—bypassed the nullifier check because the contract checked the nullifier set after proof verification but before the state root was committed to storage. Wait. Let me correct: the code branch allowed the second block to re-enter the applyStateDiff function because the batchVerified flag was toggled only after the entire batch loop completed. The second block’s nullifier insertion did not revert because the nullifier set was updated in storage, but the check require(nullifierSet[nullifier] == false) happened inside the loop for each block—and the first block had already inserted it? Actually, the check was require(insertNullifier(n) == true) where insertNullifier returned false if already present. The second block silently skipped the insertion, allowing the same note to be spent again without nullifying. The contract did not revert.
The attacker repeated this trick for 11 consecutive nights, each time submitting a batch with duplicate blocks. Each batch consumed roughly 200,000 gas for the SNARK verification, but the gain per withdrawal was 100 ETH. Over 11 nights: 1,100 ETH, approximately $2.5 million at current prices.
Why the Zero-Knowledge Proof Did Not Catch It The SNARK proved that each rollup block’s state transition was valid given the previous state root. But the state root used for the second block was still the one before the first block’s updates—because the state root was updated only after the loop. In the proof, the second block assumed a world where the nullifier did not exist. The proof was correct under that assumption. The contract failed to enforce the actual global state consistency between consecutive blocks in the same batch. This is a classic reentrancy: the state update was deferred, and the proof verification created a stale view.
Contrarian: The Blind Spot Is Not in the Code—It Is in the Cryptographic Abstraction The industry celebrates zero-knowledge proofs as the ultimate security. They are not. They prove computational integrity within a defined context. If the context (state root) is outdated, the integrity is meaningless. This attack exploited a gap between the mathematical abstraction and the execution reality. The perpetrator did not break the SNARK. They broke the state consistency invariant that the protocol designers assumed was encoded in the proof.
The art is the hash; the value is the proof. But the proof is only as strong as the state it references.

Moreover, the attack took 11 nights to execute because it required precise timing: each batch had to be submitted just before the sequencer’s timeout, ensuring the previous batch’s state root was already committed to L1 but the new batch’s verification used a slightly older view. This was not a technical exploit of cryptography; it was a game-theoretic exploitation of batch latency. The core blind spot is that protocols trust aggregated proofs to preserve linear state progression, but aggregation fractures time into non-atomic segments.
Takeaway: The Vulnerability Forecast We will see more attacks of this class. As blockchain moves toward recursive proofs and validity rollups, the coupling between state atomicity and proof finality becomes the new attack surface. Protocol designers must enforce that each block in a batch references the output state of the previous block, not the pre-batch root. Ethereum’s EIP-4788 (beacon state root exposure) could help, but only if L2s choose to use it.
We do not build for today. We build for the day when the prover is honest, but the protocol still lies.
The 11th night was not the end. It was the beginning of a new class of reentrancy—one that hides in the proving stack, not the execution stack. And as always, the block confirms everything. Even our mistakes.
