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:
- Shield: turns public tokens into private ones
- Unshield: turns private tokens back into public ones
- Private transfer: moves tokens from one user to another without revealing details
Detailed analysis of each Circom file
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
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
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
- Privacy: Amounts and the identities of sender and recipient stay hidden
- Double-spending prevention: Nullifiers allow each note to be spent only once
- Value conservation: Transfer circuits require inputs to equal outputs
- Access control: Spending a note requires its private spending key
- 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.