95 of 141 documented ZK bugs had nothing to do with cryptography. Here is what actually goes wrong, and the design decision that stops it.
Ask most people what could go wrong with a zero-knowledge system and you will hear about quantum computers. Maybe a flaw in the underlying maths. Maybe a trusted setup ceremony gone bad.
Those are real concerns. They are also, in practice, almost never what breaks.
Charles Hoskinson published a 357-page guide to zero-knowledge proof systems this year called Proving Nothing. It is free under a Creative Commons license, and buried in chapter three is a sentence that reframed the whole subject for me.
“The most common way a zero-knowledge system fails in practice is not a cryptographic break. It is not a quantum computer. It is not a governance attack. It is a bug in the program.”
Of 141 documented real-world ZK bugs, 95 were the same category of mistake. Not exotic. Not cryptographic. Ordinary programming errors in the circuit.
And the most famous one came down to a single character.
The character that broke Tornado Cash.
To understand it, you need to know something strange about how most ZK circuits are written.
In Circom, the most widely deployed ZK language by project count, every line of code is doing two jobs at once. It describes how to compute a value and it describes the mathematical rule that the proof system will enforce on that value.
Two jobs, two different operators. An arrow computes. A triple equals constrains. A combined operator does both.
The Tornado Cash bug was that a developer used a plain equals sign, ordinary JavaScript assignment, where the constrained version was needed.
Read what that means carefully, because it is genuinely unsettling. The witness generator computed the correct value. Everything looked right but the constraint system never enforced it.
Which means a malicious prover could substitute any value they liked, and the proof would still verify.
One character. Complete soundness break. The proof system worked perfectly and proved something false.
It is not a rare edge case.
A static analysis tool called ZKAP went looking for this class of bug systematically. Across 258 circuits in 15 open source projects, it found 34 previously unknown vulnerabilities.
The root causes it identified are almost mundane. Circuit outputs that can take more than one value because a constraint is missing. Sub-circuits called without properly constraining what goes in or comes out. Places where the code that computes a value and the code that constrains it quietly disagree.
Division was the single most common culprit. In computation, division is division. In constraints, it gets expressed as multiplication. When the divisor can be zero, those two things stop behaving the same way, and the gap between them is where the exploit lives.
None of this requires breaking any cryptography. The maths holds up fine. The maths is being asked to prove the wrong thing.
Two ways to respond to that
If you accept that the dominant failure mode is developer error rather than broken cryptography, you have a design choice to make.
You can document the footguns, write good linters, build static analysers, and train developers to be careful. That is the mainstream approach and it is not wrong. ZKAP exists because of it.
Or you can decide the compiler should simply refuse.
This is where Compact, Midnight’s smart contract language, does something I had not seen framed this way until I read it in Hoskinson’s book.
Rather than giving developers direct access to constraints and hoping they get it right, Compact’s compiler enforces a disclosure rule: witness values are private by default, and any attempt to use a private value in a public context without explicit consent is a compile-time error.
And then the line that matters:
This is not a style guide. It is not a best practice recommendation. It is a hard compiler rejection.
What the refusal actually looks like
Private data enters through a witness function. It runs off-chain, on your own machine, and by default nothing it touches is visible to the chain at all. You could write a hundred lines of witness logic and none of it would leave your laptop.
To use any of it inside a circuit, where it will be proven, you have to call disclose() on it explicitly. If you do not, the build fails. And the error does not just say no. It traces the whole path:
potential witness-value disclosure must be declared but is not:
witness value potentially disclosed:
the return value of witness get_amount at line 8 char 1
nature of the disclosure:
ledger operation might disclose the witness value
via this path through the program:
the argument to increment at line 11 char 8
It tells you which private value, why it would leak, and the exact route it took to get to the surface.
The analysis runs as part of a 26 stage compilation pipeline, at an intermediate stage the compiler literally names for the absence of disclosure. The privacy boundary is checked before a single line of code is generated.
Why did this get under my skin?
I hit this wall myself, months before I understood what it was.
I was writing a sealed-bid auction contract and I moved a private value somewhere it would have ended up visible on chain. On a transparent blockchain that is not even a thought you have. The data is public anyway. There is no wrong move to make.
The build failed. I was mildly annoyed, added the disclose() call, moved on, and thought of it as a quirk of an unfamiliar language.
Reading that chapter, I understand what actually happened. I walked into a wall that was put there deliberately, at compile time, before code generation, because 95 out of 141 people in the same position walked off a cliff instead.
Hoskinson’s book describes the developer experience better than I could:
“The developer experience is not one of navigating cryptographic abstractions. It is one of writing normal code inside a system that has opinions, strong, enforced, non-negotiable opinions, about what leaves the room.”
The part worth taking away
Zero-knowledge proofs get sold as cryptographic magic, and the cryptography genuinely is remarkable. But the cryptography is not where systems fail.
They fail in the gap between what a developer meant and what they actually constrained. They fail at two in the morning when someone is tired and types one character instead of three.
You cannot audit your way out of that at scale. You can only design so the mistake is impossible to make quietly.
That is the same argument I made a couple of weeks ago about foundations, and I did not realize at the time how literal it was. Privacy that depends on every developer remembering to be careful is a promise. Privacy the compiler refuses to let you break is a property.
One is a policy. The other is a wall.
Where to go next
Proving Nothing is free, Creative Commons licensed, and 357 pages. If any of the above was interesting, chapter three is where the failure modes live and chapter four covers the witness boundary in depth. You do not need to read it in order.
And if you would rather listen to cool jams and vibe code rather than read boring concepts :), the community has just launched Midnight Radio, which is worth a follow.
I will keep digging into the parts of this ecosystem that nobody is writing about. The numbers, the failure modes, the things that only show up when you actually try to build something and it refuses to work.



