Skip to main content

Circom files overview

This section provides a comprehensive analysis of all the Circom files in the zkp-demo/circom_circuit folder. This is a zero-knowledge proof (ZKP) based privacy-preserving token system for Chromia that enables private token transfers while maintaining on-chain verifiability.

Overview of the system

The system implements a shielded balance architecture with three main privacy operations:

  1. Shield: Convert public tokens to private tokens
  2. Unshield: Convert private tokens back to public tokens
  3. Private transfer: Transfer tokens privately between users

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 private
template 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: Converts public tokens to private tokens

Key features:

  • Takes a user's public tokens and creates a private commitment
  • Uses Poseidon hash to derive private address from spending key
  • Creates a commitment using amount, private address, and blinding factor
  • Ensures the shielded amount matches 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 public
template 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: Converts private tokens back to public tokens

Key features:

  • Proves ownership of private tokens without revealing the private key
  • Generates a nullifier to prevent double-spending
  • Verifies the commitment matches the claimed amount
  • Publishes the unshielded amount to make it publicly visible

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 transfers
template 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 specified
component main {
public [
commitment_input,
commitment_output_sender,
commitment_output_recipient,
recipient_privateAddress,
nullifier
]
} = PrivateTransfer();

Purpose: Enables private token transfers between users

Key features:

  • Most complex circuit with ~3,000 constraints
  • Implements a UTXO-like model: spends one input note, creates two output notes (change + recipient)
  • Validates sender has sufficient funds using range checks
  • Ensures conservation of value (input = sender_change + transfer_amount)
  • Prevents double-spending with nullifiers
  • Keeps transfer amounts completely private

Key cryptographic concepts

Poseidon hash function

  • Used throughout all circuits as the primary hash function
  • Optimized for zero-knowledge proofs (more efficient than SHA-256 in ZK contexts)
  • Used for deriving private addresses and creating commitments

Commitments

  • Hide token amounts and ownership using the formula: Poseidon(amount, privateAddress, blindingFactor)
  • The blinding factor adds randomness to prevent brute-force attacks
  • Commitments are published on-chain but reveal no information about the underlying values

Nullifiers

  • Prevent double-spending of private notes
  • Calculated as: Poseidon(privateSpendKey, commitment)
  • Each note can only be spent once because its nullifier becomes public

Range checks

  • Ensure users have sufficient funds for transfers
  • Use 252-bit comparisons to handle large token amounts with decimal precision
  • Prevent underflow attacks in the arithmetic circuits

Security features

  1. Privacy: Token amounts and sender/recipient identities remain hidden
  2. Double-spending prevention: Nullifiers ensure each note can only be spent once
  3. Value conservation: Circuits enforce that inputs equal outputs in transfers
  4. Access control: Only holders of private spending keys can spend notes
  5. Range validation: Prevents negative balances and overflow attacks