Witnessed, Not Constrained
The proof was valid. The ZEC was fake.
Hey, it’s Arsen.
In today’s menu:
• How an AI agent found a 4-year-old ZK circuit inflation bug
• Bounty retired, silent patch, then a $10.7M exploit
• One missing height check that split a chain permanently
• And more…
🏴☠️ Vulnerability
An AI found a 4-year-old inflation bug in Zcash’s Orchard circuit
You’re auditing a ZK circuit. The attack surface is the constraint system, not the business logic.
Orchard enforces honest note-spending through a scalar multiplication gadget: pk_d^old = [ivk]g_d^old. Prove your viewing key matches the note’s address. This ties the nullifier you reveal to a real note — blocking double-spends.
For performance, the gadget uses incomplete addition in its middle stage. The base point g_d gets fed in via two assign_advice() calls.
What’s the difference between assign_advice() and copy_advice()?
assign_advice() places a witness value. No constraint. The prover sets it freely. copy_advice() copies from a committed cell — it enforces equality in the circuit.
The loop constrained all base values to equal each other. Nothing constrained them to equal the actual g_d. A malicious prover picks a different base, does the algebra, and generates a valid-looking proof for any key-address pair they choose.
Wrong key → wrong nullifier → spend the same note as many times as you want, each revealing a fresh nullifier.
The fix was two lines. assign_advice() → copy_advice() in the first iteration. Existing constraints carry correctness through the rest of the loop.
Claude Opus 4.8 caught this on its first un-directed run, using a custom agentic audit framework. Prior runs using Opus 4.7 missed it on generic prompts. The bug had been live since Orchard’s May 2022 activation — through multiple professional audits.
ZODL deployed a soft fork in under 3 days. No funds were stolen.
The proof was valid. The key was wrong. The ZEC was infinite.
🗞️ News
V12 disclosed a critical THORChain bug. They got silence — then a $10.7M exploit.
V12 reported a proposer-forgery vulnerability to THORChain on April 28. A malicious validator acting as block proposer could forge unsigned finality data, releasing outbound funds before a source deposit confirmed. Every chain integrated with THORChain was exposed.
THORChain’s developers pushed a fix on May 6. The commit — “sign full ObservedTx wrapper to prevent proposer forgery” — addressed exactly what V12 described. It failed CI. It never shipped to validators.
Nine days later, $10.7 million left an Asgard vault. Blockaid confirmed the attack matched what the May 6 patch was written to stop.
What did V12 get for disclosing a fund-draining bug before a $10.7M exploit?
The bounty program is “permanently retired.” Reason given: too many AI-generated submissions.
V12 now plans to publicly release exploit code for additional THORChain vulnerabilities. The protocol still holds ~$30M TVL. No post-mortem has been published.
This isn’t just a payout dispute. A patch was written, failed CI, and nobody flagged it for re-review before the attack window closed. That’s a process failure as much as a code failure.
THORChain’s own docs still promise a bounty for verified critical bugs. The docs didn’t get the memo.
📚 Education
One missing height check. A hard fork to fix it. A $6,710 lesson.
You’re reviewing a full node’s block-processing function. It reads blocks from the DA layer, deduplicates, executes, records. Clean loop. One problem.
The deduplication check:
if self.da_db.has_executed_block(block_id.clone()).await?
Checks block ID. Not height.
What happens when two blocks share the same height but have different IDs?
Both pass. Both execute. Both get recorded as valid. The chain is now permanently split at that height.
Fork-choice logic exists precisely to prevent this. When two validators produce competing blocks at height H, the node picks one and rejects the other. Without that logic, a brief network partition is enough — both blocks arrive, both process, no error is raised.
Yunus Emre Sarıtoprak reproduced the split in a local test: two Celestia validators, a simulated disconnect, reconnection. No code modification. The logs showed the node process Block A, then Block B at the exact same height, with no complaint.
There’s no automatic recovery from this. A permanently split chain requires a hard fork — every validator and node operator upgrades in sync. Movement Labs pushed one.
The fix: mark a height as occupied once a block is processed for it. Reject anything else claiming the same slot.
One field. Not checked. One rule — one canonical block per height — broken without ceremony.
That’s it for this week.
Reply with the Solana bug, tool, or pattern you want me to cover next — I read every one.
If a working Solana auditor in your circle would find this useful, forward it their way.
— Arsen, working Solana auditor




