DOC# NULLIF SLUG nullifiers_without_witchcraft PRINTED 2026-05-06 03:47 UTC

Nullifiers without the witchcraft

Nullifier Generation is on the ZERA front page next to Pedersen Commitments and Zero-Knowledge Proofs. The Rust + TypeScript implementations are six lines apiece. Here is what they actually do, and why the design borrows from Zcash.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/nullifiers_without_witchcraft/
FILED
2026-04-02 15:30 UTC
REVISED
2026-04-02 15:30 UTC
TIME
11 min read
SERIES
ZERA cryptography
TAGS
#zera #cryptography #nullifier #poseidon #zcash #zk #solana

The zeralabs.org front page lists three “Cryptographic Innovations”: Pedersen Commitments, Zero-Knowledge Proofs, Nullifier Generation. I wrote about the first one already. The second one is what makes the protocol work at all — Groth16 over BN254, the fast lane that lets ZK leave the laboratory. This post is the third.

Nullifier Generation sounds like a wizard’s incantation. In practice, on a privacy chain, it is the most boring possible thing: a hash, with an exact and well-known input set, computed at exactly one moment in the lifecycle of a note. The reason it gets a top-line marketing slot is not because the math is exotic. It’s because nullifiers are the entire reason a privacy pool can prevent double-spending without revealing which note got spent. They are the load-bearing trick. If you understand them, you understand UTXO-style ZK.

What a nullifier is, in one sentence

A nullifier is a hash of two things — a secret only the owner of a note knows, and the on-chain commitment of that note — published once, when the note is spent, so the chain can refuse a second spend without learning anything else about the note.

That sentence has every piece. The owner has a secret. The chain has a commitment. The owner spends, reveals the hash of (secret, commitment), and the chain stamps “spent” next to that hash. If anyone else, ever, tries to spend the same note, they will produce the same hash. The chain notices and rejects.

The reason this matters: in a transparent UTXO system (Bitcoin, original Solana SPL), the chain knows which UTXO got spent because it sees the input. In a shielded system, the chain doesn’t know which note got spent — that’s the whole point of the privacy layer — so we need a way for the chain to refuse double-spends without learning the identity of the spent note. Nullifiers are that way.

The Zcash inheritance

This is not a ZERA invention. The nullifier construction goes back to Zcash Sprout (2016) and the Sapling upgrade (2018), and the Zcash protocol specification is still the canonical reference. In Sapling, the nullifier of a note is PRF^nf(nk, ρ) where nk is the spending key and ρ is a per-note nonce derived from the note’s commitment. The construction has two essential properties:

  1. Deterministic given the secret material. The same note always produces the same nullifier, so a second spend is detectable.
  2. Unlinkable without the secret material. An observer who sees the commitment cannot derive the nullifier; only the owner of the spending key can.

ZERA’s construction is the same idea, simplified for the deployment surface. Sapling has a richer key tree (ask/nsk/nk/ovk/ivk) because it ships viewing keys, expiry windows, and a separate proof-spend key. ZERA’s MVP keeps the same roles inside one secret field per note. If the protocol grows a viewing-key abstraction (and it will — see the wallet’s HKDF-derived viewing keys in the v3 wallet post), the nullifier construction can absorb that without breaking, because the input set is Poseidon(secret, commitment) and secret is the part that gets specialised.

The six lines of TypeScript

Open packages/sdk/src/note.ts in the SDK and search for computeNullifier. The whole function is:

/**
 * Compute the nullifier for spending a note.
 *
 * ```
 * nullifier = Poseidon(secret, commitment)
 * ```
 */
export async function computeNullifier(
  secret: bigint,
  commitment: bigint,
): Promise<bigint> {
  return poseidonHash([secret, commitment]);
}

That’s it. Two field elements in, one field element out, one Poseidon call in the middle. The accompanying tests in note.test.ts are equally bare:

describe("computeNullifier", () => {
  it("returns a deterministic bigint", async () => {
    const note = createNote(100n, 1n);
    const commitment = await computeCommitment(note);
    const a = await computeNullifier(note.secret, commitment);
    const b = await computeNullifier(note.secret, commitment);
    expect(a).toBe(b);
  });

  it("different secrets produce different nullifiers", async () => {
    const note1 = createNote(100n, 1n);
    const note2 = createNote(100n, 1n);
    const commitment = await computeCommitment(note1);
    const n1 = await computeNullifier(note1.secret, commitment);
    const n2 = await computeNullifier(note2.secret, commitment);
    expect(n1).not.toBe(n2);
  });
});

The first test asserts determinism — same inputs, same output, every time. The second asserts independence — two notes with the same (amount, asset) but different secrets must produce different nullifiers, otherwise the privacy property collapses.

The Rust mirror lives at crates/zera-core/src/note.rs — same shape, same Poseidon, same input order. The whole point of having two implementations under one cross-validated test vector (see the cryptography doc) is that the host language never matters. JS in the wallet, Rust in the on-chain program, Rust-via-Neon in Node consumers — all four pipelines have to agree on the byte representation of Poseidon(secret, commitment). They do, because the test vectors say so on every CI run.

Why secret and blinding are different fields

The note struct from note.ts has two random fields:

return {
  amount,
  asset,
  secret: randomFieldElement(),
  blinding: randomFieldElement(),
  memo: memo ?? [0n, 0n, 0n, 0n],
};

I noted this in the Pedersen post but it’s worth restating in nullifier-context: the secret is what the nullifier depends on. The blinding is what gives the commitment its hiding property. They are separated because they fail differently.

If blinding leaks (say, via a buggy memo encryption scheme), the worst case is the commitment becomes enumerable for small amount spaces. Bad, recoverable.

