Zero Knowledge Circuits
The ZK logic is using a LeanIMT merkle tree, as its one of the few audited merkle tree's recommended by ZK-kit.
All circuits are written in Circom, if you are new to circom, we recommend you go throw this tutorials:
https://learn.0xparc.org/materials/circom/learning-group-1/intro-zkp/
Source code:
https://codeberg.org/KusamaShield/Solidity_helpers/src/branch/main/contracts/new_circuits
Circuits explaination
A ZK-SNARK circuit system built with Circom 2.1 and Groth16 on BN254. It enables private withdrawals from an on-chain commitment pool with UTXO-style commitment refresh for transaction unlinkability.
Overview
A user deposits funds by publishing a commitment (a Poseidon hash encoding value, asset type, nullifier, and secret) into an on-chain Merkle tree. To withdraw, the user generates a zero-knowledge proof that they know the preimage of some commitment in the tree — without revealing which one. The spent commitment is invalidated via a nullifier hash, and a new "change" commitment is inserted back into the tree.
Circuit Architecture
withdraw.circom ← top-level circuit (orchestrator)
├── commitment.circom ← three-layer Poseidon commitment scheme
│ └── poseidon_bn254.circom ← Poseidon hash wrapper (BN254)
└── merkle_tree.circom ← LeanIMT variable-depth inclusion proof
└── poseidon_bn254.circom
poseidon_bn254.circom — Hash Primitive
A parameterized wrapper around circomlib's Poseidon hash. Poseidon is a ZK-friendly hash function — far cheaper inside an arithmetic circuit than SHA-256 or Keccak.
PoseidonBN254(n): signal input in[n] → signal output out
commitment.circom — Three-Layer Commitment
Constructs a commitment in three layers, each serving a distinct purpose:
| Layer | Computation | Purpose |
|---|---|---|
| 1 | nullifierHash = Poseidon(nullifier) | Published on-chain to prevent double-spending |
| 2 | precommitment = Poseidon(nullifier, secret) | Binds the nullifier to a secret only the owner knows |
| 3 | commitment = Poseidon(value, asset, precommitment) | The Merkle tree leaf, encoding value and asset type |
Separating the nullifier hash from the secret allows the nullifier to be revealed (marking a commitment as spent) without leaking the secret or the commitment's position in the tree.
merkle_tree.circom — LeanIMT Inclusion Proof
Implements a Lean Incremental Merkle Tree inclusion proof with variable depth (up to maxDepth = 254).
At each tree level:
- The
leafIndexbits determine left/right child ordering. - If a sibling is zero (empty subtree), the node propagates unchanged — this is the "lean" optimization that avoids hashing against placeholder nodes.
- If a sibling is non-zero, the ordered pair is hashed with Poseidon.
The computed root is compared against the public root input. This approach is more constraint-efficient than a fixed-depth tree because most levels in a sparse tree have zero siblings.
withdraw.circom — Main Circuit
The top-level Withdraw(254) template orchestrates the full proof in eight steps:
| Step | Operation | Description |
|---|---|---|
| 1 | Compute existing commitment | Hashes (existingValue, asset, existingNullifier, existingSecret) through the three-layer scheme |
| 2 | Output nullifier hash | Published on-chain so the contract can reject double-spends |
| 3 | Merkle inclusion proof | Verifies the existing commitment is in the tree against the public root |
| 4 | 128-bit range checks | Constrains both withdrawnValue and remainingValue to 128 bits (prevents underflow/overflow) |
| 5 | Nullifier uniqueness | Asserts existingNullifier != newNullifier to prevent nullifier reuse |
| 6 | Compute new commitment | Hashes (remainingValue, asset, newNullifier, newSecret) — the "change" UTXO |
| 7 | Output new commitment | Inserted into the on-chain tree by the contract |
| 8 | Replay protection | Constrains context into the proof (e.g., chain ID or tx hash) to prevent cross-chain/cross-tx replay |
Signals
Public inputs:
withdrawnValue— amount being withdrawnroot— current Merkle tree roottreeDepth— actual depth of the treecontext— replay protection binding (chain ID, tx hash, etc.)
Public outputs:
existingNullifierHash— marks the old commitment as spentnewCommitmentHash— the change commitment to insert into the tree
Private inputs:
asset— asset identifierexistingValue,existingNullifier,existingSecret— preimage of the spent commitmentnewNullifier,newSecret— fresh randomness for the change commitmentsiblings[254],leafIndex— Merkle proof path
Privacy Properties
- Sender privacy — the Merkle path is private, so the proof doesn't reveal which commitment is being spent.
- Transaction unlinkability — each withdrawal creates a fresh commitment with a new nullifier and secret, preventing linkage of sequential transactions by the same user.
- Double-spend prevention — the nullifier hash is published on-chain; the contract rejects any previously-seen nullifier hash.
- Value integrity — 128-bit range checks ensure no one can create value from nothing or withdraw more than they deposited.
- Replay protection — the
contextbinding prevents proof reuse across different chains or transactions.