Zapfi: A Zero-Knowledge Privacy Protocol
Draft v1.0
Written By Admin
Last updated 6 months ago
This document specifies the Zapfi protocol using two Circom circuits: `MerkleProof(depth)` and `Transfer(depth)`. The protocol enables private withdrawals by proving membership of a deposit commitment in a Merkle tree, preventing double-spend via a nullifier, and conserving value across outputs.
1. Overview
Goal: unlink deposits from withdrawals on-chain.
Privacy core: zkSNARK proofs over Circom circuits.
Hash primitive: Poseidon (arity-2) from
circomlib.Tree: Merkle tree verified inside the circuit.
No admin: verification is purely cryptographic; the contract checks a proof and enforces nullifier uniqueness.
2. Circuits
2.1 MerkleProof(depth)
File: merkle.circom
Purpose: Prove that a leaf is included in a Merkle tree with a public root.
Public/Private signals
Inputs (private by default unless exposed):
leaf: Poseidon commitment being proven.pathElements[depth]: sibling nodes along the path.pathIndex[depth]: direction bits (0 = leaf on left, 1 = leaf on right).
Output (public):
root: computed Merkle root.
Constraint sketch For i = 0..depth-1, compute
left_i = Mux1( pathIndex[i] ? pathElements[i] : hashes[i] )
right_i = Mux1( pathIndex[i] ? hashes[i] : pathElements[i] )
hashes[i+1] = Poseidon2(left_i, right_i)
pathIndex[i] * (pathIndex[i] - 1) == 0 // booleanity
with hashes[0] = leaf and root = hashes[depth].
Implementation details:
Uses
Mux1fromcircomlibto select left/right based onpathIndex.Enforces each
pathIndex[i] ∈ {0,1}(booleanity).
2.2 Transfer(depth)
File: transfer.circom
Purpose: Verify a private withdrawal that:
proves deposit membership, 2) binds a nullifier to prevent double-spend,
commits to two outputs, and 4) enforces value conservation.
Signals
Inputs (private unless made public by the contract wrapper):
inAmount: deposited value (note amount).inBlinding: deposit blinding.inPathElements[depth],inPathIndices[depth]: Merkle path to the deposit.merkleRootInput: expected Merkle root (will be checked inside circuit).out1Amount,out1Blinding: first output note (amount + blinding).out2Amount,out2Blinding: second output note (amount + blinding).
Outputs (public):
nullifierHash: binds to the input commitment, used on-chain to prevent reuse.outCommit1,outCommit2: Poseidon commitments for the two output notes.merkleRoot: recomputed root (must equalmerkleRootInput).
Internal commitments
inCommitment = Poseidon2(inAmount, inBlinding)
outCommit1 = Poseidon2(out1Amount, out1Blinding)
outCommit2 = Poseidon2(out2Amount, out2Blinding)
Merkle membership
MerkleProof(depth).leaf <- inCommitment
MerkleProof(depth).root -> merkleRoot
Constraint: merkleRoot == merkleRootInput
Nullifier derivation (domain-separated)
nullifierHash = Poseidon2(inCommitment, 1)
Note: This design binds the nullifier to the commitment with a domain separator
1, rather than hashing a standalone secret. It guarantees that each unique deposit commitment yields a unique nullifier.
Value conservation
inAmount == (out1Amount + out2Amount)
All constraints together
Poseidon commitments for input and two outputs.
Merkle inclusion of
inCommitmenttomerkleRoot.Root consistency:
merkleRoot == merkleRootInput.Nullifier binding:
nullifierHash == Poseidon2(inCommitment, 1).Sum check:
inAmount == out1Amount + out2Amount.
3. Protocol Flow
3.1 Deposit
User picks
(inAmount, inBlinding).Computes
inCommitment = Poseidon2(inAmount, inBlinding).Sends
inCommitment(and funds off-chain/on-chain, per implementation) to be appended as a leaf in the contract’s Merkle tree.
3.2 Withdraw
User selects a known
merkleRootInputand fetches corresponding path(inPathElements, inPathIndices).User chooses outputs
(out1Amount, out1Blinding)and(out2Amount, out2Blinding).User proves in
Transfer(depth):inCommitmentis in the tree with rootmerkleRootInput.nullifierHash = Poseidon2(inCommitment, 1).outCommit{1,2}built as Poseidon commitments of outputs.inAmount == out1Amount + out2Amount.
Contract verifies the proof, checks
nullifierHashis unused, and then:records
nullifierHashas spent,releases funds according to off-chain settlement rules (e.g., map commitments to actual asset transfers, or use relayers).
This circuit version does not embed recipient/relayer addresses directly. Integrators may:
map
outCommit*to notes redeemable by recipients,or extend the circuit with recipient/fee fields if desired.
4. Security Properties
Membership soundness: A withdrawal is only valid if the input commitment exists in the Merkle tree (
MerkleProof).Double-spend resistance:
nullifierHash = Poseidon2(inCommitment, 1)is unique per deposit commitment; the contract rejects repeated nullifiers.Balance conservation: The circuit enforces
inAmount == out1Amount + out2Amount.Anonymity set: All deposits in the tree compatible with the chosen root form the potential source set for a withdrawal.
Boolean path bits:
pathIndex[i] * (pathIndex[i] - 1) == 0ensures valid direction bits, ruling out malformed paths.
Assumptions
Poseidon is collision-resistant and preimage-resistant.
zkSNARK (Groth16 or similar via SnarkJS) provides knowledge soundness and zero-knowledge.
The contract maintains a correct Merkle root history and a nullifier set.
5. Contract Integration
Verification key: Export from trusted setup (per circuit) using SnarkJS.
On-chain storage:
rolling Merkle roots (recent history),
nullifier set (mapping
nullifierHash => spent).
Verification:
public inputs:
(merkleRoot, nullifierHash, outCommit1, outCommit2, merkleRootInput)— note:merkleRootandmerkleRootInputare constrained equal in-circuit.call verifier with the proof + public inputs.
require
!nullifiers[nullifierHash]; then set it spent.
Denominations: This circuit encodes amounts inside commitments and a sum check; you can either (a) enforce fixed denominations at the contract level, or (b) keep variable amounts and handle rounding/fees in off-chain note processing.
6. Extensibility
Recipient & relayer fields: Add
(recipient, fee)as public outputs and constrain them insideTransfer(depth)if you want trust-less routing.Multiple outputs: Generalize to
koutputs by repeating the Poseidon commitment pattern and updating the conservation check.Tree updates: Switch Poseidon arity or add a per-level domain separator if you later optimize the tree hasher.
7. Implementation Notes
Circuits import:
circomlib/circuits/poseidon.circomcircomlib/circuits/mux1.circom(for Merkle left/right selection)circomlib/circuits/comparators.circom(available for range/equality if extended)
maincomponent instantiated asTransfer(20)in the provided code, i.e., a tree of depth 20. Adjust for your contract’s tree height.
8. Appendix: Exact Constraints From Code
From transfer.circom:
inCommitment = Poseidon2(inAmount, inBlinding)
merkleRoot == merkleRootInput
nullifierHash = Poseidon2(inCommitment, 1)
outCommit1 = Poseidon2(out1Amount, out1Blinding)
outCommit2 = Poseidon2(out2Amount, out2Blinding)
inAmount == out1Amount + out2Amount
From merkle.circom: For each level i:
left_i = (pathIndex[i] ? pathElements[i] : hashes[i])
right_i = (pathIndex[i] ? hashes[i] : pathElements[i])
hashes[i+1] = Poseidon2(left_i, right_i)
pathIndex[i] * (pathIndex[i] - 1) == 0
with hashes[0] = leaf and root = hashes[depth].