If secret leaks, the nullifier becomes predictable, which means an attacker can stamp the chain with the nullifier before the legitimate owner does, and the legitimate spend gets rejected as a double-spend. This is the worst possible failure mode in a privacy pool. The note becomes unspendable.

Sampling them as independent 248-bit field elements means an attacker who compromises one does not get the other for free. The cost is ~62 bytes of additional state per note. The benefit is decorrelating the two failure modes that would otherwise chain.

The lifecycle, in one diagram

1.  CREATE  (off-chain)
    note = createNote(amount, asset)
        ├── secret    = randomFieldElement()   // private, kept by owner
        └── blinding  = randomFieldElement()   // private, kept by owner

2.  COMMIT  (on-chain, via deposit or transfer-output)
    commitment = Poseidon(amount, secret, blinding, asset, memo[0..3])
    --> commitment is appended to the on-chain Merkle tree at leafIndex

3.  HOLD    (off-chain, in the wallet)
    Owner stores { note, commitment, leafIndex, nullifier? } locally.
    Nullifier may be precomputed but is NOT yet on-chain.

4.  SPEND   (on-chain, via withdraw or transfer-input)
    nullifier = Poseidon(secret, commitment)
    proof     = Groth16(
                  public:  nullifier, root, recipientHash, amount, asset
                  private: secret, blinding, memo, leafIndex, merkle_path
                )
    --> on-chain program checks:
        a. proof verifies under verifying_key
        b. nullifier_pda(nullifier) does not yet exist
        c. root matches a recent on-chain root
    --> if all pass, program creates nullifier_pda(nullifier).

5.  REJECT  (any future spend attempt with the same nullifier)
    nullifier_pda(nullifier) exists --> program returns
    "DoubleSpendDetected" without ever learning which note it was.

The reject step is the magic. The on-chain program does not know which note is being respent. It does not know which leaf in the Merkle tree the nullifier corresponds to. It only knows that a PDA seeded by the nullifier hash already exists, and it refuses to recreate it. From packages/sdk/src/pda.ts, the seed shape is ["nullifier", nullifierBytes32]:

export const NULLIFIER_SEED = "nullifier";

Each nullifier on the chain is a 32-byte BN254 field element packed into a PDA. PDAs are cheap on Solana, but they are not free, and the rent-exempt minimum balance for a tiny PDA is the actual cost of “stamping the chain with a nullifier.” It is sub-cent on devnet and mainnet alike. That is the cost of double-spend protection in this design.

What the circuit actually proves

The transfer circuit (one-input, two-output) and the withdraw circuit (one-input, one-recipient-hash) both compute the nullifier inside the circuit from the witness and assert equality with the public input. From packages/sdk/src/prover.ts:

const input = {
  // Public
  root: tree.root.toString(),
  nullifierHash: nullifierHash.toString(),
  recipient: recipientHash.toString(),
  amount: note.amount.toString(),
  asset: note.asset.toString(),
  // Private
  secret: note.secret.toString(),
  // ...
};

The circuit’s predicate, in pseudocode:

1. computed_commitment   = Poseidon(amount, secret, blinding, asset, memo[0..3])
2. computed_nullifier    = Poseidon(secret, computed_commitment)
3. assert  computed_nullifier == nullifierHash         (public input)
4. assert  Merkle(root, leafIndex, path) == computed_commitment
5. assert  amount, asset bind to public inputs

That’s the whole privacy proof. The chain learns the nullifier and the new output commitments. It does not learn the amount inside, the asset, the original commitment, or the leaf index. The nullifier is the only piece of identifying information leaked, and the only thing it identifies is itself — there is no on-chain link from nullifier back to commitment without breaking the hash.

This is also why the secret-as-witness matters. If the nullifier could be derived from public information alone, anyone could replay it. The privacy story collapses. Because the secret is sampled per-note and is part of both the commitment witness and the nullifier preimage, only the holder of the secret can produce the proof. That binding is what stops one user from frontrunning another’s spend.

What an attack looks like, briefly

There are exactly two things an attacker can try, and they both lose.

Attack 1: precompute someone’s nullifier and stamp the chain first. Requires secret. Without it, you can’t compute Poseidon(secret, commitment). The note’s secret is sampled with 248 bits of CSPRNG entropy and reduced mod the BN254 prime, so brute-force is not on the table. Mitigation: the keystore in the wallet keeps the secret in Rust, behind a ChaCha20-Poly1305 layer derived from an Argon2id-hardened password, and never lets it touch JavaScript.

Attack 2: replay a nullifier from a previous valid spend. This is the “spam the chain with old nullifiers” attack. It loses immediately because the on-chain program checks for PDA existence on every spend, and an existing PDA is exactly the signal “this nullifier has been seen before, reject.” There is no clever ordering that gets around this — the PDA is monotonically created.

The thing that’s not in the threat model: a global attacker who can correlate metadata about when spends happen. That’s a network-layer problem, not a cryptographic one. Tor-style mixing, relayer rotation, and the voucher / private-cash flow are all the answer to that, and they are deliberately layered on top of the nullifier system rather than baked into it.

Why this matters for the marketing pillar

zeralabs.org ships “Anti-Double Spending” as one of its six pillars, alongside True Offline Payments, Cryptographic Privacy, Perfect Divisibility, Secure Enclaves, and Solana Speed. Anti-Double Spending and Cryptographic Privacy both live or die on this construction. The pillar is real because the construction is real. It is not a stitched-together promise that turns into a complicated multi-party signing scheme later. It is one Poseidon hash and one PDA, and it has been the right answer since 2016.

The boring answer is the right answer. The marketing word is “Nullifier Generation.” The implementation is six lines. The reason it sits next to Pedersen Commitments on the front page is that without it, the privacy pool is just a private deposit box you can drain twice.

Further reading

← Back to article