technical

Understanding Pool Mixing: How PrivacyCash Works

A technical deep-dive into pool mixing privacy on Solana. Learn how PrivacyCash uses Merkle trees and ZK proofs to break transaction links.

13 min read

Pool mixing is one of the oldest and most battle-tested privacy techniques in cryptocurrency. From CoinJoin on Bitcoin to Tornado Cash on Ethereum, the core idea remains elegant: hide in a crowd. On Solana, PrivacyCash has emerged as the leading pool mixer, processing over 680,000 SOL through 11,700+ wallets since its August 2025 launch.

This article provides a technical deep-dive into how pool mixing works, what makes PrivacyCash’s implementation unique, and the privacy guarantees it provides.

What is Pool Mixing?

Pool mixing is a privacy technique that breaks the on-chain link between deposits and withdrawals. Instead of sending tokens directly from A to B (leaving an obvious trail), you deposit tokens into a shared pool, then withdraw them later to a different address.

The key insight: if 1,000 people deposit into the same pool, an observer can’t tell which depositor corresponds to which withdrawal. You’re hiding in a crowd of 999 other people.

Traditional Transfer:
  Alice ──── 10 SOL ────> Bob
  (Public link: Everyone knows Alice paid Bob)

Pool Mixing:
  Alice ──── 10 SOL ────> [Privacy Pool] ──── 10 SOL ────> Bob's New Wallet
  (No link: 1,000 people deposited, 1,000 people withdrew)

The challenge is proving you have the right to withdraw without revealing which deposit is yours. This is where zero-knowledge proofs come in.

The Pool Mixing Model

How Deposits Create Anonymity Sets

When you deposit into a pool mixer, you’re joining an anonymity set - the group of all depositors whose identities are indistinguishable. The larger this set, the stronger your privacy.

Consider a pool with these properties:

  • 1,000 deposits of varying amounts
  • Deposits from addresses across different exchanges, wallets, and protocols
  • Withdrawals spread across days or weeks

An observer trying to link your withdrawal to your deposit faces statistical impossibility. Even with sophisticated timing analysis or amount correlation, the search space is enormous.

Merkle Tree Commitments

Pool mixers use Merkle trees to efficiently track all deposits while enabling zero-knowledge withdrawal proofs.

What’s a Merkle Tree?

A Merkle tree is a data structure that creates a single “root” hash representing an entire dataset. Each leaf in the tree is a commitment (hashed data), and intermediate nodes are hashes of their children.

                    Root Hash
                   /         \
            Hash(A,B)       Hash(C,D)
             /    \          /    \
          Leaf A  Leaf B  Leaf C  Leaf D
          (Your   (Other  (Other  (Other
         deposit) deposit) deposit) deposit)

Why Merkle Trees?

  1. Efficient proofs: You can prove membership with O(log n) hashes instead of O(n)
  2. Compact storage: Only the root needs to be stored on-chain
  3. Perfect for ZK: Merkle proofs are efficient to verify inside zero-knowledge circuits

When you deposit, your commitment is added as a new leaf. The tree grows, and the root hash updates. When you withdraw, you prove (in zero-knowledge) that your commitment exists somewhere in this tree.

Commitment Generation

When depositing, the protocol generates a commitment from two secret values:

commitment = hash(nullifier, secret)
  • Nullifier: A unique identifier that will be revealed during withdrawal to prevent double-spending
  • Secret: A random value known only to you

Together, these form your “note” - the proof that you deposited and have the right to withdraw.

// Simplified commitment generation
function generateCommitment() {
  const nullifier = randomBytes(32)  // Random 256-bit value
  const secret = randomBytes(32)     // Random 256-bit value
  const commitment = poseidonHash(nullifier, secret)

  return {
    commitment,  // Stored on-chain in Merkle tree
    note: { nullifier, secret }  // Save this privately!
  }
}

The commitment is stored publicly in the Merkle tree. The note stays with you - lose it, and you lose your funds.

Zero-Knowledge Withdrawal Proofs

Withdrawing requires proving three things without revealing anything:

  1. Membership: “My commitment exists in the Merkle tree”
  2. Knowledge: “I know the nullifier and secret that hash to this commitment”
  3. Non-double-spend: “This nullifier hasn’t been used before”

A zero-knowledge proof (ZKP) lets you prove all three statements without revealing:

  • Which commitment is yours
  • Your nullifier value (until reveal)
  • Your secret value (ever)
Prover (You):                      Verifier (Contract):
────────────────────────────────   ────────────────────────────────
Know: nullifier, secret, path      Know: Merkle root, nullifier set

1. Generate commitment from
   nullifier + secret

2. Generate Merkle proof that
   commitment is in tree

