HomeDocsZKP Circom Definitions

ZKP Circom Definitions

GROTH16 | POSEIDON (CIRCOMLIB) | BN254 | R1CS (45K CONSTRAINTS)

The Groth16 Proof System

Veridaq uses Groth16, the most widely deployed zk-SNARK proving system in production. It was introduced by Jens Groth in 2016 and optimized by Bellman and the Zcash team for the BLS12-381 curve. Veridaq uses BN254, the curve supported natively by the EVM.

Groth16 has three properties that make it ideal for academic credential verification. First, the proofs are constant size — exactly 3 group elements (about 256 bytes) regardless of the circuit size. Second, verification time is constant too — a single pairing check. Third, the prover does not need to interact with the verifier after submitting the proof.

The tradeoff is that Groth16 requires a trusted setup ceremony for each circuit. Veridaq uses the Hermez Powers of Tau ceremony for phase 1 (universal) and a circuit-specific phase 2. The parameters are public and verifiable.

Trusted Setup Ceremony

Groth16 requires a structured reference string (SRS) generated through a multi-party computation. The ceremony has two phases.

Phase 1 is circuit-agnostic and produces the Powers of Tau. Veridaq uses the Hermez ceremony which had over 100 participants. Each participant contributed entropy and destroyed their toxic waste. As long as one participant was honest, the phase 1 parameters are secure.

Phase 2 is circuit-specific. It takes the phase 1 output and produces the proving key and verification key for the credential circuit. This phase runs locally using SnarkJS and applies a random beacon to ensure the toxic waste is destroyed even if the local machine was compromised.

If the trusted setup is compromised, an attacker could forge proofs for arbitrary inputs. This is why the ceremony must be reproducible and verifiable. Anyone can verify the phase 2 output against the phase 1 reference and the circuit R1CS.

pnpm circuit:setup runs this sequence
# Phase 1: Download Hermez Powers of Tau (POT)
wget https://hermez.s3-eu-west-1.amazonaws.com/powersOfTau28_hez_final_18.ptau

# Phase 2: Circuit-specific setup
snarkjs groth16 setup credential.r1cs pot18_final.ptau credential_0000.zkey

# Phase 2 contribution (random beacon)
snarkjs zkey contribute credential_0000.zkey credential_0001.zkey \
  --entropy="VERIDAQ_RANDOM_BEACON_$(date +%s)"

# Export verification key
snarkjs zkey export verificationkey credential_final.zkey verification_key.json

# Export Solidity verifier
snarkjs zkey export solidityverifier credential_final.zkey ZKVerifier.sol

The Poseidon Hash Commitment

Before any proof can be generated, the institution must submit a commitment to the CredentialRegistry contract. The commitment is a Poseidon hash of the student data combined with a random blinding factor.

Poseidon is a zero knowledge-friendly hash function designed specifically for use inside arithmetic circuits. Unlike SHA-256 or keccak256, which require thousands of R1CS constraints per hash, Poseidon compiles to approximately 100 constraints per permutation. This is why Veridaq uses Poseidon instead of keccak256.

// The commitment is computed off-chain by the backend
// It is the only value ever submitted on-chain

commitment = Poseidon(
  nameHash,          // sha256(full name) as field element
  matricHash,        // sha256(matric number) as field element
  cgpa,              // integer scaled by 100 (e.g. 450 for 4.50)
  classification,    // 0=PASS, 1=THIRD, 2=LOWER_CREDIT,
                     // 3=UPPER_CREDIT, 4=FIRST_CLASS
  courseHash,        // sha256(course code) as field element
  graduationYear,    // 4-digit integer
  blindingFactor     // random 256-bit scalar
)

The blinding factor is critical. It is a cryptographically random 256-bit value generated on the backend using Node.js crypto.randomBytes. Without it, an attacker could brute force the commitment by hashing known student data and comparing against the on-chain value. With it, the commitment is computationally hiding.

The nullifier is computed separately and prevents the same credential from being verified twice. It binds the matriculation number to the institution key:

nullifier = Poseidon(matricHash, institutionKey)

Private vs Public Signals

In Groth16, inputs to the circuit are divided into private signals (known only to the prover) and public signals (visible on-chain in the verification transaction). The circuit template declares which signals are which.

Private Inputs (8)

Known only to the backend. Destroyed after proof generation. Never submitted on-chain.

  • nameHashsha256 of full name
  • matricHashsha256 of matric number
  • cgpainteger multiplied by 100
  • classificationenum 0 to 4
  • courseHashsha256 of course code
  • graduationYear4-digit year
  • blindingFactorrandom 256-bit scalar
  • institutionKeyunique per institution

Public Inputs (4)

Visible on-chain. The verifier contract reads these during verification.

  • commitmentthe on-chain hash pointer
  • nullifierprevents double-verification
  • claimTypeinteger 1 to 6
  • thresholdnumeric boundary for the claim

The Six Claim Types

The ClaimDecoder template inside the circuit maps the claimType signal to one of six constraint templates. Each template checks a different condition against the private input signals.

1

Programme Completion

