How to build a ZK-Proof Voting Contract on The Scroll Network with Noir
Introduction
Using traditional voting mechanisms has only proven to be susceptible to fraud, manipulation, lack of transparency, and centralized control.
The Zero-Knowledge (ZK) proof is a technology that introduces a secure and transparent voting system, a revolutionary solution, that ensures voter privacy and the integrity of the electoral system. It can be integrated into a blockchain network like the scroll network Zero-Knowledge Rollups.
Using a domain-specific language like Noir for creating a proving system. Building a voting system that can be integrated into an L2 blockchain is made possible. This implementation is in early development. It is designed to use any ACIR-compatible proving system.
On completing this tutorial you will
Make use of basic Noir tools like
nargo
Understand key concepts of how ZK-proofing works in terms of generating Proofs and validation with ZKs,
Explore concepts revolving around Integration of ZK-Proofing to a Layer2/Layer1 Blockchain
Build and deploy a ZK-proof contract to the scroll sepolia network
Create and deploy a Voting Contract utilizing your deployed ZK contract
Understand and implement Merkle tree concepts in the context of a ZK voting contract
Noir
Using Noir cutting-edge development tools etc. nargo
, the application of Zero-knowledge proofing within a solidity contract is made possible, particularly within the scroll network.
Zero-Knowledge Proofing (ZK Proofing) stands as a beacon of privacy and security in the world of blockchain technology, and its application within Solidity contracts is possible with the introduction of Noir. This tool revolutionizes how developers implement ZK Proofing, particularly within the realm of Solidity contracts on The Scroll Network.
Although ZK circuits built with the Noir Language are still quite in an early stage of development.
Key Features of Utilizing Noir
Noir brings a set of powerful features tailored for ZK Proofing:
Simplicity and Accessibility: Noir simplifies the integration of ZK Proofing techniques, making it accessible for developers regardless of their expertise level.
Compatibility with Solidity: Noir seamlessly integrates with Solidity, ensuring a smooth transition for developers familiar with the language.
Enhanced Privacy: By leveraging advanced cryptographic techniques, Noir enhances the privacy features of Solidity contracts.
The Scroll Network and ZK rollups
While ZK-Rollup is recognized as the best scaling solution for Ethereum. being as Ethereum Layer 1 and has the shortest finalizing time compared to all other Layer 2 solutions (Detailed comparisons here).
Scroll Tech is an innovation-driven L2 blockchain company. Building a scalable, EVM-compatible ZK-Rollup with a strong proving network. It aims to create a universal network that provides developers with the same user experience and security as Ethereum but with higher throughput, faster verification, and cheaper gas fees than Ethereum.
Workflow for ZK-EVM
The first stage of proving is done by the ZK circuit, on the Layer 2 chain.
In Layer 2, the bytecode is also stored in the storage and users will behave in the same way. Transactions will be sent off-chain to a centralized ZK-EVM node. Then, instead of just executing the bytecode, ZK-EVM will generate a succinct proof to prove the states are updated correctly after applying the transactions.
The second stage of proving is done in the Layer 1 chain by re-executing the smart contract.
In Layer 1, the bytecodes of the deployed smart contracts are stored in the Ethereum storage. Transactions will be broadcast in a P2P network, and Finally, the contract will verify the proof and update the states without re-executing the transactions.
To know more about how to scroll's view and intention on how they tend to implement ZK rollups into their L2 blockchain network, you can delve into this intuitive research here
The Basic Components of the ZK proofing are:
Proof Generation: The prover generates proof attesting to the validity of a statement without disclosing the actual information.
Verification: The verifier confirms the accuracy of the proof without gaining insight into the original data.
Soundness: Ensures that a false statement cannot be proven as true.
ZK Proofing in Smart Contracts
ZKProofs can secure smart contracts in several ways. First, they can protect the privacy of the data and the logic involved in the smart contract execution. For example, ZK-Proofs can verify that a smart contract has performed a certain calculation correctly, without revealing the inputs or outputs of the calculation.
Noir provides a set of functions and libraries that allow developers to implement ZK-Proofing directly into their Solidity contracts. This enables the creation of secure and private transactions, such as those found in voting systems.
Developers can easily incorporate Noir into their Solidity projects by following a straightforward setup process. Noir's documentation and community support make the onboarding process seamless.
The Benefit of ZK proofing in smart contracts
Enhancing Privacy and Security:
As a cryptographic technique, ZK Proofing allows a party to prove the authenticity of a statement without revealing any information about the statement itself. Solidity contracts, it relate to a heightened level of privacy and security, especially in sensitive processes like voting systems.
Reducing Attack Vectors:
By employing ZK Proofing, potential attack vectors are mitigated as the need for parties to disclose specific information is eliminated. It therefore significantly reduces the risk of data breaches and manipulation within smart contracts.
ZK-SNARKs with SnarksJS and Circom
Zero-Knowledge Succinct Non-Interactive Arguments of Knowledge (ZK-SNARKs), is also another technology that plays a role in achieving privacy and security within the blockchain, and in this context voting contracts. These succinct proofs allow for efficient verification of statements without revealing the underlying data, making them a fundamental building block for enhanced privacy. The ZK-SNARKs are simply an alternative to using Noir, only they are used mostly when considering writing smart contracts in Typescript.
Exploring snarkjs:
Snarkjs is a JavaScript library that facilitates the generation and verification of ZK-SNARK proofs. It empowers developers to implement Zero-Knowledge Proofs directly in their applications, including Solidity contracts.
Integration with Solidity:
integrating snarks into your Solidity contracts, enabling the creation of ZK-SNARKs proofs within the Ethereum ecosystem. Nut in this instance you'll be building a TypeScript contract. This integration is crucial for enhancing the privacy and security features of voting contracts built on platforms like The Scroll Network.
Understanding Circom:
Circom is a constraint-based DSL that allows programmers to design and create their arithmetic circuits for ZK purposes. The Circom compiler is mostly written in Rust and it is open source1. It is designed as a low-level circuit language, close to the design of electronic circuits.
Just like Noir and snarkjs, circom is also a domain-specific language designed explicitly for the specification and verification of Zk-SNARKs circuits. It allows you to define the logic of the statements being proven concisely and expressively.
Workflow with ZK-Snarks:
Circom is mostly used in conjunction with snarkjs in the development of ZK-SNARKs. Developers define the circuit using circom, which is then compiled and processed by snarkjs to generate the corresponding proofs. This streamlined workflow enhances the efficiency and accuracy of ZK Proofing implementation.
ZK SNARK Circom and Smart Contracts
Privacy and Security:
Snarkjs and Circom are core development tools used in enhancing the privacy and security features of smart contracts generally. By utilizing ZK-SNARKs, you can ensure that votes remain confidential while still being verifiable.
Compatibility with Solidity and the Scroll Network:
The compatibility of snarkjs and circom with Solidity allows for the seamless integration of ZK-SNARKs into voting contracts. This integration is especially relevant for projects deployed on The Scroll Network, where the combination of these tools contributes to a robust and privacy-centric voting system.
Solidity ZK Circuit using Noir
Solidity is a high-level programming language designed for writing smart contracts that run on blockchain platforms, with Ethereum being a prominent example. It incorporates elements from C++, Python, and JavaScript, making it accessible for developers familiar with these languages.
Building a Solidity ZK Proofing Voting Contract
Now that you have a good grasp of the concepts, it's time to get your hands dirty.
Head over to the git repository to get the starter file for your ZK-proof voting contract.
Note: You can either choose to run the application locally or on GitHub codespace, This tutorial walks you through using uses GitHub code space. To di that simply fork the repository and click on.
to initialize the codespace and you can follow through the next steps below.
Now you have a running workspace with your starter code:
The
circuit/src/main.nr
file is where you have your major thenargo
code that generates your zk-proofing smart contract for verifying your voting smart contract.Note: The
proofs
file updates its hash value every time you run the command to generate a new proof.First, run the command
yarn
to initialize a basic node environment.Next, run the command
curl -L
https://raw.githubusercontent.com/noir-lang/noirup/main/install
| bash
in your terminal.
to install nargo.Reopen a new terminal and run the command
noirup
, thennoirup -v 1.0.0
, to install thenargo version 1.0.0
, as it is the latest confirmed version confirmed to be compatible with the code in this tutorial.Note: Run the code
nargo --version
to confirm your running nargo on the rightversion ~1.0.0
Head over to the
circuit/src/main.nr
file and replace the code with the one below.
use dep::std;
fn main(root : pub Field, index : Field, hash_path : [Field; 2], secret: Field, proposalId: pub Field) -> pub Field {
let note_commitment = std::hash::pedersen([secret]);
let nullifier = std::hash::pedersen([root, secret, proposalId]);
let check_root = std::merkle::compute_merkle_root(note_commitment[0], index, hash_path);
assert(root == check_root);
nullifier[0]
}
#[test]
fn test_valid_build_merkle_tree() {
let commitment_0 = std::hash::pedersen([1])[0];
let commitment_1 = std::hash::pedersen([2])[0];
let commitment_2 = std::hash::pedersen([3])[0];
let commitment_3 = std::hash::pedersen([4])[0];
let left_branch = std::hash::pedersen([commitment_0, commitment_1])[0];
let right_branch = std::hash::pedersen([commitment_2, commitment_3])[0];
let root = std::hash::pedersen([left_branch, right_branch])[0];
let proposalId = 0;
let nullifier = main(
root,
0,
[commitment_1, right_branch],
1,
proposalId
);
let expected_nullifier = std::hash::pedersen([root, 1, proposalId]);
assert(nullifier == expected_nullifier[0]);
}
```
The Code above implements a Merkle tree verification system. Where the test function generates commitments, constructs a Merkle tree, and then calls the main verification function to check if the provided Merkle root matches the computed Merkle root based on the given parameters.
It validates that the Merkle tree construction and verification logic are functioning as expected.
Specifics:
main
Function:The
main
function takes four parameters:root
,index
,hash_path
, andsecret
andproposalId
.It calculates the commitment of a note using the Pedersen hash function:
note_commitment = std::hash::pedersen([secret])
.It checks if the computed Merkle root (
check_root
) based on the note commitment, index, and hash path matches the providedroot
. If it matches, the assertion passes; otherwise, it fails.The Pedersen hash (
nullifier
) is computed using theroot
,secret
, andproposalId
. The hash serves as a nullifier for the given proposal.The function now returns the first element of the
nullifier
array (nullifier[0]
).
test_valid_build_merkle_tree
Function:
This function is a test function (indicated by the
#[test]
attribute).The variable
proposalId
is introduced and set to0
.It creates four commitments (
commitment_0
,commitment_1
,commitment_2
,commitment_3
) using the Pedersen hash function.It constructs the left branch of the Merkle tree by hashing
commitment_0
andcommitment_1
.It constructs the right branch of the Merkle tree by hashing
commitment_2
andcommitment_3
.The variable,
expected_nullifier
, is created by hashingroot
,1
(secret), andproposalId
.An assertion is then added to check if the computed
nullifier
matches the expectednullifier
.Finally, it computes the Merkle root by hashing the left and right branches.
The
main
function is then called with the calculatedroot
, index0
, the right branch (commitment_1, right_branch
), and asecret
value of1
.
Change your working directory to
/circuit
and runnargo test
, to test the nargo code inmain.nr
, then runnargo prove
, the command generated a new proof, you'll notice a hashed value in of the new proof inside theproofs/circuit.proof
folder.Next run
nargo verify
, which uses the specified details in theverify.toml
file to verify the proof.Now, run
nargo codegen-verifier
, and you'll notice the zk smart contract generated in the new foldercontract/circuits/contract/plonk_vk.sol
Note: The file updates every time you modify the
main.nr
runnargo codegen-verifier
.Next chain to your main working directory to install and run foundry by running the code
curl -L
https://foundry.paradigm.xyz
| bash
,foundryup
, andnpx foundry init
Head over to the
src
folder and rename the fileCounter.sol
tozkVoting.sol
, then replace the code with the one below.// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; import { UltraVerifier } from "../circuits/contract/circuits/plonk_vk.sol"; contract Voting { bytes32 merkleRoot; uint256 proposalCount; mapping(uint256 proposalId => Proposal) public proposals; mapping(bytes32 hash => bool isNullified) nullifiers; UltraVerifier verifier; struct Proposal { string description; uint256 deadline; uint256 forVotes; uint256 againstVotes; } constructor(bytes32 _merkleRoot, address _verifier) { merkleRoot = _merkleRoot; verifier = UltraVerifier(_verifier); } function propose( string memory description, uint deadline ) public returns (uint) { proposals[proposalCount] = Proposal(description, deadline, 0, 0); proposalCount += 1; return proposalCount; } function castVote( bytes calldata proof, uint proposalId, uint vote, bytes32 nullifierHash ) public returns (bool) { require(!nullifiers[nullifierHash], "Proof has been already submitted"); require( block.timestamp < proposals[proposalId].deadline, "Voting period is over." ); nullifiers[nullifierHash] = true; bytes32[] memory publicInputs = new bytes32[](3); publicInputs[0] = merkleRoot; publicInputs[1] = bytes32(proposalId); publicInputs[2] = nullifierHash; require(verifier.verify(proof, publicInputs), "Invalid proof"); if (vote == 1) proposals[proposalId].forVotes += 1; else proposals[proposalId].againstVotes += 1; return true; } } ```
- The contract uses the ZK-proof system implemented as its privacy feature.
It uses a privacy-focused voting system where the Merkle tree's root is publicly known, but individual votes remain confidential through the use of zero-knowledge proofs. The contract ensures that votes are cast within the specified deadline and verifies the validity of the provided proofs before updating the vote counts.
- The contract uses the ZK-proof system implemented as its privacy feature.
Specifics:
Import Statement:
- The contract imports the
UltraVerifier
contract from the specified file path (../circuits/contract/circuits/plonk_vk.sol
).
Contract Structure:
The contract is named
Voting
.It contains state variables:
merkleRoot
(a bytes32 variable representing the root of a Merkle tree) andproposalCount
(to keep track of the number of proposals).
Mapping for Proposals and Nullifiers:
The contract uses a mapping to store information about each proposal, identified by a unique
proposalId
.It also maps a boolean value (
true
orfalse
) with each uniquebytes32
hash to keep track of nullifiers, which prove the spending or nullification of confidential data without revealing the data itself.
UltraVerifier Instance:
- An instance of the
UltraVerifier
contract is created and stored in theverifier
state variable during contract deployment.
Proposal Struct:
- The
Proposal
struct defines the details of a proposal, including its description, deadline, and vote counts (for and against).
Constructor:
- The constructor initializes the
merkleRoot
andverifier
variables with the provided values.
Propose Function:
The
propose
function allows users to create new proposals by providing a description and a deadline.A new
Proposal
struct is added to the mapping, and theproposalCount
is incremented. The function returns the unique identifier of the created proposal.
CastVote Function:
The
castVote
function allows users to cast their votes for or against a specific proposal using a zero-knowledge proof.It checks whether the voting period is still open (before the deadline).
It constructs an array
publicInputs
containing themerkleRoot
.The
verifier.verify
function is used to verify the zero-knowledge proof providedproof
against the public inputs. If the proof is valid, the vote is cast.The function updates the vote counts based on the user's choice (for or against) and returns
true
.
The Nullifier:
It sets the value associated with a specific nullifier hash (
nullifierHash
) totrue
.Indicating that the corresponding nullifier has been used or spent, providing a mechanism to prevent double-spending or reuse of confidential information while maintaining privacy.
Head over to the file
test/Counter.sol
and rename toVoting.t.sol
, then replace the code with the one below.// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.18; import "forge-std/Test.sol"; import "../src/zkVoting.sol"; contract VotingTest is Test { Voting public voteContract; UltraVerifier public verifier; bytes proofBytes; uint256 deadline = block.timestamp + 10000000; bytes32 merkleRoot; function readInputs() internal view returns (string memory) { string memory inputDir = string.concat( vm.projectRoot(), "/data/input" ); return vm.readFile(string.concat(inputDir, ".json")); } function setUp() public { string memory inputs = readInputs(); merkleRoot = bytes32(vm.parseJson(inputs, ".merkleRoot")); verifier = new UltraVerifier(); voteContract = new Voting(merkleRoot, address(verifier)); voteContract.propose("First proposal", deadline); string memory proofFilePath = "./circuits/proofs/circuits.proof"; string memory proof = vm.readLine(proofFilePath); proofBytes = vm.parseBytes(proof); } function test_validVote() public { voteContract.castVote( proofBytes, 0, 1 ); } function testFail_invalidProof() public { voteContract.castVote( hex"12", 0, 1 ); } function testFail_doubleVoting() public { voteContract.castVote( proofBytes, 0, 1 ); voteContract.castVote( proofBytes, 0, 1 ); } }
Next, head over to your
scripts/Counter.sol
and rename toVoting.s.sol
and replace the code with the one below// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.18; import "forge-std/Script.sol"; import "../contracts/Voting.sol"; contract DeploymentScript is Script { function readInputs() internal view returns (string memory) { string memory inputDir = string.concat( vm.projectRoot(), "/data/input" ); return vm.readFile(string.concat(inputDir, ".json")); } function run() external { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); UltraVerifier verifier = new UltraVerifier(); string memory inputs = readInputs(); bytes memory merkleRoot = vm.parseJson(inputs, ".merkleRoot"); Voting voting = new Voting(bytes32(merkleRoot), address(verifier)); vm.stopBroadcast(); } }
Now, run
forge test
to run a solidity test on your contract.Note: you can modify the current state of the ZK proofing system and the smart contract by following a more in-depth walkthrough by the ZKCamp team on GitHub here.
Deploying on Scroll
To deploy your contract on the scroll sepolia test network
Head over to the link to get scroll sepolia faucets.
Run forge
create --rpc-url forge create --rpc-url
https://alpha-rpc.scroll.io/l2
src/zkVoting.sol:Voting --private-key <enter your PRIVATE_KEY>
, to install your private key and scroll's rpc URL in your forge environment.Finally, run the command
forge script scripts/Voting.s.sol:DeploymentScript --rpc-url
https://sepolia-rpc.scroll.io
--broadcast --verify -vvvv
to deploy to scroll.
Conclusion
Although the ZK technology is still quite at the early stage of its development.
This tutorial not only gets you started with understanding how the ZK system works and how it can be used as a tool in roll-ups for EVM-compatible blockchains, but you also get to build a proofing system for a voting contract and deploy it on a blockchain. With the Noir's ZK Smart Contract Functionality, there is an endless possibility of what you can build. And this tutorial is only a start to making that progress.
I'd love to connect with you on Twitter | LinkedIn | GitHub | Portfolio
Subscribe to my newsletter
Read articles from Mayowa Ogungbola directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Mayowa Ogungbola
Mayowa Ogungbola
Hi I'm Phenzic I simply wanna help you transition into web3 seamlessly...