DOC# VANTAL SLUG vanta_l1_nullifier_set PRINTED 2026-05-06 03:47 UTC

L1 nullifier sets: enforcing no-double-spend at consensus

Most privacy chains track spent notes in a wallet-side index and pray. Vanta puts the nullifier set in chainstate and lets the consensus rules do the praying. Here's why that line moved, and what it costs.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/vanta_l1_nullifier_set/
FILED
2026-04-17 05:52 UTC
REVISED
2026-04-17 05:52 UTC
TIME
7 min read
SERIES
Vanta In Practice
TAGS
#vanta #zk #nullifier #consensus #bitcoin #utxo

This is a follow-up to Vanta: a Bitcoin fork with ZK at consensus and a sibling to Nullifiers without the witchcraft. The first post explains the chain. The second explains what nullifiers are. This one is about a deliberate, opinionated design decision: nullifiers in Vanta live at consensus, not at the wallet.

I want to walk through what that means, what alternatives we considered, what it costs, and why the cost is worth paying.

The problem statement

In a shielded UTXO model, every spent note has a deterministic, single-use nullifier — a hash that proves to a verifier “some unspent note has been consumed” without revealing which one. The classic Zcash construction is roughly Poseidon(note_secret_key, commitment). The same secret + the same commitment always produces the same nullifier; revealing it twice means two spends of the same note.

The verifier needs to know the global set of nullifiers ever revealed. If the same nullifier appears twice, one of the two spends is invalid. That’s how double-spend is detected.

The question every chain has to answer: where does that nullifier set live?

Three answers

Answer 1 — wallet-side index

The original Zcash sapling protocol materialises the nullifier set client-side. Every wallet trying to spend reads the chain, builds a local nullifier set, and refuses to construct a transaction whose nullifier already appears.

This works. It’s also fragile in a way that always made me uncomfortable. A wallet bug — or a malicious wallet — can construct a transaction whose nullifier matches a previous one. The transaction is valid by chain rules until the second spend is mined; only then do nodes notice. In practice this means a brief reorg window where a double-spend is technically possible.

It also means node operators can’t run a privacy chain without the wallet code. That’s a sociological problem more than a technical one, but it’s real.

Answer 2 — separate nullifier-tracking smart contract / sidechain

The Tornado-Cash-on-Ethereum approach. The nullifier set lives in a smart contract. The contract enforces uniqueness as a side effect of every withdraw. The chain itself doesn’t know what nullifiers are — it just runs the contract.

This works on Ethereum because Ethereum has expressive smart contracts that can hold and mutate large state cheaply (relative to L1 gas). It’s a non-starter on a Bitcoin-fork chain because Bitcoin Script doesn’t have arbitrary stateful contracts. You could put a precompile in. We didn’t want to.

Answer 3 — chainstate

The nullifier set lives in the same database the UTXO set lives in. Validating a block means (a) checking script signatures, (b) checking the witness ZK proofs, (c) checking that no two spent nullifiers in this block (or this block + history) collide. Nodes that don’t enforce nullifier-uniqueness reject blocks the network considers valid. They literally cannot stay in sync.

This is what Vanta does.

Why we picked answer 3

Three reasons.

Soundness

A nullifier collision in chainstate is a consensus violation, not a wallet bug. There is no version of the network where the double-spend is “valid for a few blocks until someone notices.” Either the block is valid or it’s not. The confidence story is the same as Bitcoin’s UTXO model: a confirmed transaction is final under the same assumptions every other Bitcoin transaction is final under.

This matters for an audience that already understands Bitcoin’s finality assumptions. We did not want to introduce a new set of finality caveats for the privacy layer.

Operator simplicity

A node operator running vantad doesn’t need to also run wallet software, doesn’t need to trust an indexer, doesn’t need to subscribe to a third-party “nullifier feed.” The chain validates itself. This is the same reason most exchanges run their own Bitcoin nodes instead of trusting Blockchain.info: chainstate is the source of truth.