graduationYear == threshold

Employer submits the expected graduation year. The circuit checks the private graduationYear signal equals the threshold. This proves the candidate completed the programme in the stated year.

2

Minimum Lower Second Class

classification >= 2

The circuit checks that the classification signal is greater than or equal to 2 (Lower Credit classification or higher). This is the standard minimum for most graduate jobs in Nigeria.

3

Minimum Upper Second Class

classification >= 3

Same pattern but with a higher barrier. The employer wants candidates who graduated with Upper Credit or First Class. The circuit checks classification >= 3.

4

First Class Honours

classification == 4

The strictest classification check. The circuit proves the candidate graduated with First Class Honours. The threshold is ignored for this claim type.

5

CGPA Above Threshold

cgpa >= threshold

The employer sets a numeric CGPA threshold and the circuit proves the student's CGPA meets or exceeds it. The threshold is an integer scaled by 100. To check for CGPA >= 3.50, the employer submits threshold = 350.

6

Course Specific Completion

courseHash matches AND passing grade

The most sophisticated claim type. The employer specifies a course code. The circuit checks that the courseHash matches AND the classification represents a passing grade for that course. This is used when a specific prerequisite course is required.

The R1CS Constraint System

Circom compiles the circuit to a Rank-1 Constraint System (R1CS) with approximately 45,000 constraints. Each constraint is of the form A * B = C where A, B, and C are linear combinations of the circuit signals.

The constraints fall into three groups. First, the poseidon hash constraints. Each poseidon permutation requires about 20 constraints per round, and the 3-round sponge configuration adds about 100 constraints per hash. The circuit hashes 7 elements for the commitment and 2 elements for the nullifier, so the total hash constraint count is approximately 9 * 100 = 900 constraints.

Second, the comparison constraints. The circuit needs to check cgpa >= threshold and classification >= threshold. These comparisons are implemented using bit decomposition and subtraction with borrow checking. Each comparison adds approximately 500 constraints.

Third, the equality constraints. The commitment equality check and nullifier equality check each add a single constraint: hasher.out === commitment. The ClaimDecoder output check adds another single constraint: claim.out === 1. The remaining constraints come from signal routing, template instantiation, and the Circom compiler's internal bookkeeping.

credential.circom — Full Circuit
pragma circom 2.0.0;
include "poseidon.circom";
include "claim_decoder.circom";

template CredentialVerifier() {
  // ── Private Inputs (8) ──
  signal input nameHash;
  signal input matricHash;
  signal input cgpa;
  signal input classification;
  signal input courseHash;
  signal input graduationYear;
  signal input blindingFactor;
  signal input institutionKey;

  // ── Public Inputs (4) ──
  signal input commitment;
  signal input nullifier;
  signal input claimType;
  signal input threshold;

  // ── Constraint 1: Commitment binding ──
  // Hash all 7 private inputs + blinding factor
  // and assert equality with public commitment
  component commHasher = Poseidon(7);
  commHasher.inputs[0] <== nameHash;
  commHasher.inputs[1] <== matricHash;
  commHasher.inputs[2] <== cgpa;
  commHasher.inputs[3] <== classification;
  commHasher.inputs[4] <== courseHash;
  commHasher.inputs[5] <== graduationYear;
  commHasher.inputs[6] <== blindingFactor;
  commHasher.out === commitment;

  // ── Constraint 2: Nullifier binding ──
  // Bind matric to institution to prevent
  // cross-institution replay attacks
  component nullHasher = Poseidon(2);
  nullHasher.inputs[0] <== matricHash;
  nullHasher.inputs[1] <== institutionKey;
  nullHasher.out === nullifier;

  // ── Constraint 3: Claim satisfaction ──
  // The ClaimDecoder maps claimType to a
  // specific constraint template. Output
  // must be 1 for the proof to be valid.
  component claim = ClaimDecoder();
  claim.cgpa <== cgpa;
  claim.classification <== classification;
  claim.courseHash <== courseHash;
  claim.graduationYear <== graduationYear;
  claim.claimType <== claimType;
  claim.threshold <== threshold;
  claim.out === 1;
}

The ClaimDecoder Template

The ClaimDecoder is a Circom template that maps a claimType enum to the appropriate constraint template. It uses Circom's conditional signal routing to select from six possible claim templates without executing all of them.

claim_decoder.circom — Simplified
template ClaimDecoder() {
  signal input cgpa;
  signal input classification;
  signal input courseHash;
  signal input graduationYear;
  signal input claimType;
  signal input threshold;
  signal output out;

  // Ensure claimType is in range 1-6
  signal typeValid;
  component rangeCheck = Num2Bits(3);
  rangeCheck.in <== claimType;
  typeValid <== 1 - rangeCheck.out[3]; // Bit 3 must be 0

  // Route to the correct template
  // claimType 1: Programme completion
  //   out = 1 if graduationYear == threshold
  //
  // claimType 2: Min Lower Second (2.1 equivalent)
  //   out = 1 if classification >= 2
  //
  // claimType 3: Min Upper Second (2.2 equivalent)
  //   out = 1 if classification >= 3
  //
  // claimType 4: First Class
  //   out = 1 if classification == 4
  //
  // claimType 5: CGPA above threshold
  //   out = 1 if cgpa >= threshold
  //
  // claimType 6: Course completion
  //   out = 1 if courseHash matches AND passing grade
  //
  // Output is the logical OR of all type-specific
  // checks, but only one fires due to claimType
  component comp = IsEqual();
  comp.in[0] <== graduationYear;
  comp.in[1] <== threshold;
  // ... routing through claimType selectors
}

Proof Generation and Verification Flow

When an employer submits a verification request, the backend runs the following sequence to generate and submit the proof.

1

Decrypt the credential record

The backend retrieves the encrypted record from PostgreSQL using the matriculation number as the lookup key. It decrypts the AES-256-GCM ciphertext using the encryption key from the environment. The raw student data exists in memory for the next 2 seconds, then is garbage collected.

2

Compute private signal values

The backend hashes the student name and matric number using sha256 and converts them to BN254 field elements. The CGPA is multiplied by 100 and floored to an integer. The classification is mapped to the 0-4 enum. courseHash is computed from the course code. These become the 8 private input signals.

3

Run SnarkJS fullProve

The backend calls snarkjs.groth16.fullProve() with the private inputs, public inputs (commitment, nullifier, claimType, threshold), and the compiled WASM circuit. SnarkJS runs the prover inside Node.js using the WASM backend. Proof generation takes approximately 0.7 seconds on a modern CPU.

4

Submit proof to on-chain verifier

The backend encodes the proof (3 G1/G2 points) and the 4 public signals into a Solidity-compatible ABI format. It sends a transaction to the ZKVerifier contract on Base Sepolia via viem. The contract checks the proof using BN254 pairing precompiles at addresses 0x06, 0x07, and 0x08.

5

Check revocation status

Before accepting the proof, the verifier calls RevocationRegistry.isRevoked(nullifier). If the nullifier has been revoked, the proof is rejected. This ensures that degrees rescinded by the university are immediately unverifiable, regardless of proof validity.

Performance and Benchmarks

Measured on a 2023 laptop with Apple M3 Pro, 18 GB RAM, Node.js 22, and SnarkJS 0.7.4.

R1CS Constraints~45,000
Private Inputs8 signals
Public Inputs4 signals
Proof Generation (WASM)~0.7 seconds
Proof Size~256 bytes (3 G points)
Proving Key Size~45 MB
Verification Key Size~2 KB
On-Chain Gas Cost~236,000 gas
On-Chain Cost (Base)~$0.01 USD
Verification Time (EVM)~12 ms

The on-chain cost depends on Base Sepolia gas prices and the ETH/USD exchange rate. At 0.1 gwei and $1,669/ETH, 236,000 gas costs approximately $0.01. This makes VERIDAQ practical for high-volume employer verification use cases.

Security Model and Limitations

The zero knowledge proof provides soundness: if the proof verifies, the prover possesses a valid witness that satisfies all circuit constraints. Soundness relies on the security of the Groth16 trusted setup and the BN254 curve.

What the ZKP guarantees

  • The backend possesses the private data that produces the on-chain commitment.
  • The credential has not been revoked by the institution.
  • The claimed condition is mathematically satisfied by the private data.
  • The same credential cannot be verified twice because the nullifier is consumed.

What the ZKP does NOT guarantee

  • The accuracy of the original data. If the institution uploaded incorrect data, the proof will be valid but the credential is wrong. This is a data quality problem, not a cryptographic one.
  • The identity of the person submitting the proof. The backend has access to all private data and could generate proofs without the student's knowledge. The institution is trusted to control access to its backend.
  • The liveness of the institution. If the institution's backend goes offline, no new proofs can be generated for their credentials. On-chain commitments remain valid but inaccessible without the encrypted private data.

Why Poseidon Instead of SHA-256 or Keccak256

Hash functions designed for traditional computing (SHA-256, keccak256) are extremely expensive inside arithmetic circuits. The following table shows the constraint count for each hash function in Circom:

Hash FunctionConstraints per CallConstraints for 7 InputsProof Time (est.)
Poseidon (3 rounds)~100~700~0.7s
Pedersen (BabyJubJub)~10,000~70,000~5s
SHA-256~30,000~210,000~30s
Keccak256~50,000~350,000~60s

Using keccak256 instead of Poseidon would make proof generation take over a minute instead of under a second. The circuit would be 7 times larger and the proving key would be hundreds of megabytes. Poseidon is the correct choice for any ZKP system that needs to hash data inside the circuit.

Comparison with Alternative Approaches

ApproachPrivacyGas CostVerification SpeedTrust Model
VERIDAQ (Groth16)Full ZK~236k gas~12 msTrusted setup + honest prover
PLONKFull ZK~400k gas~15 msUniversal trusted setup
STARK (on-chain)Full ZK~5M gas~100 msNo trusted setup
Plain hash on-chainNo privacy~50k gasN/ACentralized
Paper certificatesFull exposureN/AWeeksManual verification