ZK allows us to create applications with private data and execution. This opens the door to many new use cases, like the one we'll create in this guide: an anonymous and secure voting system combining Circom and Solidity.
Circom and dependencies
If you don't have Circom installed yet, install it with the following commands. I'm using node v20, but it should work with other versions as well.
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
git clone https://github.com/iden3/circom.git
cd circom
cargo build --release
cargo install --path circom
npm install -g snarkjs
We'll also use the Circom libraries where the Poseidon function that we'll be using is located.
git clone https://github.com/iden3/circomlib.git
1. Public key creation
The method we will use to conduct anonymous and secure voting is by proving that we are part of a group without revealing our identity. For example, I will vote for the president of Honduras, demonstrating that I am a Honduran without revealing which specific Honduran I am. This is called "proof of inclusion in a set."
The most practical way to achieve this in zk and blockchain is through Merkle trees. We will place the voters as leaves in the tree and prove that we are one of them without disclosing which one.
Since the tree is public, we will use a set of public-private key pairs so that each voter can cast their vote only once.
You might wonder if we can use the public keys from our Ethereum wallet (e.g., from MetaMask). In future guides like this one, I'll address that topic just as I did with noir. To reach that point, you'll need the fundamentals from this guide. So stay tuned and subscribe!
Now, let's create the public keys for the following private keys using the circuit privateKeyHasher.circom below:
111
222
333
444
privateKeyHasher.circom
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
template privateKeyHasher() {
signal input privateKey;
signal output publicKey;
component poseidonComponent;
poseidonComponent = Poseidon(1);
poseidonComponent.inputs[0] <== privateKey;
publicKey <== poseidonComponent.out;
log(publicKey);
}
component main = privateKeyHasher();
input.json
{
"privateKey": "111"
}
Compile and compute the circuit with the commands below, and you'll see the result in the terminal.
circom privateKeyHasher.circom --r1cs --wasm --sym --c
node privateKeyHasher_js/generate_witness.js privateKeyHasher_js/privateKeyHasher.wasm input.json witness.wtns
The result of the 4 private keys should be as follows:
Private key | Public key |
---|---|
111 | 13377623690824916797327209540443066247715962236839283896963055328700043345550 |
222 | 3370092681714607727019534888747304108045661953819543369463810453568040251648 |
333 | 19430878135540641438890585969007029177622584902384053006985767702837167003933 |
444 | 2288143249026782941992289125994734798520452235369536663078162770881373549221 |
Is it necessary to do this through Circom? The answer is no. By using Circom, we're performing a lot of unnecessary computation. For now, we're doing it this way to ensure that the implementation of the Poseidon hashing algorithm we'll use later is compatible. This is not recommended for production projects.
2. Tree Creation
Now we have the four leaves of our tree positioned as follows
└─ ???
├─ ???
│ ├─ 13377623690824916797327209540443066247715962236839283896963055328700043345550
│ └─ 3370092681714607727019534888747304108045661953819543369463810453568040251648
└─ ???
├─ 19430878135540641438890585969007029177622584902384053006985767702837167003933
└─ 2288143249026782941992289125994734798520452235369536663078162770881373549221
Next, we're going to generate the Merkle tree branch by branch. Remember that Merkle trees are generated by hashing each of their leaves and branches in pairs until reaching the root.
To generate the complete tree, we'll execute the following function that hashes two leaves to generate their root. We'll do this a total of 3 times because that's what's needed to obtain the root of a tree with 4 leaves: root = hash(hash(A, B), hash(C, D))
.
hashLeaves.circom
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
template hashLeaves() {
signal input leftLeaf;
signal input rightLeaf;
signal output root;
component poseidonComponent;
poseidonComponent = Poseidon(2);
poseidonComponent.inputs[0] <== leftLeaf;
poseidonComponent.inputs[1] <== rightLeaf;
root <== poseidonComponent.out;
log(root);
}
component main = hashLeaves();
Here are the inputs needed to generate the first branch. Similarly, you can generate the other branch and the root.
input.json
{
"leftLeaf": "13377623690824916797327209540443066247715962236839283896963055328700043345550",
"rightLeaf": "3370092681714607727019534888747304108045661953819543369463810453568040251648"
}
Similar to the previous step, with the following commands, the circuit will be compiled and the root will be printed given its two leaves.
circom hashLeaves.circom --r1cs --wasm --sym --c
node hashLeaves_js/generate_witness.js hashLeaves_js/hashLeaves.wasm input.json witness.wtns
This is how the full tree looks like:
└─ 172702405816516791996779728912308790882282610188111072512380034048458433129
├─ 8238706810845716733547504554580992539732197518335350130391048624023669338026
│ ├─ 13377623690824916797327209540443066247715962236839283896963055328700043345550
│ └─ 3370092681714607727019534888747304108045661953819543369463810453568040251648
└─ 11117482755699627218224304590393929490559713427701237904426421590969988571596
├─ 19430878135540641438890585969007029177622584902384053006985767702837167003933
└─ 2288143249026782941992289125994734798520452235369536663078162770881373549221
3. Generate Proof of an Anonymous Vote
To generate a vote, we need to pass the following parameters to the circuit:
-
privateKey
: The user's private key. -
root
: The root of the tree ensures that we are operating within the correct set. Additionally, for clarity, we could add the contract and the chain where the vote will be executed. This variable will be public and accessible to the smart contract. -
proposalId
andvote
: The vote chosen by the user. -
pathElements
andpathIndices
: The minimal information needed to reconstruct the root. This includespathElements
, which are the leaf or branch nodes, and pathIndices, which show which path to take for hashing, where 0 represents nodes on the left and 1 represents nodes on the right.
proveVote.circom
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
template switchPosition() {
signal input in[2];
signal input s;
signal output out[2];
s * (1 - s) === 0;
out[0] <== (in[1] - in[0])*s + in[0];
out[1] <== (in[0] - in[1])*s + in[1];
}
template privateKeyHasher() {
signal input privateKey;
signal output publicKey;
component poseidonComponent;
poseidonComponent = Poseidon(1);
poseidonComponent.inputs[0] <== privateKey;
publicKey <== poseidonComponent.out;
}
template nullifierHasher() {
signal input root;
signal input privateKey;
signal input proposalId;
signal output nullifier;
component poseidonComponent;
poseidonComponent = Poseidon(3);
poseidonComponent.inputs[0] <== root;
poseidonComponent.inputs[1] <== privateKey;
poseidonComponent.inputs[2] <== proposalId;
nullifier <== poseidonComponent.out;
}
template proveVote(levels) {
signal input privateKey;
signal input root;
signal input proposalId;
signal input vote;
signal input pathElements[levels];
signal input pathIndices[levels];
signal output nullifier;
signal leaf;
component hasherComponent;
hasherComponent = privateKeyHasher();
hasherComponent.privateKey <== privateKey;
leaf <== hasherComponent.publicKey;
component selectors[levels];
component hashers[levels];
signal computedPath[levels];
for (var i = 0; i < levels; i++) {
selectors[i] = switchPosition();
selectors[i].in[0] <== i == 0 ? leaf : computedPath[i - 1];
selectors[i].in[1] <== pathElements[i];
selectors[i].s <== pathIndices[i];
hashers[i] = Poseidon(2);
hashers[i].inputs[0] <== selectors[i].out[0];
hashers[i].inputs[1] <== selectors[i].out[1];
computedPath[i] <== hashers[i].out;
}
root === computedPath[levels - 1];
component nullifierComponent;
nullifierComponent = nullifierHasher();
nullifierComponent.root <== root;
nullifierComponent.privateKey <== privateKey;
nullifierComponent.proposalId <== proposalId;
nullifier <== nullifierComponent.nullifier;
}
component main {public [root, proposalId, vote]} = proveVote(2);
input.json
{
"privateKey": "111",
"root": "172702405816516791996779728912308790882282610188111072512380034048458433129",
"proposalId": "0",
"vote": "1",
"pathElements": ["3370092681714607727019534888747304108045661953819543369463810453568040251648", "11117482755699627218224304590393929490559713427701237904426421590969988571596"],
"pathIndices": ["0","0"]
}
Let's test if everything works correctly:
circom proveVote.circom --r1cs --wasm --sym --c
node proveVote_js/generate_witness.js proveVote_js/proveVote.wasm input.json witness.wtns
If there were no issues, nothing should be printed in the terminal.
4. Verify an on-chain vote, from Soldity
With the following commands, we carry out the initial ceremony, also known as the trusted setup.
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
snarkjs groth16 setup proveVote.r1cs pot12_final.ptau proveVote_0000.zkey
snarkjs zkey contribute proveVote_0000.zkey proveVote_0001.zkey --name="1st Contributor Name" -v
snarkjs zkey export verificationkey proveVote_0001.zkey verification_key.json
Next, we generate the verifier contract in Solidity.
snarkjs zkey export solidityverifier proveVote_0001.zkey verifier.sol
Upon executing this command, a verifier contract will be generated in the file verifier.sol. Now deploy that contract on-chain.
Next, deploy the following contract on-chain, which contains the logic for voting and proof verification. Pass the address of the verifier contract we just deployed as a parameter in the constructor.
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
interface ICircomVerifier {
function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals) external view returns (bool);
}
contract CircomVoter {
ICircomVerifier circomVerifier;
uint public publicInput;
struct Proposal {
string description;
uint deadline;
uint forVotes;
uint againstVotes;
}
uint merkleRoot;
uint proposalCount;
mapping (uint proposalId => Proposal) public proposals;
mapping (uint nullifier => bool isNullified) public nullifiers;
constructor(uint _merkleRoot, address circomVeriferAddress) {
merkleRoot = _merkleRoot;
circomVerifier = ICircomVerifier(circomVeriferAddress);
}
function propose(string memory description, uint deadline) public {
proposals[proposalCount] = Proposal(description, deadline, 0, 0);
proposalCount += 1;
}
function castVote(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals) public {
circomVerifier.verifyProof(_pA, _pB, _pC, _pubSignals);
uint nullifier = _pubSignals[0];
uint merkleRootPublicInput = _pubSignals[1];
uint proposalId = uint(_pubSignals[2]);
uint vote = uint(_pubSignals[3]);
require(block.timestamp < proposals[proposalId].deadline, "Voting period is over");
require(merkleRoot == merkleRootPublicInput, "Invalid merke root");
require(!nullifiers[nullifier], "Vote already casted");
nullifiers[nullifier] = true;
if(vote == 1)
proposals[proposalId].forVotes += 1;
else if (vote == 2)
proposals[proposalId].againstVotes += 1;
}
}
Now, create the first proposal for voting by calling the propose() function. For example, you can test by creating a vote with "Do we eat pizza?" as the description and with 1811799232 as the deadline, which expires in 2027.
Next, let's generate a proof in the format required to verify it in Remix.
snarkjs groth16 prove proveVote_0001.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
snarkjs generatecall
Let's pass the result from the terminal as a parameter in Remix, and we'll see how the vote was executed by accessing the data of proposal 0 through the proposals mapping.
We observe that our vote was counted without revealing who the sender was. Try to cast the same vote again, and you'll see that it won't be possible—the transaction will revert. This is because we nullified the vote so that each voter can only cast one vote.
Sources and official documentation:
Thanks for reading this guide!
Follow Filosofía Código on dev.to and in Youtube for everything related to Blockchain development.
Top comments (0)