Zero-knowledge proofs (ZKPs) are cryptographic methods that allow one party (the prover) to prove to another party (the verifier) that a statement is true without revealing any additional information beyond the validity of the statement itself. When implemented on a blockchain, these verifiers enable powerful privacy-preserving applications.
This tutorial will guide you through creating a simple ZK onchain verifier using Circom and Solidity. We'll build a basic system where a user can prove they know a secret number without revealing it.
Before we begin, make sure you have:
Node.js and npm installed
Basic understanding of Solidity
Familiarity with basic cryptography concepts
A code editor (VS Code recommended)
npm install -g circom
npm install -g snarkjs
mkdir zk-verifier-tutorial
cd zk-verifier-tutorial
npm init -y
Our ZK system will consist of three main parts:
A Circom circuit (the proving system)
A Solidity verifier contract
A frontend application to interact with the system
Let's create a simple circuit that proves knowledge of a secret number. Create a file named circuit.circom
:
pragma circom 2.0.0;
template SecretNumber() {
// Private input: the secret number
signal private input secret;
// Public input: the hash of the secret number
signal public input hash;
// Output: whether the proof is valid
signal output out;
// Component to compute hash
component hash = Poseidon(1);
// Connect the secret to the hash component
hash.inputs[0] <== secret;
// Verify that the provided hash matches the computed hash
hash.out === hash;
// Set output to 1 if verification passes
out <== 1;
}
component main = SecretNumber();
build
directory:mkdir build
circom circuit.circom --r1cs --wasm --sym -o build
snarkjs groth16 setup build/circuit.r1cs pot12_final.ptau build/circuit_final.zkey
Now, let's create a Solidity contract that will verify the proofs on-chain. Create a file named Verifier.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecretNumberVerifier is Ownable {
// Event emitted when a valid proof is submitted
event ProofVerified(address indexed prover, uint256 hash);
// Mapping to store verified hashes
mapping(uint256 => bool) public verifiedHashes;
// Function to verify the proof
function verifyProof(
uint[2] memory a,
uint[2][2] memory b,
uint[2] memory c,
uint[1] memory input
) public returns (bool) {
// Verify the proof using the verifier contract
bool isValid = verify(a, b, c, input);
if (isValid) {
verifiedHashes[input[0]] = true;
emit ProofVerified(msg.sender, input[0]);
}
return isValid;
}
// Function to check if a hash has been verified
function isHashVerified(uint256 hash) public view returns (bool) {
return verifiedHashes[hash];
}
}
Let's create a simple test script to verify our implementation. Create a file named test.js
:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const snarkjs = require("snarkjs");
describe("SecretNumberVerifier", function () {
let verifier;
let secret = 42; // Our secret number
beforeEach(async function () {
const Verifier = await ethers.getContractFactory("SecretNumberVerifier");
verifier = await Verifier.deploy();
await verifier.deployed();
});
it("Should verify a valid proof", async function () {
// Generate proof
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
{ secret: secret },
"build/circuit.wasm",
"build/circuit_final.zkey"
);
// Verify on-chain
const result = await verifier.verifyProof(
[proof.pi_a[0], proof.pi_a[1]],
[
[proof.pi_b[0][0], proof.pi_b[0][1]],
[proof.pi_b[1][0], proof.pi_b[1][1]],
],
[proof.pi_c[0], proof.pi_c[1]],
[publicSignals[0]]
);
expect(result).to.be.true;
});
});
The Circuit:
Takes a private input (the secret number) that only the prover knows
Uses the Poseidon hash function, which is specifically designed for zero-knowledge proofs and is more efficient than traditional hash functions like SHA-256 in ZK contexts
Performs a cryptographic verification by comparing the computed hash of the secret number with a provided hash value
Outputs a binary signal (1) if the verification succeeds, indicating that the prover indeed knows a number that produces the claimed hash
The circuit's constraints ensure that the prover cannot cheat by providing false inputs
The Verifier Contract:
Acts as an on-chain verifier that receives and processes the zero-knowledge proof
Takes three main components as input:
The proof itself (containing the cryptographic evidence)
Public inputs (like the hash value that was claimed)
Verification key (pre-computed during the trusted setup)
Implements the Groth16 verification algorithm, which is a specific type of zk-SNARK that's particularly efficient for blockchain applications
Maintains a mapping of verified hashes to prevent replay attacks and enable future reference
Emits events that can be used by frontend applications to track successful verifications
Runs entirely on-chain, ensuring the verification process is trustless and decentralized
The Proof Generation Process:
The prover starts with their secret number and the circuit's constraints
Uses snarkjs to generate a cryptographic proof that demonstrates knowledge of the secret number
The proof generation process involves:
Creating a witness (a set of values that satisfy the circuit's constraints)
Running the circuit with the witness to generate the proof
Formatting the proof according to the Groth16 protocol
The generated proof is then sent to the verifier contract
The contract performs the verification without ever learning the actual secret number
This entire process ensures that the prover can demonstrate knowledge of the secret number while maintaining complete privacy
Circuit Security:
Ensure your circuit is properly constrained
Use well-audited cryptographic primitives
Consider side-channel attacks
Contract Security:
Implement proper access controls
Consider gas optimization
Add circuit-specific checks
System Security:
Protect private keys
Implement proper frontend security
Consider replay attacks
Advanced Features:
Add support for multiple secrets
Implement more complex circuits
Add frontend integration
Optimization:
Optimize circuit size
Reduce gas costs
Improve proof generation time
Integration:
Connect to a frontend application
Implement a user interface
Add more complex business logic
This tutorial has introduced you to the basics of creating a zero-knowledge onchain verifier. You've learned how to:
Create a basic Circom circuit
Generate and verify proofs
Deploy a verifier contract
Test the system
Remember that this is a simplified example. Real-world applications often require more complex circuits and additional security considerations. Always audit your code and consider professional review for production systems.