Skip to main content

Overview

Commitments and nullifiers are the core cryptographic primitives in Privacy Cash that enable both transaction privacy and double-spend prevention.

Commitments

Hide transaction detailsA commitment is a cryptographic hash that represents a UTXO (amount + owner + blinding) without revealing its contents.

Nullifiers

Prevent double-spendingA nullifier is a unique identifier derived from a commitment that marks it as “spent” without revealing which commitment was spent.
Together, commitments and nullifiers enable the fundamental tradeoff: Privacy without double-spending.

Commitments

What is a Commitment?

A commitment is a cryptographic hash that binds a user to a specific UTXO without revealing its details.
UTXO Structure

Commitment Properties

The commitment reveals nothing about the underlying UTXO.What’s hidden:
  • Amount: How much SOL/tokens
  • Owner: Who owns it (pubkey)
  • Blinding: Random entropy
  • Mint: Which token (if multiple tokens have similar commitments)
Why secure:
  • Poseidon is a cryptographic hash (pre-image resistant)
  • Given only the commitment, you cannot determine the UTXO
  • Blinding adds randomness so identical amounts have different commitments
The commitment uniquely binds to exactly one UTXO.Properties:
  • Changing any UTXO field changes the commitment (collision resistant)
  • Owner can prove they created the commitment (using zero-knowledge proof)
  • Others cannot modify the UTXO without detection
Example:
Commitments are stored on-chain in the Merkle tree and are publicly visible.What observers see:
  • The commitment hash (32 bytes)
  • When it was created (block timestamp)
  • Its position in the Merkle tree (index)
  • Encrypted output data (for intended recipient)
What observers don’t see:
  • The UTXO details (amount, owner, etc.)
  • Which commitment is yours
  • When/if a commitment is spent (until nullifier is revealed)

Commitment Scheme

Privacy Cash uses Poseidon hash for commitments:
From transaction.circom:58
Formula:
Where:
  • amount ∈ [0, 2^248) (enforced in circuit)
  • pubkey = Poseidon(privateKey)
  • blinding ∈ [0, FIELD_SIZE) (random)
  • mintAddress = Native SOL or SPL token mint

Creating Commitments

1

Generate Keypair

Create or use existing private key:
2

Generate Blinding Factor

Create random blinding for commitment uniqueness:
Never reuse blinding factors! Each commitment must have a unique blinding to prevent linkability.
3

Compute Commitment

Hash the UTXO data:
4

Store UTXO Data

Save UTXO details securely (needed for spending later):
You need the full UTXO data to spend the commitment later. If you lose this data, the funds are unrecoverable.

Encrypted Outputs

When creating commitments on-chain, the depositor encrypts UTXO data for the recipient:
On-chain events:
From lib.rs:351
Recipients scan events and attempt to decrypt:

Nullifiers

What is a Nullifier?

A nullifier is a unique identifier derived from a commitment that marks it as spent, without revealing which commitment it came from.

Nullifier Properties

Each commitment has exactly one valid nullifier.Why unique:
  • Nullifier depends on commitment (different commitments → different nullifiers)
  • Depends on signature (only owner can generate valid signature)
  • Depends on Merkle path (where commitment is in tree)
Collision resistance:
  • Probability of two commitments having same nullifier ≈ 2^-256 (negligible)
  • Guaranteed by Poseidon hash collision resistance
Nullifiers don’t reveal which commitment was spent.What attackers know:
  • A nullifier was revealed
  • Some commitment was spent
  • The nullifier value (random-looking hash)
What attackers don’t know:
  • Which commitment the nullifier came from
  • Who spent the commitment
  • The commitment’s amount or other details
Why secure:
  • Nullifier appears random due to hashing
  • No linkage between nullifier and commitment hash
  • Signature component prevents brute force
Once a nullifier is published, the commitment cannot be spent again.Mechanism:
From lib.rs:602
Double-spend attempt:
  1. User tries to spend same commitment twice
  2. Generates same nullifier (deterministic)
  3. Transaction fails: nullifier account already exists
  4. No state changes occur (atomicity)
Double-spend prevention is enforced by Solana’s account creation before proof verification, saving compute units on invalid transactions.

Nullifier Generation

Nullifiers are computed in the zero-knowledge circuit:
From transaction.circom:64
Where:
From keypair.circom:15
Full formula:
The signature component ensures only the commitment owner (who knows the private key) can generate the correct nullifier.

Nullifier Accounts