3. Create ZK proof combining
   both statements

4. Send: proof + nullifier ────────> Verify proof is valid
                                     Check nullifier not in set
                                     Add nullifier to set
                                     Transfer funds to recipient

The magic happens because the proof reveals nothing about your original deposit:

  • The Merkle proof is hidden inside the ZK proof
  • Your secret is never revealed
  • Only the nullifier is published (and it’s not linked to your deposit commitment)

An observer sees:

  • “Someone who deposited at some point just withdrew”
  • A nullifier that prevents double-spending
  • The recipient address

They cannot determine when you deposited, from which address, or link this to any other activity.

PrivacyCash Specifics

PrivacyCash brought Tornado Cash-style privacy to Solana with several modern improvements. Let’s examine its architecture.

Core Program

PrivacyCash operates through a Solana program deployed at:

Program ID: 9fhQBbumKEFuXtMBDw8AaQyAjCorLGJQiS3skWZdQyQD

The program manages:

  • Merkle tree state for commitments
  • Nullifier registry to prevent double-spending
  • Token escrow for deposited assets
  • Proof verification for withdrawals

SDK Interface

The PrivacyCash SDK provides a clean interface for integration:

import { PrivacyCash } from '@privacycash/sdk'
import { Connection, PublicKey } from '@solana/web3.js'

// Initialize client
const connection = new Connection('https://api.mainnet-beta.solana.com')
const privacyCash = new PrivacyCash(connection)

// Check pool statistics
const stats = await privacyCash.getPoolStats('SOL')
console.log(`Total deposits: ${stats.totalDeposits}`)
console.log(`Anonymity set: ${stats.anonymitySetSize}`)

Deposit Flow:

// Generate commitment and deposit
const { commitment, note } = await privacyCash.deposit({
  amount: 1_000_000_000,  // 1 SOL in lamports
  token: 'SOL',
  wallet: userWallet
})

// CRITICAL: Save the note securely
// This is your proof of deposit - lose it, lose funds
await saveNoteSecurely(note)

console.log(`Deposited. Commitment: ${commitment}`)
console.log(`Save your note: ${note.toString()}`)

Withdrawal Flow:

// Later: withdraw to a fresh address
const recipientWallet = Keypair.generate()

const txHash = await privacyCash.withdraw({
  note,
  recipient: recipientWallet.publicKey,
  relayerFee: 0.01  // 1% fee to relayer
})

console.log(`Withdrawn to ${recipientWallet.publicKey}`)
console.log(`Transaction: ${txHash}`)

Balance Checking:

// Check shielded balance (sum of unspent notes)
const balance = await privacyCash.getPrivateBalance({
  notes: savedNotes,  // Your saved deposit notes
  token: 'SOL'
})

console.log(`Private balance: ${balance.total} SOL`)
console.log(`Spendable notes: ${balance.notes.length}`)

Compliance Features

Unlike classic Tornado Cash, PrivacyCash includes compliance mechanisms:

Selective Disclosure:

// Generate proof for auditor without revealing everything
const disclosure = await privacyCash.generateSelectiveDisclosure({
  note,
  auditor: auditorPublicKey,
  disclose: ['amount', 'timestamp'],  // What to reveal
  hide: ['depositAddress']  // What to keep private
})

// Auditor can verify the disclosure
const verified = await privacyCash.verifyDisclosure(disclosure, auditorPrivateKey)

AML/KYT Integration Hooks:

// Check if withdrawal address is flagged
const risk = await privacyCash.checkCompliance({
  recipient: withdrawalAddress,
  provider: 'chainalysis'  // or 'elliptic', 'trm'
})

if (risk.score > threshold) {
  console.log('High-risk address detected')
}

Current Statistics

As of January 2026, PrivacyCash has processed:

  • 680,000+ SOL total volume
  • 11,700+ unique wallets interacted with the protocol
  • Average anonymity set: ~500 depositors per rolling window
  • Supported tokens: SOL, USDC, USDT, and select SPL tokens

Technical Deep-Dive

The Deposit Flow in Detail

When you call deposit(), the following happens:

1. Client generates random nullifier (32 bytes)
2. Client generates random secret (32 bytes)
3. Client computes commitment = PoseidonHash(nullifier, secret)
4. Client creates deposit transaction:
   - Transfer tokens to escrow PDA
   - Call insert_commitment instruction
5. Program verifies token transfer
6. Program inserts commitment into Merkle tree
7. Program updates root hash
8. Client returns note = { nullifier, secret, leafIndex }

The Poseidon hash function is used because it’s optimized for zero-knowledge circuits, making withdrawal proofs efficient.

