Zero-knowledge proofs (ZK proofs) allow one party (the prover) to convince another party (the verifier) that a statement is true without revealing any information beyond the truth of the statement itself.identiPay uses ZK proofs for two primary purposes:
Eligibility verification: Prove you meet age or other requirements without revealing your birthdate or identity
Shielded pool withdrawals: Prove you own a note in the pool without revealing which note
identiPay uses Groth16, a ZK-SNARK proof system known for small proof size (~192 bytes) and fast verification. Proofs are verified on-chain using Sui’s native sui::groth16 module.
template AgeCheck() { // Private inputs signal input birthYear; signal input birthMonth; signal input birthDay; signal input dobHash; signal input userSalt; // Public inputs signal input ageThreshold; signal input referenceDate; signal input identityCommitment; signal input intentHash; // ... constraints ...}component main {public [ageThreshold, referenceDate, identityCommitment, intentHash]} = AgeCheck();
Public inputs are visible on-chain and included in the proof. Private inputs never leave the prover’s device.
Bind the proof to a specific transaction intent (prevents replay):
age_check.circom
// These signals are public inputs declared in main.// We create dummy constraints to ensure they are not// optimized away by the compiler.signal identitySquared;identitySquared <== identityCommitment * identityCommitment;signal intentSquared;intentSquared <== intentHash * intentHash;
The intentHash public input links this proof to a specific payment, preventing reuse.
template PoolSpend(depth) { // Private inputs signal input noteAmount; signal input ownerKey; signal input salt; signal input pathElements[depth]; signal input pathIndices[depth]; // Public inputs signal input merkleRoot; signal input nullifier; signal input recipient; signal input withdrawAmount; signal input changeCommitment; // ... constraints ...}component main {public [merkleRoot, nullifier, recipient, withdrawAmount, changeCommitment]} = PoolSpend(20);
template MerkleProof(depth) { signal input leaf; signal input pathElements[depth]; signal input pathIndices[depth]; // 0 = left, 1 = right signal output root; component hashers[depth]; component indexChecks[depth]; signal currentHash[depth + 1]; currentHash[0] <== leaf; for (var i = 0; i < depth; i++) { // Ensure pathIndices[i] is binary (0 or 1) indexChecks[i] = IsZero(); indexChecks[i].in <== pathIndices[i] * (pathIndices[i] - 1); indexChecks[i].out === 1; hashers[i] = Poseidon(2); // Compute hash based on whether current node is left or right // ... currentHash[i + 1] <== hashers[i].out; } root <== currentHash[depth];}
This proves the note commitment exists in the Merkle tree with root merkleRoot.
// Verify Merkle proof: commitment is in the tree with merkleRootcomponent merkleProof = MerkleProof(depth);merkleProof.leaf <== commitment;for (var i = 0; i < depth; i++) { merkleProof.pathElements[i] <== pathElements[i]; merkleProof.pathIndices[i] <== pathIndices[i];}merkleProof.root === merkleRoot;
This proves the note exists in the pool without revealing its position.
3. Nullifier derivation
pool_spend.circom
// Compute nullifier = Poseidon(commitment, ownerKey)// This is deterministic per note+owner, preventing double-spends.component nullifierHasher = Poseidon(2);nullifierHasher.inputs[0] <== commitment;nullifierHasher.inputs[1] <== ownerKey;nullifierHasher.out === nullifier;
The nullifier is public but reveals nothing about which note is being spent.
4. Amount range check
pool_spend.circom
signal changeAmount;changeAmount <== noteAmount - withdrawAmount;// Decompose changeAmount into 64 bits to prove it's non-negativecomponent changeRangeCheck = Num2Bits(64);changeRangeCheck.in <== changeAmount;// Also range-check withdrawAmount to 64 bitscomponent withdrawRangeCheck = Num2Bits(64);withdrawRangeCheck.in <== withdrawAmount;
This prevents underflow: withdrawAmount ≤ noteAmount.
5. Change commitment validation
pool_spend.circom
component isChangeZero = IsZero();isChangeZero.in <== changeAmount;// When changeAmount == 0: changeCommitment must be 0signal zeroCheck;zeroCheck <== isChangeZero.out * changeCommitment;zeroCheck === 0;// When change > 0: changeCommitment must be non-zerocomponent isChangeCommitmentZero = IsZero();isChangeCommitmentZero.in <== changeCommitment;signal hasChange;hasChange <== 1 - isChangeZero.out;signal hasCommitment;hasCommitment <== 1 - isChangeCommitmentZero.out;signal changeNeedsCommitment;changeNeedsCommitment <== hasChange * (1 - hasCommitment);changeNeedsCommitment === 0;
The pool spend circuit (depth 20) has approximately 35,000 constraints. Proof generation takes ~2-3 seconds on modern hardware, and verification costs ~200K gas.
During deployment, the verification key for each circuit is registered:
zk_verifier.move
/// Create and share a new verification key for a circuit./// Called once during deployment to register each circuit's verification key.public fun create_verification_key( circuit_name: std::string::String, raw_vk: vector<u8>, ctx: &mut TxContext,) { let curve = groth16::bn254(); let pvk = groth16::prepare_verifying_key(&curve, &raw_vk); let vk = VerificationKey { id: object::new(ctx), circuit_name, pvk, }; transfer::share_object(vk);}
The verification key is “prepared” (pre-processed) during setup for efficient verification. This one-time cost saves gas on every subsequent proof verification.
The settlement contract uses ZK proofs to verify eligibility before processing payments:
settlement.move
// 1. Verify ZK proof of buyer eligibilityzk_verifier::assert_proof_valid(zk_vk, &zk_proof, &zk_public_inputs);// 2. Verify intent signature (buyer signed the canonical intent hash)intent::verify_intent_signature(&intent_sig, &intent_hash, &buyer_pubkey);// 3. Split exact amount and transfer to merchantlet exact = coin::split(payment, amount, ctx);transfer::public_transfer(exact, merchant);
If the proof is invalid, the entire transaction aborts atomically.
Proof verification is NOT free. Expect ~100-200K gas per proof depending on circuit complexity. Design your system to minimize proof verifications in hot paths.
identiPay chose Groth16 over other ZK proof systems for several reasons:
Advantages
Small proof size: ~192 bytes (fits in single transaction)
Fast verification: ~5-10ms, suitable for on-chain verification
Mature tooling: Circom + snarkjs well-tested and documented
Native blockchain support: Sui has sui::groth16 built-in
Trade-offs
Trusted setup required: Needs multi-party ceremony (security risk if compromised)
Circuit-specific keys: Each circuit needs separate setup
No updatability: Changing circuit requires new setup
Slower proving: 1-3 seconds per proof (acceptable for client-side)
Alternatives like PLONK (universal setup) or STARKs (no trusted setup) have larger proofs or higher verification costs, making them less suitable for on-chain verification on Sui.
Groth16 requires a trusted setup ceremony where participants contribute randomness. If ALL participants collude or are compromised, they could generate fake proofs.Mitigation:
Multi-party ceremony with diverse participants
Public verification of ceremony transcript
“Powers of Tau” universal setup for initial parameters
identiPay’s circuits use the Perpetual Powers of Tau ceremony trusted by projects like Tornado Cash and Hermez.
Public input binding
All security-critical values must be public inputs, not private inputs:
✅ intentHash (public) - binds proof to specific transaction
✅ merkleRoot (public) - binds proof to current pool state
✅ nullifier (public) - prevents double-spending
❌ Never make security parameters like ageThreshold private
Making a constraint check depend on a private input that doesn’t affect public outputs allows the prover to lie about that constraint.
Range check completeness
All values must be range-checked to prevent overflow attacks:
// Wrong: attacker could use negative amountsignal amount;// Correct: enforce amount fits in 64 bitscomponent amountCheck = Num2Bits(64);amountCheck.in <== amount;
identiPay circuits range-check all amounts, dates, and indices.
Nullifier uniqueness
For shielded pool withdrawals, the nullifier MUST be deterministic:
// Correct: deterministic per notenullifier <== Poseidon([commitment, ownerKey])// Wrong: includes random valuenullifier <== Poseidon([commitment, ownerKey, randomSalt])
The second version allows double-spending by generating different nullifiers for the same note.
Measured on Apple M2 (wallet) and Sui testnet (verification):
Circuit
Constraints
Proof Gen
Proof Size
Verification Gas
Age Check
~1,500
50ms
192 bytes
~100K
Pool Spend (depth 20)
~35,000
2.5s
192 bytes
~200K
Pool Spend (depth 24)
~45,000
3.2s
192 bytes
~250K
Proof generation is the bottleneck for user experience. Consider pre-generating proofs when possible or showing progress indicators for circuits >30K constraints.