Skip to main content

Custom Circuits

Learn how to create your own zero-knowledge circuits tailored to your specific use case.

When to Build Custom Circuits

Consider creating a custom circuit when:

  • Existing templates don't match your requirements
  • You need to verify unique business logic
  • You want to optimize for specific constraints
  • Your use case requires novel privacy guarantees

Circuit Development Workflow

graph LR
A[Design Logic] --> B[Write Circom]
B --> C[Compile Circuit]
C --> D[Generate Keys]
D --> E[Test Proofs]
E --> F[Deploy Verifier]
F --> G[Integrate]

Step 1: Design Your Logic

Define what you want to prove privately.

Example: Credit Score Verification

Goal: Prove credit score is above a threshold without revealing exact score.

Private Inputs:

  • creditScore - User's actual credit score

Public Inputs:

  • minScore - Minimum required score

Output:

  • scoreValid - 1 if score ≥ minScore, 0 otherwise

Sketch the Circuit

Input: creditScore (private)
Input: minScore (public)

Check: creditScore >= minScore
Output: scoreValid (1 or 0)

Step 2: Write the Circom Circuit

Create credit_score.circom:

pragma circom 2.0.0;

include "circomlib/circuits/comparators.circom";

template CreditScoreVerification() {
// Private input
signal input creditScore;

// Public inputs
signal input minScore;

// Output
signal output scoreValid;

// Comparison component (32-bit integers)
component gte = GreaterEqThan(32);

// Check: creditScore >= minScore
gte.in[0] <== creditScore;
gte.in[1] <== minScore;

// Output result
scoreValid <== gte.out;
}

component main {public [minScore]} = CreditScoreVerification();

Circuit Components

OpenZKTool includes circomlib with useful components:

ComponentPurposeExample
GreaterThanCompare numbersa > b
LessThanCompare numbersa < b
IsEqualEquality checka == b
MultiplexerConditional selectionif a then b else c
PoseidonHash functionHash private data
ECDSASignature verificationVerify signatures

Step 3: Compile the Circuit

# Create circuits directory
mkdir -p my-project/circuits

# Copy your circuit
cp credit_score.circom my-project/circuits/

# Compile
circom circuits/credit_score.circom \
--r1cs \
--wasm \
--sym \
--output circuits/build/

Output:

  • credit_score.r1cs - Constraint system
  • credit_score.wasm - Witness calculator
  • credit_score.sym - Symbol table

Step 4: Generate Trusted Setup Keys

Use a universal trusted setup:

# Download Powers of Tau (one-time)
wget https://hermez.s3-eu-west-1.amazonaws.com/powersOfTau28_hez_final_15.ptau

# Generate circuit-specific keys
snarkjs groth16 setup \
circuits/build/credit_score.r1cs \
powersOfTau28_hez_final_15.ptau \
circuits/build/credit_score_0000.zkey

# Contribute to phase 2 (optional but recommended)
snarkjs zkey contribute \
circuits/build/credit_score_0000.zkey \
circuits/build/credit_score_final.zkey \
--name="Your contribution" \
-v

# Export verification key
snarkjs zkey export verificationkey \
circuits/build/credit_score_final.zkey \
circuits/build/verification_key.json

Option B: Custom Ceremony

For production deployments with high security requirements:

# Phase 1: Powers of Tau ceremony
snarkjs powersoftau new bn128 15 pot15_0000.ptau
snarkjs powersoftau contribute pot15_0000.ptau pot15_0001.ptau
# ... multiple contributors ...
snarkjs powersoftau beacon pot15_final.ptau
snarkjs powersoftau prepare phase2 pot15_final.ptau prepared.ptau

# Phase 2: Circuit-specific ceremony
snarkjs groth16 setup circuits/build/credit_score.r1cs prepared.ptau credit_score_0000.zkey
snarkjs zkey contribute credit_score_0000.zkey credit_score_final.zkey

Step 5: Test Proof Generation

Create Test Input

test_input.json:

{
"creditScore": "720",
"minScore": "650"
}

Generate and Verify

# Generate witness
node circuits/build/credit_score_js/generate_witness.js \
circuits/build/credit_score_js/credit_score.wasm \
test_input.json \
witness.wtns

# Generate proof
snarkjs groth16 prove \
circuits/build/credit_score_final.zkey \
witness.wtns \
proof.json \
public.json

# Verify locally
snarkjs groth16 verify \
circuits/build/verification_key.json \
public.json \
proof.json

Expected Output:

[INFO]  snarkJS: OK!

Step 6: Export Soroban Verifier

Generate Rust code for Stellar deployment:

# Export Soroban contract
snarkjs zkey export soroban \
circuits/build/credit_score_final.zkey \
contracts/src/verifier.rs

Output: verifier.rs with embedded verification key

Customize the Contract

Edit contracts/src/lib.rs:

#![no_std]
use soroban_sdk::{contract, contractimpl, Env, Vec};

mod verifier;
use verifier::verify_groth16;

#[contract]
pub struct CreditScoreVerifier;

#[contractimpl]
impl CreditScoreVerifier {
pub fn verify(env: Env, proof: Vec<u8>) -> bool {
// Call generated verifier
verify_groth16(&env, proof)
}

pub fn check_credit(env: Env, applicant: Address, proof: Vec<u8>) -> bool {
let valid = Self::verify(env.clone(), proof);

if valid {
log!(&env, "Credit check passed for: {}", applicant);
}

valid
}
}

Step 7: Deploy to Stellar

# Build contract
cd contracts
cargo build --target wasm32-unknown-unknown --release

# Optimize
wasm-opt --strip-debug -Oz \
target/wasm32-unknown-unknown/release/credit_score_verifier.wasm \
-o verifier-optimized.wasm

# Deploy
soroban contract deploy \
--wasm verifier-optimized.wasm \
--source YOUR_SECRET_KEY \
--network testnet

Advanced Techniques

1. Range Proofs

Prove a value is within a range:

template RangeProof(n) {
signal input value;
signal input min;
signal input max;
signal output inRange;

component gte = GreaterEqThan(n);
component lte = LessEqThan(n);

gte.in[0] <== value;
gte.in[1] <== min;

lte.in[0] <== value;
lte.in[1] <== max;

inRange <== gte.out * lte.out; // AND gate
}

2. Set Membership

Prove a value is in an allowed set:

template SetMembership(setSize) {
signal input value;
signal input allowedValues[setSize];
signal output isMember;

component eq[setSize];
signal matches[setSize];

for (var i = 0; i < setSize; i++) {
eq[i] = IsEqual();
eq[i].in[0] <== value;
eq[i].in[1] <== allowedValues[i];
matches[i] <== eq[i].out;
}

// OR gate: at least one match
signal or[setSize];
or[0] <== matches[0];
for (var i = 1; i < setSize; i++) {
or[i] <== or[i-1] + matches[i] - (or[i-1] * matches[i]);
}

isMember <== or[setSize - 1];
}

3. Merkle Tree Verification

Prove membership in a large set efficiently:

include "circomlib/circuits/poseidon.circom";

template MerkleTreeVerifier(levels) {
signal input leaf;
signal input pathElements[levels];
signal input pathIndices[levels];
signal input root;

component hashers[levels];
signal levelHashes[levels + 1];

levelHashes[0] <== leaf;

for (var i = 0; i < levels; i++) {
hashers[i] = Poseidon(2);

// Left or right based on path index
hashers[i].inputs[0] <== levelHashes[i] * (1 - pathIndices[i]) +
pathElements[i] * pathIndices[i];
hashers[i].inputs[1] <== pathElements[i] * (1 - pathIndices[i]) +
levelHashes[i] * pathIndices[i];

levelHashes[i + 1] <== hashers[i].out;
}

root === levelHashes[levels];
}