The Withdrawal Flow in Detail

Withdrawal is more complex, involving proof generation and verification:

Client Side:
1. Load note (nullifier, secret, leafIndex)
2. Fetch current Merkle root from program
3. Compute Merkle path from leaf to root
4. Generate ZK proof:
   - Public inputs: root, nullifier, recipient, fee
   - Private inputs: secret, pathElements, pathIndices
   - Constraints:
     a. commitment = hash(nullifier, secret)
     b. commitment is in tree with given root
     c. nullifier matches public input
5. Submit transaction with proof

Program Side:
1. Verify ZK proof against public inputs
2. Check nullifier not in nullifier set
3. Add nullifier to set (prevent double-spend)
4. Transfer tokens from escrow to recipient
5. Pay relayer fee if applicable

The Relayer System

A key challenge with pool mixing: if you withdraw to a fresh address, how do you pay for gas? The fresh address has no SOL for transaction fees.

PrivacyCash solves this with relayers:

Without Relayer:
  Fresh Wallet (0 SOL) ──X── Can't submit transaction

With Relayer:
  You ─── proof + signed intent ───> Relayer
  Relayer ─── submits transaction ───> Solana
  Program ─── sends (amount - fee) ───> Fresh Wallet
  Program ─── sends fee ───> Relayer

The relayer never learns your deposit details - they only see the zero-knowledge proof and recipient. They’re incentivized by the fee (typically 0.5-2%).

// Withdraw using a relayer
const withdrawal = await privacyCash.withdraw({
  note,
  recipient: freshWallet.publicKey,
  relayer: 'https://relayer.privacycash.io',
  maxFee: 0.02  // Max 2% fee
})

Privacy Analysis

Understanding the privacy guarantees and limitations of pool mixing is crucial for using it effectively.

Anonymity Set Strength

Your privacy is directly proportional to the anonymity set size. Consider:

  • 100 depositors: 1% chance of being identified
  • 1,000 depositors: 0.1% chance
  • 10,000 depositors: 0.01% chance

But raw numbers aren’t everything. The anonymity set is only as strong as its diversity:

  • Similar deposit amounts reduce effective anonymity
  • Temporal clustering (many deposits at same time) weakens privacy
  • Address reuse across deposits can be correlated

PrivacyCash mitigates these by supporting arbitrary amounts and maintaining active pools with continuous deposit/withdrawal activity.

Timing Attacks

Timing correlation is a classic attack on pool mixers:

Attack: If Alice deposits at 3:00 PM and Bob withdraws at 3:01 PM,
        they might be the same person.

Defense: Wait before withdrawing. The longer you wait, the more
         deposits happen, the larger your anonymity set.

Best practice: wait at least 24-48 hours between deposit and withdrawal, ideally longer. PrivacyCash provides guidance:

const timing = await privacyCash.getOptimalWithdrawalTiming({
  depositTimestamp: note.timestamp,
  targetAnonymitySet: 500
})

console.log(`Recommended wait: ${timing.minimumWait} hours`)
console.log(`Current anonymity set: ${timing.currentAnonymitySet}`)

Amount Correlation

Classic Tornado Cash required fixed denominations (0.1, 1, 10, 100 ETH) because arbitrary amounts are easier to correlate:

Attack: Alice deposits 1.23456 SOL. Bob withdraws 1.23456 SOL.
        Probably the same person.

Defense (Classic): Only allow fixed amounts (1 SOL, 10 SOL, etc.)
Defense (Modern): Internal splitting and noise

PrivacyCash handles arbitrary amounts through internal batching and splitting, making amount correlation significantly harder. However, unusual amounts (e.g., 7.777777 SOL) may still have smaller effective anonymity sets.

Graph Analysis Resistance

Sophisticated attackers use graph analysis across multiple transactions:

Attack: Track patterns across many deposits/withdrawals
        - Alice always deposits Tuesday, withdraws Thursday
        - Alice's withdrawals always go to addresses that interact
          with the same DeFi protocols

Defense: Vary timing, use different protocols, maintain OpSec

Pool mixing provides strong privacy against on-chain analysis but cannot protect against real-world operational security failures.

Trade-offs

Pros

Battle-Tested: Pool mixing has been used since 2013 (CoinJoin) and formalized in 2019 (Tornado Cash). The cryptographic primitives are well-understood and thoroughly audited.

Conceptually Simple: The mental model is intuitive - hide in a crowd. Users don’t need to understand Pedersen commitments or elliptic curves to grasp the privacy guarantee.

Efficient Proofs: Merkle tree membership proofs are small and fast to verify, keeping transaction costs reasonable on Solana.

Compliance Path: Modern implementations like PrivacyCash offer selective disclosure, addressing regulatory concerns that doomed classic Tornado Cash.

Strong Privacy with Participation: With sufficient depositors, pool mixing provides excellent unlinkability. The more users, the better everyone’s privacy.

Cons

Privacy Depends on Pool Size: If the pool has few participants, privacy is weak. A pool with 10 depositors provides 10% identification probability - hardly anonymous.

Liquidity Fragmentation: Supporting multiple tokens or amounts means splitting the anonymity set. The USDC pool and SOL pool don’t help each other.

Timing Metadata: Deposit and withdrawal times are public. Without careful timing hygiene, users can be correlated through temporal analysis.

Note Management: Users must securely store notes. Lost notes mean lost funds - there’s no recovery mechanism.

Relayer Dependency: Fresh-address withdrawals require relayers, introducing trusted third parties (though they can’t steal funds or break privacy).

Complete Integration Example

Here’s a full example integrating PrivacyCash into a Solana application:

import { PrivacyCash, Note } from '@privacycash/sdk'
import { Connection, Keypair, PublicKey } from '@solana/web3.js'
import { SecureStorage } from './secure-storage'

class PrivacyWallet {
  private privacyCash: PrivacyCash
  private storage: SecureStorage

  constructor(connection: Connection) {
    this.privacyCash = new PrivacyCash(connection)
    this.storage = new SecureStorage()
  }

  async deposit(amount: number, wallet: Keypair): Promise<string> {
    // Generate commitment and deposit
    const { commitment, note } = await this.privacyCash.deposit({
      amount: amount * 1e9,  // Convert SOL to lamports
      token: 'SOL',
      wallet
    })

    // Securely store the note
    await this.storage.saveNote(note)

    return commitment
  }

  async withdraw(noteId: string, recipient: PublicKey): Promise<string> {
    // Load note from secure storage
    const note = await this.storage.loadNote(noteId)

    // Check recommended timing
    const timing = await this.privacyCash.getOptimalWithdrawalTiming({
      depositTimestamp: note.timestamp,
      targetAnonymitySet: 500
    })

    if (timing.currentAnonymitySet < 100) {
      console.warn('Warning: Low anonymity set. Consider waiting.')
    }

    // Withdraw via relayer (no gas needed on recipient)
    const txHash = await this.privacyCash.withdraw({
      note,
      recipient,
      relayer: 'https://relayer.privacycash.io',
      maxFee: 0.02
    })

    // Mark note as spent
    await this.storage.markNoteSpent(noteId)

    return txHash
  }

  async getBalance(): Promise<number> {
    const notes = await this.storage.getUnspentNotes()
    const balance = await this.privacyCash.getPrivateBalance({
      notes,
      token: 'SOL'
    })
    return balance.total / 1e9  // Convert lamports to SOL
  }
}

// Usage
const wallet = new PrivacyWallet(connection)

// Deposit 5 SOL
const commitment = await wallet.deposit(5, userKeypair)
console.log(`Deposited. Save commitment: ${commitment}`)

// Wait for anonymity set to grow...
// (In production, wait 24-48+ hours)

// Withdraw to fresh address
const freshWallet = Keypair.generate()
const txHash = await wallet.withdraw(noteId, freshWallet.publicKey)
console.log(`Withdrawn to ${freshWallet.publicKey}: ${txHash}`)

Conclusion: When to Use Pool Mixing

Pool mixing is an excellent choice when:

  • You need simple deposit/withdraw privacy without complex key management
  • You’re operating on a single chain (Solana in PrivacyCash’s case)
  • The pool has sufficient participation (check anonymity set before depositing)
  • You can wait for optimal withdrawal timing
  • You want battle-tested cryptography with years of production use

Pool mixing is less ideal when:

  • You need privacy guarantees independent of adoption (consider cryptographic hiding like Pedersen commitments)
  • You need homomorphic properties (proving amount relationships without revealing amounts)
  • You’re building cross-chain applications (pool mixing is typically single-chain)
  • You need constant, predictable privacy rather than crowd-dependent anonymity

PrivacyCash represents the state-of-the-art in pool mixing on Solana, with compliance features that address regulatory concerns and arbitrary amounts that solve classical denomination attacks. For many privacy use cases, it’s an excellent solution.

Understanding pool mixing deeply - its mechanics, guarantees, and limitations - makes you a better privacy-aware developer. Whether you build on PrivacyCash, alternative approaches like Pedersen commitments, or combine multiple techniques, this foundation will serve you well.


This is part of SIP Protocol’s Privacy Education Series. Next up: comparing cryptographic approaches to privacy - pool mixing vs Pedersen commitments vs stealth addresses.

References: