Chromia
Start Building

07 / 19Module 1: Writing Circom circuitsAdvanced

Circom files overview

This page walks through every Circom file in the zkp-demo/circom_circuit folder. Together, these circuits form a privacy-preserving token system for Chromia built on zero-knowledge proofs (ZKPs): you can move tokens privately, and the chain can still verify each operation.

Overview of the system

The design follows a shielded balance architecture and supports three privacy operations:

  1. Shield: turns public tokens into private ones
  2. Unshield: turns private tokens back into public ones
  3. Private transfer: moves tokens from one user to another without revealing details

Detailed analysis of each Circom file

shield_operation.circom

circom_circuit/shield_operation.circom
pragma circom 2.0.0;include "./node_modules/circomlib/circuits/poseidon.circom";include "./node_modules/circomlib/circuits/comparators.circom";// Template for shielding public tokens to privatetemplate ShieldOperation() {    // Private inputs (known only to the prover)    signal input privateSpendKey;    signal input amount;    signal input blindingFactor;    signal input userPubKey; // The public key of the user shielding tokens    signal input shieldAmount; // Amount being shielded    // Public inputs (visible on-chain)    signal output commitment;            // 1. Derive private address from private key    component hashPrivateKey = Poseidon(1);    hashPrivateKey.inputs[0] <== privateSpendKey;    signal privateAddress;    privateAddress <== hashPrivateKey.out;        // 2. Calculate commitment    component commitmentCalc = Poseidon(3);    commitmentCalc.inputs[0] <== amount;    commitmentCalc.inputs[1] <== privateAddress;    commitmentCalc.inputs[2] <== blindingFactor;    commitment <== commitmentCalc.out;        // 3. Ensure shield amount equals private amount    shieldAmount === amount;}component main { public [userPubKey, shieldAmount] } = ShieldOperation(); 

Purpose: Turns public tokens into private tokens.

Key features:

  • Accepts a user's public tokens and produces a private commitment for them
  • Derives the private address from the spending key with the Poseidon hash
  • Builds the commitment from the amount, the private address, and a blinding factor
  • Checks that the shielded amount equals the private amount

unshield_operation.circom

circom_circuit/unshield_operation.circom
pragma circom 2.0.0;include "./node_modules/circomlib/circuits/poseidon.circom";include "./node_modules/circomlib/circuits/comparators.circom";// Template for unshielding private tokens back to publictemplate UnshieldOperation() {    // Private inputs (known only to the prover)    signal input privateSpendKey;    signal input amount;    signal input blindingFactor;        // Public inputs (visible on-chain)    signal input commitment;    signal input nullifier;    signal input userPubKey; // The public key of the user unshielding tokens    signal input unshieldAmount; // Amount being unshielded        // 1. Derive private address from private key    component hashPrivateKey = Poseidon(1);    hashPrivateKey.inputs[0] <== privateSpendKey;    signal privateAddress;    privateAddress <== hashPrivateKey.out;        // 2. Calculate commitment    component commitmentCalc = Poseidon(3);    commitmentCalc.inputs[0] <== amount;    commitmentCalc.inputs[1] <== privateAddress;    commitmentCalc.inputs[2] <== blindingFactor;    commitment === commitmentCalc.out;        // 3. Calculate nullifier to prevent double-spending    component nullifierGen = Poseidon(2);    nullifierGen.inputs[0] <== privateSpendKey;    nullifierGen.inputs[1] <== commitment;    nullifier === nullifierGen.out;        // 4. Ensure unshield amount equals private amount    unshieldAmount === amount;}component main { public [userPubKey, unshieldAmount, commitment, nullifier] } = UnshieldOperation(); 

Purpose: Turns private tokens back into public tokens.

Key features:

  • Lets you prove you own private tokens while keeping the private key secret
  • Produces a nullifier so the same tokens cannot be spent twice
  • Confirms that the commitment corresponds to the claimed amount
  • Outputs the unshielded amount publicly so anyone can see it

private_transfer.circom