Optimization Tips

1. Minimize Constraints

More constraints = larger proofs + slower generation.

Bad:

signal intermediate1 <== a * b;
signal intermediate2 <== intermediate1 * c;
signal result <== intermediate2 * d;

Good:

signal result <== a * b * c * d;

2. Use Efficient Components

Prefer built-in circomlib components:

OperationEfficientInefficient
ComparisonGreaterThanManual bit decomposition
HashingPoseidonSHA256 (many constraints)
ConditionalsMux1Custom if/else logic

3. Batch Operations

Process multiple items in one circuit:

template BatchVerifier(n) {
signal input values[n];
signal input minValues[n];
signal output allValid;

component checks[n];
signal results[n];

for (var i = 0; i < n; i++) {
checks[i] = GreaterEqThan(32);
checks[i].in[0] <== values[i];
checks[i].in[1] <== minValues[i];
results[i] <== checks[i].out;
}

// AND all results
signal and[n];
and[0] <== results[0];
for (var i = 1; i < n; i++) {
and[i] <== and[i-1] * results[i];
}

allValid <== and[n-1];
}

Testing Best Practices

1. Unit Test Components

# Test individual components
circom test_component.circom --test

2. Integration Testing

import { wasm as wasm_tester } from "circom_tester";

describe("Credit Score Circuit", function () {
let circuit;

before(async function () {
circuit = await wasm_tester("circuits/credit_score.circom");
});

it("should verify valid credit score", async function () {
const witness = await circuit.calculateWitness({
creditScore: "720",
minScore: "650"
});

await circuit.checkConstraints(witness);
await circuit.assertOut(witness, { scoreValid: 1 });
});

it("should reject invalid credit score", async function () {
const witness = await circuit.calculateWitness({
creditScore: "600",
minScore: "650"
});

await circuit.assertOut(witness, { scoreValid: 0 });
});
});

3. Fuzzing

Test with random inputs:

for (let i = 0; i < 1000; i++) {
const creditScore = Math.floor(Math.random() * 850);
const minScore = 650;

const witness = await circuit.calculateWitness({
creditScore: creditScore.toString(),
minScore: minScore.toString()
});

const expected = creditScore >= minScore ? 1 : 0;
await circuit.assertOut(witness, { scoreValid: expected });
}

Security Considerations

1. Soundness

Ensure constraints fully enforce your logic:

// BAD: Doesn't constrain the result
signal output result;
result <-- a + b; // <-- is unconstrained assignment

// GOOD: Properly constrains
signal output result;
result <== a + b; // <== adds constraint

2. Underflow/Overflow

Be careful with field arithmetic:

// Field elements wrap around
// 0 - 1 = p - 1 (large number)

// Use safe comparison
component gte = GreaterEqThan(32);
gte.in[0] <== a;
gte.in[1] <== b;

3. Input Validation

Validate all inputs in constraints:

template SafeCircuit() {
signal input age;
signal output valid;

// Ensure age is reasonable (0-150)
component rangeCheck = LessEqThan(8);
rangeCheck.in[0] <== age;
rangeCheck.in[1] <== 150;

valid <== rangeCheck.out;
}

Common Pitfalls

1. Unconstrained Signals

// BAD
signal x;
x <-- a * b; // x is not constrained!

// GOOD
signal x;
x <== a * b; // x is constrained to equal a * b

2. Division

// Division is not directly supported
// Use inverse multiplication instead

signal inverse;
inverse <== 1 / divisor; // Computed off-circuit
divisor * inverse === 1; // Constraint checks it's correct

3. Non-Deterministic Operations

// BAD: Random values
signal random <-- Math.random();

// GOOD: Deterministic from inputs
signal hash <== Poseidon([input1, input2]);

Next Steps


Questions? GitHub Discussions