On Solana, nullifiers are represented as PDA (Program Derived Address) accounts:
From lib.rs:602
Why PDAs?
  • Deterministic: Same nullifier always derives same address
  • Ownerless: Program owns the account, not a user
  • Existence check: Account creation fails if already exists
  • No rent drain: Nullifier accounts pay rent once, protect deposits forever

Nullifier Collisions

Privacy Cash prevents nullifier collisions between the two input slots:
From lib.rs:623
Why needed:
  • Without this check, a malicious user could use the same nullifier in both input slots
  • This would allow spending one commitment and receiving two outputs (printing money!)
Check logic:
  1. Create nullifier0 from proof.input_nullifiers[0]
  2. Create nullifier1 from proof.input_nullifiers[1]
  3. Verify nullifier2 (slot 0 seeds + nullifier[1]) doesn’t exist ✅
  4. Verify nullifier3 (slot 1 seeds + nullifier[0]) doesn’t exist ✅
If nullifiers are identical, step 3 or 4 fails (account already exists from step 1 or 2).

Commitment Lifecycle

Follow a commitment from creation to spending:
1

Create UTXO

Generate UTXO with random blinding:
2

Compute Commitment

Hash UTXO to create commitment:
3

Deposit to Pool

Submit commitment to Merkle tree:
4

Wait for Anonymity

Let other users deposit to increase anonymity set:
5

Generate Merkle Proof

Prove commitment is in tree:
6

Compute Nullifier

Generate nullifier to spend commitment:
7

Withdraw

Submit zero-knowledge proof with nullifier:

Security Analysis

Security: Commitments reveal no information about UTXOs.Proof: Given commitment c = Poseidon(amount, pubkey, blinding, mint), an attacker cannot determine any input without:
  1. Breaking Poseidon pre-image resistance (~2^256 operations)
  2. Brute forcing blinding space (~2^248 possibilities)
Attack vectors:
  • ❌ Brute force: Computationally infeasible
  • ❌ Dictionary attack: Blinding adds randomness
  • ❌ Timing attack: Constant-time hashing
  • ✅ Side channel: Use constant-time crypto libraries
Best practice: Use cryptographically secure randomness for blinding.
Security: Nullifiers don’t reveal which commitment was spent.Proof: Given nullifier n = Poseidon(c, path, signature), an attacker cannot:
  1. Determine which commitment c was spent (no linkage)
  2. Forge a valid nullifier without private key (signature required)
  3. Link nullifier to commitment via Merkle path (path + signature hashed together)
Attack vectors:
  • ❌ Commitment → Nullifier: Requires private key (unknown)
  • ❌ Nullifier → Commitment: Pre-image resistance (~2^256)
  • ⚠️ Timing correlation: Same user withdraws shortly after depositing
Best practice: Add delays between deposit and withdrawal.
Security: Commitments cannot be spent twice.Mechanism:
  1. Nullifiers are deterministic (same commitment → same nullifier)
  2. Nullifier accounts are PDAs (same nullifier → same address)
  3. Account init fails if exists (Solana runtime check)
  4. Check happens before proof verification (fail fast)
Attack vectors:
  • ❌ Use different nullifier: Circuit enforces correct nullifier
  • ❌ Different account seeds: Program enforces standard seeds
  • ❌ Race condition: Solana provides transaction atomicity
  • ❌ Replay attack: Nullifier accounts persist forever
Guarantee: Impossible to double-spend even with infinite compute.
Security: Random blinding prevents commitment linkage.Why needed: Without blinding, identical amounts have identical commitments:
Best practices:
  • Use cryptographically secure randomness (crypto.randomBytes)
  • Never reuse blinding factors
  • Blinding should be at least 248 bits of entropy

Common Patterns

Change Outputs

When withdrawing less than the commitment amount, create a change output:

Splitting Commitments

Split large commitments into smaller denominations:

Multi-Commitment Withdrawals

Combine multiple commitments in one withdrawal:

Implementation Reference

Commitment Circuit

transaction.circom:58Commitment computation in ZK circuit

Nullifier Circuit

transaction.circom:69Nullifier generation in ZK circuit

Nullifier Accounts

lib.rs:602On-chain nullifier account structure

Signature Template

keypair.circom:15Signature generation for nullifiers

Next Steps

How It Works

See how commitments and nullifiers fit into the full transaction flow

Zero-Knowledge Proofs

Learn how ZK proofs verify commitments without revealing them

Merkle Trees

Understand how commitments are stored and proven

Build with SDK

Start creating commitments and generating nullifiers