How to build a ZK-Proof Voting Contract on The Scroll Network with Noir

Mayowa OgungbolaMayowa Ogungbola
14 min read

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

  1. Make use of basic Noir tools like nargo

  2. Understand key concepts of how ZK-proofing works in terms of generating Proofs and validation with ZKs,

  3. Explore concepts revolving around Integration of ZK-Proofing to a Layer2/Layer1 Blockchain

  4. Build and deploy a ZK-proof contract to the scroll sepolia network

  5. 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

  1. 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.

  2. 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

  1. 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.

  2. 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:

  1. The circuit/src/main.nr file is where you have your major the nargo 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.

  2. First, run the command yarn to initialize a basic node environment.

  3. Next, run the command curl -Lhttps://raw.githubusercontent.com/noir-lang/noirup/main/install| bash in your terminal.
    to install nargo.

  4. Reopen a new terminal and run the command noirup, then noirup -v 1.0.0, to install the nargo 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 right version ~1.0.0

  5. 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:

  1. main Function:

    • The main function takes four parameters: root, index, hash_path, and secret and proposalId.

    • 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 provided root. If it matches, the assertion passes; otherwise, it fails.

    • The Pedersen hash (nullifier) is computed using the root, secret, and proposalId. 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_treeFunction:

  • This function is a test function (indicated by the #[test] attribute).

  • The variable proposalId is introduced and set to 0.

  • 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 and commitment_1.

  • It constructs the right branch of the Merkle tree by hashing commitment_2 and commitment_3.

  • The variable, expected_nullifier, is created by hashing root, 1 (secret), and proposalId.

  • An assertion is then added to check if the computed nullifier matches the expected nullifier.

  • Finally, it computes the Merkle root by hashing the left and right branches.

  • The main function is then called with the calculated root, index 0, the right branch (commitment_1, right_branch), and a secret value of 1.

  1. Change your working directory to /circuit and run nargo test, to test the nargo code in main.nr , then run nargo prove, the command generated a new proof, you'll notice a hashed value in of the new proof inside the proofs/circuit.proof folder.

  2. Next run nargo verify, which uses the specified details in the verify.toml file to verify the proof.

  3. Now, run nargo codegen-verifier , and you'll notice the zk smart contract generated in the new folder contract/circuits/contract/plonk_vk.sol

    Note: The file updates every time you modify the main.nrrunnargo codegen-verifier.

  4. Next chain to your main working directory to install and run foundry by running the code curl -Lhttps://foundry.paradigm.xyz| bash, foundryup, and npx foundry init

  5. Head over to the src folder and rename the file Counter.sol to zkVoting.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.

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) and proposalCount (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 or false) with each unique bytes32 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 the verifier 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 and verifier 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 the proposalCount 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 the merkleRoot.

  • The verifier.verify function is used to verify the zero-knowledge proof provided proof 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) to true.

  • 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.

  1. Head over to the file test/Counter.sol and rename to Voting.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
             );
         }
     }
    
  2. Next, head over to your scripts/Counter.sol and rename to Voting.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();
         }
     }
    
  3. 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

  1. Head over to the link to get scroll sepolia faucets.

  2. Run forge create --rpc-url forge create --rpc-urlhttps://alpha-rpc.scroll.io/l2src/zkVoting.sol:Voting --private-key <enter your PRIVATE_KEY>, to install your private key and scroll's rpc URL in your forge environment.

  3. Finally, run the command forge script scripts/Voting.s.sol:DeploymentScript --rpc-urlhttps://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

1
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...