Wallet flexibility

If the chain owns nullifier-uniqueness, wallets become commodity software. You can have ten different wallets, three different proof systems, an iOS-native client, a CLI, a hardware-wallet integration — and they all rely on the same chainstate validation. The wallet’s job collapses to “construct a valid spending transaction.” The chain is the arbiter.

What it costs

Nothing is free. Three real costs:

Storage

Every nullifier ever revealed has to live in chainstate forever. With Poseidon-2 over BN254 the digest is 32 bytes. Vanta is a 1-minute-block chain with 100k VANTA per block; assume a long-term steady state of, say, 5 nullifiers per block (transparent transactions don’t burn nullifiers; only shielded spends do). At ~525,600 blocks per year, that’s 5 × 525600 × 32 = ~84 MB of nullifier state per year. After ten years: ~840 MB.

Compare to Bitcoin’s UTXO set, which is currently ~12 GB. We’re well below it. Storage is not the limiting factor.

Sync time

A new node has to download and verify the nullifier history. The verification cost is just a hash check per nullifier (no proof re-verification needed if the block was already validated by the network — the witness root in the coinbase commits to the SMT). At a few microseconds per hash, ten years of history validates in ~half an hour on a modern CPU. Acceptable.

Sparse-Merkle-tree maintenance

This is the real cost. We commit to the root of the nullifier set in every coinbase, so that light clients and SPV-style verifiers don’t need the full chainstate to verify a proof. Maintaining an SMT over a growing set of 32-byte hashes is non-trivial. We use the smirk Rust crate (an SMT library written for exactly this kind of consensus-state use case) and the marginal cost per insert is O(log n) hashes — a few hundred microseconds in practice.

The implementation lives in vanta/ (the Rust subtree) and the binding into the C++ core happens in src/validation.cpp via FFI. TODO: Dax confirm exact SMT crate namesmirk is what we use today; if we switched to a custom implementation note that here.

The witness-v2 dance

Here’s the part that took me longest to get comfortable with. Bitcoin’s witness data (segwit) is verified after the script. We needed the ZK proof to be verified after the script too — the script confirms the spender knows the right commitment, the proof confirms the spend is valid under the rules of the shielded pool.

Vanta extends segwit to a “witness v2” format that includes:

  1. The classic script witness (signatures, etc.).
  2. A new proof_root field — a 32-byte commitment to the proof’s public inputs.
  3. A new nullifier field — the 32-byte nullifier the spend is consuming.

The C++ validator does three checks in order:

  1. The script verifies (standard segwit path).
  2. The nullifier is not already in the chainstate nullifier set.
  3. The proof_root matches the in-block coinbase’s SMT root for this transaction’s logical position.

The actual ZK proof verification happens out of process in the Rust sidecar. The C++ node fires off the proof to a local Unix socket and waits for ok or not ok. This sounds slow but in practice the prover-side work is what’s expensive (4-8 seconds); the verifier-side check is ~30 milliseconds and well-amortised across the block.

If the sidecar is unavailable, the node refuses to validate witness-v2 transactions and stays in IBD-style “I’m not caught up” mode. Better than silently accepting unverified shielded spends.

What I changed my mind about

I started this design wanting to put proof verification inside the C++ validator via a precompile-style C++ binding. That would have meant linking the entire risc0-zkvm Rust crate into Bitcoin Core’s C++ build, which is — to put it mildly — not a small ask of a Bitcoin Core review process.

The out-of-process sidecar pattern was a concession to “we will eventually want to upstream as much of this as possible.” A node that talks to a sidecar over a Unix socket is a node that can be ported to the eventual full-Rust rewrite without changing its consensus contract. The sidecar is the ABI; the language behind it can move.

I’m still not 100% sold on this trade. The audit surface is a lot bigger when there are two processes. TODO: Dax confirm whether we end up upstreaming the proof verifier into the core process for v2.

Further reading

← Back to article