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
Hiding
Hiding
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)
- 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
Binding
Binding
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
Public
Public
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)
- 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
amount∈ [0, 2^248) (enforced in circuit)pubkey = Poseidon(privateKey)blinding∈ [0, FIELD_SIZE) (random)mintAddress= Native SOL or SPL token mint
Creating Commitments
Encrypted Outputs
When creating commitments on-chain, the depositor encrypts UTXO data for the recipient:From lib.rs:351
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
Uniqueness
Uniqueness
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)
- Probability of two commitments having same nullifier ≈ 2^-256 (negligible)
- Guaranteed by Poseidon hash collision resistance
Privacy-Preserving
Privacy-Preserving
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)
- Which commitment the nullifier came from
- Who spent the commitment
- The commitment’s amount or other details
- Nullifier appears random due to hashing
- No linkage between nullifier and commitment hash
- Signature component prevents brute force
Double-Spend Prevention
Double-Spend Prevention
Once a nullifier is published, the commitment cannot be spent again.Mechanism:Double-spend attempt:
From lib.rs:602
- User tries to spend same commitment twice
- Generates same nullifier (deterministic)
- Transaction fails: nullifier account already exists
- 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
From keypair.circom:15
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
- 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
- First Spend (Valid)
- Second Spend (Rejected)
Nullifier Collisions
Privacy Cash prevents nullifier collisions between the two input slots:From lib.rs:623
- 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!)
- Create
nullifier0fromproof.input_nullifiers[0]✅ - Create
nullifier1fromproof.input_nullifiers[1]✅ - Verify
nullifier2(slot 0 seeds + nullifier[1]) doesn’t exist ✅ - Verify
nullifier3(slot 1 seeds + nullifier[0]) doesn’t exist ✅
Commitment Lifecycle
Follow a commitment from creation to spending:Security Analysis
Commitment Hiding
Commitment Hiding
Security: Commitments reveal no information about UTXOs.Proof: Given commitment
c = Poseidon(amount, pubkey, blinding, mint), an attacker cannot determine any input without:- Breaking Poseidon pre-image resistance (~2^256 operations)
- Brute forcing blinding space (~2^248 possibilities)
- ❌ Brute force: Computationally infeasible
- ❌ Dictionary attack: Blinding adds randomness
- ❌ Timing attack: Constant-time hashing
- ✅ Side channel: Use constant-time crypto libraries
Nullifier Privacy
Nullifier Privacy
Security: Nullifiers don’t reveal which commitment was spent.Proof: Given nullifier
n = Poseidon(c, path, signature), an attacker cannot:- Determine which commitment
cwas spent (no linkage) - Forge a valid nullifier without private key (signature required)
- Link nullifier to commitment via Merkle path (path + signature hashed together)
- ❌ Commitment → Nullifier: Requires private key (unknown)
- ❌ Nullifier → Commitment: Pre-image resistance (~2^256)
- ⚠️ Timing correlation: Same user withdraws shortly after depositing
Double-Spend Prevention
Double-Spend Prevention
Security: Commitments cannot be spent twice.Mechanism:
- Nullifiers are deterministic (same commitment → same nullifier)
- Nullifier accounts are PDAs (same nullifier → same address)
- Account init fails if exists (Solana runtime check)
- Check happens before proof verification (fail fast)
- ❌ 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
Blinding Security
Blinding Security
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