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

FeatureDescription
8 public signalsnewCommitmentHash, existingNullifierHash, contextHash, withdrawnValue, treeDepth, context, root, asset
Linkability fixNo deposits[] mapping — nullifierHash not exposed at deposit time
Known-roots window16-slot recent-roots window on-chain
Leaner eventsDeposit(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])

poseidon1 is a single-input Poseidon — NOT poseidon2(nullifier, 0). Using the wrong function produces mismatched nullifierHash and fails on-chain.

V7 Public Signals

IndexSignalPurpose
[0]newCommitmentHashChange commitment inserted into tree
[1]existingNullifierHashMarks the spent commitment (double-spend prevention)
[2]contextHashReplay protection
[3]withdrawnValueAmount being withdrawn
[4]treeDepthFixed at 128
[5]contextChain-specific binding
[6]rootMerkle tree root
[7]assetAsset 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:

LayerComputationPurpose
1nullifierHash = Poseidon(nullifier)Published on-chain to prevent double-spending
2precommitment = Poseidon(nullifier, secret)Binds the nullifier to a secret only the owner knows
3commitment = 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 leafIndex bits 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:

StepOperationDescription
1Compute existing commitmentHashes through the three-layer scheme
2Output nullifier hashPublished on-chain so the contract can reject double-spends
3Merkle inclusion proofVerifies the existing commitment is in the tree against the public root
4Range checksConstrains withdrawnValue to valid range
5Nullifier uniquenessAsserts existingNullifier != newNullifier
6Compute new commitmentThe "change" UTXO
7Output new commitmentInserted into the on-chain tree by the contract
8Replay protectionBinds 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 context binding prevents proof reuse across different chains or transactions.