How I Built an Anonymous Voting System on the Ethereum Blockchain Using Zero-Knowledge Proof

I’ve started to deal with the technology of zero-knowledge proofs because I was curious if it was possible to create an anonymous…

How I Built an Anonymous Voting System on the Ethereum Blockchain Using Zero-Knowledge Proof

Photo by Fred Moon on Unsplash

I’ve started to deal with the technology of zero-knowledge proofs because I was curious if it was possible to create an anonymous, unhackable voting system on the blockchain.

Paper-based voting (like the Elections) is a very expensive way of voting. I have no exact data, but it costs billions of dollars, and it is always in the air that somebody cheated it.

A blockchain-based system costs only a fraction of this price, and it is super secure because the blockchain is public and everybody can track everything on it.

The only thing that is untrackable is that who voted to which party thanks to the zero-knowledge proof (for a more detailed explanation, please read my previous article). So, blockchain-based voting seems like the holy grail of voting.

In my previous article, I promised a simple proof of concept. It is now completed and available on GitHub. I will show you how it works in this article.

The method that I’m using is the same as what is used by Tornado Cash. Tornado Cash is a non-custodial Ethereum and ERC20 privacy solution based on zkSNARKs.

The main component of it is a smart contract where you can deposit your money with a commitment that you can withdraw later by a nullifier. One commitment is assigned to only one nullifier, but nobody knows which nullifier is assigned to which commitment.

This is also usable for anonymous voting because everybody should vote only once, but nobody should know which vote is assigned to which voter. (I have a full article about the topic.)

My voting app is an Ethereum dApp. It is a static page with JavaScript without any backend (the backend is the smart contract on Ethereum). Because of this, it is tough to attack the system, because, on the blockchain, there is no single point of failure.

If you want to test it locally, the easiest way is to run a local blockchain by:

npx hardhat node

and set up the accounts in MetaMask. We will use the first 2 accounts.

In the first step, clone the repo, deploy the smart contracts, and start the app:

git clone https://github.com/TheBojda/zktree-vote
cd zktree-vote
npm i
npm run prepare (optional, npm i should run it)
npm run deploy
npm start

The dApp uses MetaMask to communicate the blockchain, so if you use it from your desktop browser, MetaMask has to be installed, and if you use it from your mobile phone, open it in the MetaMask application’s embedded browser.

When you open http://localhost:1234/ you will see the menu of the app:

Click on the registration to vote to open the registration page.

When you open the page, it automatically generates the commitment and the nullifier in the background, and stores it in the browser’s local storage.

The validation of the voter can be done in person at the voting place, or online by using a video conferencing system, etc. The validator can use the “Validator tool” for it:

To make it easy, I have added a QR reader to the page, so sending the commitment to the validator can be done by scanning the voter’s QR code, or the voter can copy it and send it in the chat (online validation).

When the validator checked the voter’s identity by checking her identity card, etc., he sends the commitment to the blockchain with a unique hash that can be the hash of the user’s ID card or any unique identifier.

This ensures that one voter will be registered only once with one commitment, which means she can vote only once.

The voting process is easy. You choose one option and send it to the blockchain. The app will use the nullifier that is generated in the background.

On the last page, you can check the results of the voting in real time.

That’s it. Simple and very comfortable way of voting, but it has all of the advantages of paper-based voting without all of the disadvantages.

After this short intro, let’s see some code.

To develop this voting dApp, I’ve used my zk-merkle-tree library. It is a JavaScript library that uses Tornado Cash’s zkSNARK-based method and hides all of the complexity of zero-knowledge proofs.

I plan to write a full article about this library, so in this article, I won’t write about it in more detail.

The smart contract of the voting system looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "zk-merkle-tree/contracts/ZKTree.sol";

contract ZKTreeVote is ZKTree {
address public owner;
mapping(address => bool) public validators;
mapping(uint256 => bool) uniqueHashes;
uint numOptions;
mapping(uint => uint) optionCounter;

constructor(
uint32 _levels,
IHasher _hasher,
IVerifier _verifier,
uint _numOptions
) ZKTree(_levels, _hasher, _verifier) {
owner = msg.sender;
numOptions = _numOptions;
for (uint i = 0; i <= numOptions; i++) optionCounter[i] = 0;
}

function registerValidator(address _validator) external {
require(msg.sender == owner, "Only owner can add validator!");
validators[_validator] = true;
}

function registerCommitment(
uint256 _uniqueHash,
uint256 _commitment
) external {
require(validators[msg.sender], "Only validator can commit!");
require(
!uniqueHashes[_uniqueHash],
"This unique hash is already used!"
);
_commit(bytes32(_commitment));
uniqueHashes[_uniqueHash] = true;
}

function vote(
uint _option,
uint256 _nullifier,
uint256 _root,
uint[2] memory _proof_a,
uint[2][2] memory _proof_b,
uint[2] memory _proof_c
) external {
require(_option <= numOptions, "Invalid option!");
_nullify(
bytes32(_nullifier),
bytes32(_root),
_proof_a,
_proof_b,
_proof_c
);
optionCounter[_option] = optionCounter[_option] + 1;
}

function getOptionCounter(uint _option) external view returns (uint) {
return optionCounter[_option];
}
}