circom_circuit/private_transfer.circom
pragma circom 2.0.0;include "./node_modules/circomlib/circuits/poseidon.circom";include "./node_modules/circomlib/circuits/comparators.circom";// Template for private-to-private token transferstemplate PrivateTransfer() {    // Private inputs (known only to the prover)    signal input privateSpendKey;           // Sender's spending key    signal input amount_input;              // Amount of the input note    signal input privateAddress_input;      // Sender's private address    signal input blindingFactor_input;      // Blinding factor of input note    signal input transfer_amount;           // Amount being transferred    signal input blindingFactor_output_sender;     // Blinding factor for sender's change    signal input blindingFactor_output_recipient;  // Blinding factor for recipient's note        // Public inputs (visible on-chain)    signal input commitment_input;          // Input note commitment    signal input commitment_output_sender;  // Sender's change note commitment    signal input commitment_output_recipient; // Recipient's note commitment    signal input recipient_privateAddress;  // Recipient's private address    signal input nullifier;                 // Nullifier for input note        // 1. Verify that privateAddress_input is correctly derived from privateSpendKey    component hashPrivateKey = Poseidon(1);    hashPrivateKey.inputs[0] <== privateSpendKey;    privateAddress_input === hashPrivateKey.out;        // 2. Verify input commitment    component inputCommitmentCalc = Poseidon(3);    inputCommitmentCalc.inputs[0] <== amount_input;    inputCommitmentCalc.inputs[1] <== privateAddress_input;    inputCommitmentCalc.inputs[2] <== blindingFactor_input;    commitment_input === inputCommitmentCalc.out;        // 3. Calculate sender's change amount    signal sender_change;    sender_change <== amount_input - transfer_amount;        // 4. Verify sender's output commitment (change note)    component senderOutputCommitmentCalc = Poseidon(3);    senderOutputCommitmentCalc.inputs[0] <== sender_change;    senderOutputCommitmentCalc.inputs[1] <== privateAddress_input;    senderOutputCommitmentCalc.inputs[2] <== blindingFactor_output_sender;    commitment_output_sender === senderOutputCommitmentCalc.out;        // 5. Verify recipient's output commitment    component recipientOutputCommitmentCalc = Poseidon(3);    recipientOutputCommitmentCalc.inputs[0] <== transfer_amount;    recipientOutputCommitmentCalc.inputs[1] <== recipient_privateAddress;    recipientOutputCommitmentCalc.inputs[2] <== blindingFactor_output_recipient;    commitment_output_recipient === recipientOutputCommitmentCalc.out;        // 6. Verify nullifier    component nullifierCalc = Poseidon(2);    nullifierCalc.inputs[0] <== privateSpendKey;    nullifierCalc.inputs[1] <== commitment_input;    nullifier === nullifierCalc.out;        // 7. Ensure transfer amount is positive (non-zero)    component isZero = IsZero();    isZero.in <== transfer_amount;    isZero.out === 0; // transfer_amount must not be zero        // 8. Range check: ensure sender_change >= 0 (sufficient funds)    // Since Circom works with field elements, we need to ensure no underflow    // With 6 decimal places, amounts can be large (1 token = 1,000,000 base units)    // Use 252-bit comparison to handle large token amounts safely    component gtEqZero = GreaterEqThan(252); // 252-bit comparison for large amounts with decimals    gtEqZero.in[0] <== amount_input;    gtEqZero.in[1] <== transfer_amount;    gtEqZero.out === 1; // amount_input >= transfer_amount}// The main component with public signals specifiedcomponent main {     public [        commitment_input,         commitment_output_sender,         commitment_output_recipient,         recipient_privateAddress,         nullifier    ] } = PrivateTransfer();

Purpose: Lets users send tokens to each other privately.

Key features:

  • The most complex of the circuits, at roughly 3,000 constraints
  • Follows a UTXO-style model: one input note is spent and two output notes are created (one for change, one for the recipient)
  • Uses range checks to confirm the sender holds enough funds
  • Enforces value conservation (input = sender_change + transfer_amount)
  • Blocks double-spending through nullifiers
  • Reveals nothing about the transfer amount

Key cryptographic concepts

Poseidon hash function

  • Serves as the main hash function in every circuit
  • Designed for zero-knowledge proofs, where it costs less than SHA-256
  • Derives private addresses and builds commitments

Commitments

  • Conceal token amounts and ownership, computed as Poseidon(amount, privateAddress, blindingFactor)
  • The blinding factor injects randomness, which defeats brute-force guessing
  • They are stored on-chain, yet expose nothing about the values they hide

Nullifiers

  • Stop a private note from being spent more than once
  • Computed as Poseidon(privateSpendKey, commitment)
  • Once a note is spent, its nullifier is public, so a second spend of that note is rejected

Range checks

  • Confirm that a user has enough funds before a transfer
  • Compare values at 252 bits so large token amounts with decimal precision fit
  • Guard the arithmetic circuits against underflow attacks

Security features

  1. Privacy: Amounts and the identities of sender and recipient stay hidden
  2. Double-spending prevention: Nullifiers allow each note to be spent only once
  3. Value conservation: Transfer circuits require inputs to equal outputs
  4. Access control: Spending a note requires its private spending key
  5. Range validation: Rules out negative balances and overflow attacks
Written for this site from Chromia’s public course. Code samples are the course’s own.