How ZKMix Works
Technical overview of the deposit, wait, and withdrawal process
How ZKMix Works
ZKMix operates as a three-step process: deposit, wait, and withdraw. Each step is designed to contribute to the unlinkability between your source and destination addresses. This page explains the cryptographic mechanisms behind each step.
Overview
The fundamental principle is simple. Many users deposit the same fixed amount into a shared pool. Later, any depositor can withdraw that same amount to a completely different address by proving, via a zero-knowledge proof, that they previously made a deposit, without revealing which one. Because all deposits are the same size and the proof reveals nothing about which deposit is being spent, an observer cannot determine which deposit funds which withdrawal.
The process relies on three cryptographic primitives:
- Poseidon hash function: A hash function designed for efficient computation inside arithmetic circuits, used to create commitments and nullifier hashes.
- Incremental Merkle tree: A binary tree stored on-chain that holds all deposit commitments and allows efficient membership proofs.
- Groth16 ZK-SNARK: A proving system that generates succinct proofs of knowledge without revealing the underlying witness data.
Step 1: Deposit
When you make a deposit, the following operations occur.
Secret and Nullifier Generation
Your client generates two cryptographically random 31-byte values: a secret and a nullifier. These values are generated entirely on your device and are never transmitted to anyone.
secret = random_bytes(31) // 248 bits of entropy
nullifier = random_bytes(31) // 248 bits of entropyThe secret is the private knowledge you will later use to prove ownership of the deposit. The nullifier is the value that prevents double-spending: once its hash is revealed during withdrawal, it marks the deposit as spent.
Commitment Computation
The client computes a commitment by hashing the nullifier and secret together using the Poseidon hash function:
commitment = PoseidonHash(nullifier, secret)Poseidon is chosen over general-purpose hash functions like SHA-256 or Keccak because it is algebraically structured for arithmetic circuits. A Poseidon hash requires roughly 8x fewer constraints in a ZK circuit compared to SHA-256, which directly translates to faster proof generation and smaller proof sizes.
The commitment is a public value. It is submitted to the smart contract along with the deposit. However, because Poseidon is a one-way function, knowing the commitment reveals nothing about the secret or nullifier.
Note Generation
The client encodes the secret and nullifier into a note, which is a string that the user must save securely. The note is the only piece of information needed to withdraw the funds later.
note = "zkmix-sol-1.0-" + base58(nullifier + secret)If you lose the note, you lose access to the deposited funds permanently. There is no recovery mechanism because there is no party who knows the secret.
On-Chain Merkle Tree Insertion
The smart contract receives the commitment and inserts it as a leaf into the on-chain incremental Merkle tree. ZKMix uses a Merkle tree of depth 20, which supports up to 1,048,576 deposits per pool.
MerkleTree[nextIndex] = commitment
nextIndex += 1After insertion, the contract updates the Merkle root. The root is a single hash that summarizes the entire set of commitments. During withdrawal, the prover will demonstrate that their commitment is a leaf in a tree with this root.
The Merkle tree uses Poseidon hashes at every level. Each internal node is computed as:
parent = PoseidonHash(leftChild, rightChild)The contract stores the current path of hashes needed to compute the root efficiently when a new leaf is inserted, avoiding the need to recompute the entire tree.
Deposit Transaction
The complete deposit transaction sends the fixed denomination amount (for example, 10 SOL) along with the commitment to the ZKMix program. The program:
- Verifies the deposit amount matches the pool denomination.
- Checks that the commitment has not been submitted before.
- Inserts the commitment into the Merkle tree.
- Updates the stored Merkle root.
- Emits a deposit event with the commitment and the leaf index.
Step 2: Wait
After depositing, you should wait before withdrawing. The reason is the anonymity set: the number of deposits that have been made between your deposit and your withdrawal determines how many possible depositors you could be.
If you deposit and immediately withdraw, you are the only recent depositor, and the link is trivially obvious. If 500 other deposits have occurred since yours, an observer knows only that you are one of 501 possible depositors. The longer you wait and the more deposits that occur, the stronger your privacy.
Anonymity Set Size
The anonymity set for a given pool is the total number of unspent deposits. If a pool has received 5,000 deposits and 3,000 withdrawals have been made, the anonymity set size is 5,000 (since any of the 5,000 commitments could be the one being spent in a new withdrawal; observers do not know which commitments are still unspent).
In practice, because nullifier hashes are revealed during withdrawal but cannot be linked back to specific commitments without knowing the secret, the effective anonymity set is the total number of deposits ever made in the pool.
Timing Considerations
- Minimum wait: There is no enforced minimum. However, withdrawing within minutes of depositing provides minimal privacy.
- Recommended wait: Allow at least several hundred deposits to occur after yours before withdrawing. The ZKMix interface displays the current anonymity set size for each pool.
- Timing attacks: Avoid withdrawing at unusual times or in amounts that correlate with your deposit timing. Use relayers to avoid the need to fund the recipient address from a connected wallet.
Step 3: Withdraw
Withdrawal is the step where zero-knowledge proofs do their work. The user proves they know the secret behind one of the commitments in the Merkle tree without revealing which commitment it is.
Building the Witness
To generate a proof, the client needs the following witness data (private inputs to the circuit):
witness = {
// Private inputs (not revealed)
secret: the secret from your note,
nullifier: the nullifier from your note,
pathElements: the sibling hashes along the Merkle path,
pathIndices: the left/right direction at each tree level,
// Public inputs (revealed on-chain)
root: the Merkle root at the time of proof generation,
nullifierHash: PoseidonHash(nullifier),
recipient: the Solana address to receive the funds,
relayer: the relayer address (or zero),
fee: the relayer fee amount,
}The client fetches the current Merkle tree state from the chain to compute the path elements and path indices for the leaf corresponding to its commitment.
Circuit Logic
The ZK circuit enforces the following constraints:
- Commitment reconstruction: The circuit computes
commitment = PoseidonHash(nullifier, secret)from the private inputs and verifies that this value is consistent with the Merkle proof.
- Merkle membership proof: Starting from the commitment at the leaf level, the circuit walks up the tree using the path elements and path indices, computing each parent node as
PoseidonHash(left, right). It verifies that the computed root matches the publicrootinput.
- Nullifier hash computation: The circuit computes
nullifierHash = PoseidonHash(nullifier)and constrains it to equal the publicnullifierHashinput. This ensures the nullifier is correctly derived and can be checked for double-spending.
- Recipient and fee binding: The circuit takes the recipient address, relayer address, and fee as public inputs and includes them in the proof. This prevents an attacker from intercepting a proof and redirecting the funds.
// Simplified circuit pseudocode
template ZKMix(merkleTreeDepth) {
// Private inputs
signal private input secret;
signal private input nullifier;
signal private input pathElements[merkleTreeDepth];
signal private input pathIndices[merkleTreeDepth];
// Public inputs
signal input root;
signal input nullifierHash;
signal input recipient;
signal input relayer;
signal input fee;
// Step 1: Compute commitment
component commitmentHasher = Poseidon(2);
commitmentHasher.inputs[0] <== nullifier;
commitmentHasher.inputs[1] <== secret;
// Step 2: Verify Merkle membership
component tree = MerkleTreeChecker(merkleTreeDepth);
tree.leaf <== commitmentHasher.out;
tree.root <== root;
for (var i = 0; i < merkleTreeDepth; i++) {
tree.pathElements[i] <== pathElements[i];
tree.pathIndices[i] <== pathIndices[i];
}
// Step 3: Compute and verify nullifier hash
component nullifierHasher = Poseidon(1);
nullifierHasher.inputs[0] <== nullifier;
nullifierHash === nullifierHasher.out;
// Step 4: Bind recipient and fee to the proof
signal recipientSquare;
recipientSquare <== recipient * recipient;
signal feeSquare;
feeSquare <== fee * fee;
}Proof Generation
The client uses the witness data and the proving key (generated during the trusted setup) to produce a Groth16 proof. This computation happens entirely in the browser using WebAssembly.
const { proof, publicSignals } = await groth16.fullProve(
witness,
"zkmix.wasm", // compiled circuit
"zkmix_proving.zkey" // proving key from trusted setup
);The resulting proof consists of three elliptic curve points (A, B, C) and is approximately 192 bytes. The public signals include the Merkle root, nullifier hash, recipient, relayer, and fee.
On-Chain Verification
The withdrawal transaction submits the proof and public signals to the ZKMix program on Solana. The program:
- Verifies the Merkle root is a known root. The contract stores a history of recent roots to accommodate deposits that occur between proof generation and proof submission.
- Checks the nullifier hash has not been used before. If it has, the withdrawal is rejected as a double-spend attempt.
- Verifies the Groth16 proof using the on-chain verification key. This is a constant-time operation involving elliptic curve pairing checks.
- Records the nullifier hash so it cannot be used again.
- Transfers the funds to the specified recipient address, minus any relayer fee.
// On-chain verification pseudocode
fn process_withdrawal(
proof: Groth16Proof,
root: [u8; 32],
nullifier_hash: [u8; 32],
recipient: Pubkey,
relayer: Pubkey,
fee: u64,
) -> Result<()> {
// Verify root is known
require!(known_roots.contains(&root), "Unknown root");
// Verify nullifier has not been spent
require!(!spent_nullifiers.contains(&nullifier_hash), "Already spent");
// Verify the ZK proof
let public_inputs = [root, nullifier_hash, recipient, relayer, fee];
require!(groth16_verify(&proof, &public_inputs, &VERIFYING_KEY), "Invalid proof");
// Mark nullifier as spent
spent_nullifiers.insert(nullifier_hash);
// Transfer funds
transfer(pool_vault, recipient, denomination - fee)?;
if fee > 0 {
transfer(pool_vault, relayer, fee)?;
}
Ok(())
}Why This Is Private
An observer sees:
- A deposit of 10 SOL with commitment
0xabc123... - (Later) A withdrawal of 10 SOL to address
SoL456...with nullifier hash0xdef789...
They cannot link the two because:
- The commitment and the nullifier hash are unrelated without knowledge of the secret and nullifier.
- The ZK proof demonstrates knowledge of a valid secret/nullifier pair but does not reveal which commitment they correspond to.
- The proof only reveals that the commitment exists somewhere in the Merkle tree of all deposits.
- The fixed denomination means the amount provides no linking information.
The only information leaked is the fact that a withdrawal occurred at a particular time and that the withdrawer was one of the depositors in the pool. The larger the pool, the weaker this information becomes.
Summary
| Step | Action | On-Chain Data | Private Data |
|---|---|---|---|
| Deposit | Submit commitment + funds | Commitment, amount, timestamp | Secret, nullifier |
| Wait | Do nothing | Nothing | Nothing |
| Withdraw | Submit proof + nullifier hash | Nullifier hash, recipient, proof | Secret, nullifier, which commitment |
The cryptographic guarantee is that no computationally bounded adversary can determine which deposit funds a given withdrawal, assuming the underlying cryptographic assumptions (discrete log hardness, knowledge-of-exponent) hold and the trusted setup was performed honestly by at least one participant.