Zero-Knowledge Proofs
How Velum uses ZK circuits to verify transactions without revealing sender, recipient, or amounts.
The Circuit
Velum uses a Groth16 proof system with a custom circuit called transaction2. This circuit verifies four properties simultaneously, without revealing any private inputs.
What the Proof Hides
The ZK proof creates a clear boundary between what an on-chain observer can see and what remains private:
| Observer Sees | Observer Cannot See |
|---|---|
| A transaction occurred | Which UTXO was spent |
| Net deposit/withdrawal amount | Who owns the UTXO |
| Merkle root (state commitment) | Private keys |
| Nullifiers (anti-double-spend) | Blinding factors |
| New output commitments | Individual UTXO amounts |
The proof guarantees correctness ā funds cannot be created from nothing, double-spent, or stolen ā while revealing absolutely nothing about the transaction's participants or internal structure.
Proof Generation
Proofs are generated client-side in the browser using snarkjs and WebAssembly. No server ever sees private inputs.
1const { proof, publicSignals } = await snarkjs.groth16.fullProve(2{3 // Private inputs (never leave the browser)4 inAmount: [utxo1.amount, utxo2.amount],5 inBlinding: [utxo1.blinding, utxo2.blinding],6 inPrivateKey: [utxo1.keypair.privkey, utxo2.keypair.privkey],7 inPathElements: [merkleProof1.elements, merkleProof2.elements],8 inPathIndices: [merkleProof1.indices, merkleProof2.indices],9 outAmount: [outUtxo1.amount, outUtxo2.amount],10 outBlinding: [outUtxo1.blinding, outUtxo2.blinding],11 outPubkey: [outUtxo1.pubkey, outUtxo2.pubkey],1213 // Public inputs (verified on-chain)14 root: currentMerkleRoot,15 publicAmount: depositAmount,16 extDataHash: hashOfExternalData,17 inputNullifier: [nullifier1, nullifier2],18 outputCommitment: [commitment1, commitment2],19},20circuitWasm, // transaction2.wasm (~3 MB)21provingKey // transaction2.zkey (~16 MB)22);Circuit Files
The circuit requires two files that are loaded lazily in the browser:
| File | Size | Purpose |
|---|---|---|
transaction2.wasm | ~3 MB | Compiled circuit (constraint system) |
transaction2.zkey | ~16 MB | Proving key (from trusted setup ceremony) |
1// Files are cached in IndexedDB after first download2// Subsequent loads are instant from cache34// Loading strategy:5// 1. Check IndexedDB cache (by CIRCUIT_VERSION)6// 2. If miss: fetch from /circuit.wasm and /circuit.zkey7// 3. Show download progress (0-100%)8// 4. Store in cache for next time9// 5. Generate proof (~2-5 seconds)1011// Prefetch hint: after 5s idle on /pay or /withdraw pages12// the files start downloading in the backgroundProof generation happens entirely in the browser. The proving key and circuit never need to be trusted by the user ā they can verify the circuit's constraints are correct by inspecting the open-source circom code.
On-Chain Components
The ZK proof is verified on-chain by Privacy Cash's verifier contract. The on-chain state consists of four components:
Merkle Tree
Merkle Tree (depth: 26)
āāā Stores UTXO commitments as leaves
āāā Capacity: 2^26 = 67,108,864 leaves
āāā Hash function: Poseidon (ZK-friendly)
āāā Root published in every transaction
When a new UTXO is created:
1. Compute commitment = Poseidon(amount, pubkey, blinding, mint)
2. Insert commitment at next available leaf
3. Update root hash (path from leaf to root)
4. New root becomes the "current state"Nullifier Set
Nullifier Set (PDAs on Solana)
āāā Each spent UTXO produces a unique nullifier
āāā Nullifiers are stored as Program Derived Addresses (PDAs)
āāā Before accepting a transaction:
ā ā Check nullifier PDA doesn't exist
ā ā If exists: REJECT (double-spend attempt)
ā ā If new: CREATE PDA (mark as spent)
āāā Nullifiers cannot be linked to their source UTXOConfig Account & Address Lookup Table
| Component | Purpose |
|---|---|
| Config Account | Stores protocol parameters (max deposit, fee rate) |
| ALT (Address Lookup Table) | 12 pre-registered addresses to reduce TX size |
Relayer API
The relayer serves as a privacy-preserving intermediary ā it indexes encrypted outputs, provides Merkle proofs, and submits withdrawal transactions so the recipient's wallet never appears on-chain.
| Endpoint | Method | Purpose |
|---|---|---|
/config | GET | Fee rates and configuration |
/deposit | POST | Relay SOL deposit (pre-signed by sender) |
/deposit/spl | POST | Relay SPL token deposit (pre-signed) |
/withdraw | POST | Execute SOL withdrawal (relayer signs) |
/withdraw/spl | POST | Execute SPL withdrawal (relayer signs) |
/tree/state | GET | Current Merkle root + next index |
/tree/proof/{index} | GET | Merkle proof for a specific leaf |
/utxos/range?start=X&end=Y | GET | Batch fetch encrypted outputs |
/utxos/check/{output} | GET | Poll deposit confirmation |
The relayer cannot steal funds (it never has access to private keys) and cannot break privacy (it cannot link deposits to withdrawals). Its worst-case attack is censorship ā refusing to relay transactions ā but funds remain safely in the pool and can be withdrawn through an alternative relayer.
Further Reading
- UTXO System ā The UTXOs that ZK proofs operate on
- Cryptography ā The hash functions and encryption used in the circuit
- Privacy Model ā How ZK proofs enable the overall privacy guarantees