The smart contract is inherited from ZKTree (from the zk-merkle-tree library) and uses its _commit and _nullify methods. The _commit method stores the commitment, and the _nullify method stores the nullifier and verifies the zero-knowledge proof for it.

The owner can add the validators by calling the registerValidator method. Only validators can send a commitment to the smart contract after checking the voter’s identity.

The last method is getOptionCounter which you can use to query the result of the voting in real-time.

That’s all. Thanks to zk-merkle-tree, the voting contract is super simple, every complexity is hidden behind the library.

The dApp itself is a vue.js single-page application. The commitment and the nullifier are generated in the VoterRegistration component by using the generateCommitment from zk-merkle-tree and stored in the local storage.

this.commitment = JSON.parse(
localStorage.getItem("zktree-vote-commitment")
);
if (!this.commitment) {
this.commitment = await generateCommitment();
localStorage.setItem(
"zktree-vote-commitment",
JSON.stringify(this.commitment)
);
}

The ValidatorTool component is used by the validator to send the commitment to the blockchain. It reads the contract address from the contracts.json (generated by the deployment process) and sends the commitment with the unique hash to the voting contract.

const abi = [
"function registerCommitment(uint256 _uniqueHash, uint256 _commitment)",
];
const provider = new ethers.providers.Web3Provider(
(window as any).ethereum
);
await provider.send("eth_requestAccounts", []);
const signer = provider.getSigner();
const contracts = await (await fetch("contracts.json")).json();
const contract = new ethers.Contract(contracts.zktreevote, abi, signer);
try {
await contract.registerCommitment(this.uniqueHash, this.commitment);
} catch (e) {
alert(e.reason);
}

The Vote component is used by the voter for voting. It generates the ZK proof by using the calculateMerkleRootAndZKProof method from zk-merkle-tree and sends it to the blockchain with the nullifier.

const commitment = JSON.parse(
localStorage.getItem("zktree-vote-commitment")
);

const abi = [
"function vote(uint _option,uint256 _nullifier,uint256 _root,
uint[2] memory _proof_a,uint[2][2] memory _proof_b,
uint[2] memory _proof_c)"
,
];
const provider = new ethers.providers.Web3Provider(
(window as any).ethereum
);
await provider.send("eth_requestAccounts", []);
const signer = provider.getSigner();
const contracts = await (await fetch("contracts.json")).json();
const contract = new ethers.Contract(contracts.zktreevote, abi, signer);
const cd = await calculateMerkleRootAndZKProof(
contracts.zktreevote,
signer,
TREE_LEVELS,
commitment,
"verifier.zkey"
);
try {
await contract.vote(
this.option,
cd.nullifierHash,
cd.root,
cd.proof_a,
cd.proof_b,
cd.proof_c
);
} catch (e) {
alert(e.reason);
}

As you can see, the code is really simple, because all of the complexity of ZKP is hidden by the zk-merkle-tree library. Based on this code, you can easily build your own voting system.

Possible improvements:

  • Voter whitelisting. You can build a Merkle tree from the unique hashes of the voters before voting, and check the voter’s existence in it when the validator sends the commitment. It prevents using fake identities.
  • Better validator management. If there are thousands of validators, then a Merkle tree is a better way to batch-register them. If voters and validators are divided into districts, then you can generate separate Merkle trees for districts, and assign the validators to these trees.
  • Better validation method. The only way to cheat in this system is if somebody uses a fake identity for voting. This is why validation is very important. There are more methods to prevent malicious validators. For example, the system can randomly choose 2 or 3 validators for one voter. It has very little chance that all of them are malicious. Or it can record the validation process, and other validators randomly check the recorded videos. Cheating on voting is a crime, so it has a very high risk for the validator to be malicious.
  • Integration of video conferencing systems like Jitsi Meet. Sending the commitment, and the whole validation process can be very easy if the video conferencing system is integrated. Voters can fill out a form with their data and send it before the validation. The validator has to only check the ID card through the camera, and if everything is well, he can send the commitment and the unique hash to the blockchain with a button click.
  • Digital ID card support. If the voter has a digital ID card that can be used for digitally signing the commitment, then the whole validation process can be skipped. This method is super cheap because no human resource is needed, so it can be used frequently, and voters can be more involved in decision-making.

Democracy is the most important aspect of blockchain, and Web3 and blockchain-based anonymous voting are the holy grail of it.

I hope this short article and the zk-merkle-tree library can be a good starting point for others to make their own systems that will be used in the real-world one day.

UPDATE: You can read the introduction article about the zk-merkle-tree library here: