Zero Knowledge Circuits
The ZK logic uses 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
V7 Circuit (Current)
The V7 circuit is a ZK-SNARK circuit built with Circom 2.1 and Groth16 on BN254. It enables private withdrawals from an on-chain commitment pool with 8 public signals and a linkability fix — no deposits[] mapping means deposits and withdrawals cannot be correlated on-chain.
V7 Improvements Over Previous Versions
| Feature | Description |
|---|---|
| 8 public signals | newCommitmentHash, existingNullifierHash, contextHash, withdrawnValue, treeDepth, context, root, asset |
| Linkability fix | No deposits[] mapping — nullifierHash not exposed at deposit time |
| Known-roots window | 16-slot recent-roots window on-chain |
| Leaner events | Deposit(address,bytes32) — only asset and commitment |
V7 Commitment Derivation
nullifier = poseidon2([secret, 1])
nullifierHash = poseidon1([nullifier])
precommitment = poseidon2([nullifier, secret])
valueAsset = poseidon2([amountWei, assetId])
commitment = poseidon2([valueAsset, precommitment])
poseidon1is a single-input Poseidon — NOTposeidon2(nullifier, 0). Using the wrong function produces mismatched nullifierHash and fails on-chain.
V7 Public Signals
| Index | Signal | Purpose |
|---|---|---|
[0] | newCommitmentHash | Change commitment inserted into tree |
[1] | existingNullifierHash | Marks the spent commitment (double-spend prevention) |
[2] | contextHash | Replay protection |
[3] | withdrawnValue | Amount being withdrawn |
[4] | treeDepth | Fixed at 128 |
[5] | context | Chain-specific binding |
[6] | root | Merkle tree root |
[7] | asset | Asset precompile address (0 for native) |
V7 Event Format
event Deposit(address indexed asset, bytes32 commitment);
Only the asset address and commitment are emitted. The nullifierHash is never exposed — it is only revealed in the withdrawal proof's public signals. This prevents linking deposits to withdrawals.
Context Hash
contextHash = keccak256(abi.encodePacked(senderAddress)) % BN254_R
Where BN254_R = 21888242871839275222246405745257275088548364400416034343698204186575808495617.
Circuit Architecture (V7)
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 template orchestrates the full proof:
| Step | Operation | Description |
|---|---|---|
| 1 | Compute existing commitment | Hashes 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 | Range checks | Constrains withdrawnValue to valid range |
| 5 | Nullifier uniqueness | Asserts existingNullifier != newNullifier |
| 6 | Compute new commitment | The "change" UTXO |
| 7 | Output new commitment | Inserted into the on-chain tree by the contract |
| 8 | Replay protection | Binds context into the proof |
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 — 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.