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 Mux1 from circomlib to select left/right based on pathIndex.

  • Enforces each pathIndex[i] ∈ {0,1} (booleanity).


2.2 Transfer(depth)

File: transfer.circom

Purpose: Verify a private withdrawal that:

  1. proves deposit membership, 2) binds a nullifier to prevent double-spend,

  2. 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 equal merkleRootInput).

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 inCommitment to merkleRoot.

  • Root consistency: merkleRoot == merkleRootInput.

  • Nullifier binding: nullifierHash == Poseidon2(inCommitment, 1).

  • Sum check: inAmount == out1Amount + out2Amount.


3. Protocol Flow

3.1 Deposit

  1. User picks (inAmount, inBlinding).

  2. Computes inCommitment = Poseidon2(inAmount, inBlinding).

  3. 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

  1. User selects a known merkleRootInput and fetches corresponding path (inPathElements, inPathIndices).

  2. User chooses outputs (out1Amount, out1Blinding) and (out2Amount, out2Blinding).

  3. User proves in Transfer(depth):

    • inCommitment is in the tree with root merkleRootInput.

    • nullifierHash = Poseidon2(inCommitment, 1).

    • outCommit{1,2} built as Poseidon commitments of outputs.

    • inAmount == out1Amount + out2Amount.

  4. Contract verifies the proof, checks nullifierHash is unused, and then:

    • records nullifierHash as 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) == 0 ensures 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: merkleRoot and merkleRootInput are 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 inside Transfer(depth) if you want trust-less routing.

  • Multiple outputs: Generalize to k outputs 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.circom

    • circomlib/circuits/mux1.circom (for Merkle left/right selection)

    • circomlib/circuits/comparators.circom (available for range/equality if extended)

  • main component instantiated as Transfer(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].