How to Build an Application with Noir: Decentralized Voting
At Three Sigma, we recognize the intricate opportunities within the Web3 space. Based in Lisbon, our team of experts offers premier services in development, security, and economic modeling to drive your project's success. Whether you need precise code audits, advanced economic modeling, or comprehensive blockchain engineering, Three Sigma is your trusted partner.
Explore our website to learn more.
We are not just experts in code, economic auditing, and blockchain engineering—we are passionate about breaking down Web3 concepts to drive clarity and adoption across the industry.
1. Introduction
In this guide, we’ll develop a decentralized voting system step by step, using Noir, a domain-specific language (DSL) designed for building privacy-preserving Zero-Knowledge (ZK) programs. Along the way, we will explain key components such as the Noir standard library and its oracle integration.
By following this technical walkthrough, you’ll learn how to develop zero-knowledge circuits building a decentralized voting system with Noir.
2. Noir Standard Library
Cryptographic Primitives
Noir's Cryptographic Primitives library offers essential tools for secure application development, including ciphers, hash functions, elliptic curve operations, digital signatures.
Additionally, it includes utilities such as BigInt, Black Box Functions, BN254, Containers, is_unconstrained, Merkle Trees, Options, Zeroed and more.
To learn more about the Noir Standard Library, check out the Noir documentation
3. Oracles in Noir
Introduction to Oracles
In Noir, oracles are essential components that allow a Zero-Knowledge (ZK) circuit to interact with external data sources securely. Oracles enable the incorporation of real-world data into ZK proofs without compromising the privacy and integrity of the proof system. This capability is crucial for many blockchain applications, where external data (like asset prices, weather information, or any off-chain data) needs to be included in on-chain operations.
How Oracles Work in Noir
An oracle in Noir is essentially a bridge between the external world and the ZK circuit. It fetches data from an external source, inputs this data into the circuit, and ensures that the data used in the proof is both valid and authentic. The use of oracles typically involves the following steps:
- Requesting Data: The oracle requests specific data from an external source.
- Data Validation: The data received is validated to ensure its integrity and correctness.
- Incorporating Data into the Proof: The validated data is then used within the ZK circuit to prove certain statements or conditions without revealing the data itself.
Oracles are particularly powerful because they allow smart contracts and ZK circuits to remain deterministic while still using external data that may change over time.
If you want to learn more about Oracles in Noir you can check the official documentation.
4. Example application
Introduction
In this guide, we’ll explore the development of a decentralized voting system that ensures privacy and security through advanced cryptographic techniques. This system leverages zero-knowledge circuits to process and verify votes, ensuring that each vote is both unique and anonymous, while also maintaining the integrity of the entire voting process.
We’ll begin by setting up the project environment, installing the necessary tools, and then proceed to develop a circuit that validates votes and computes necessary cryptographic proofs. Following this, we’ll create a Solidity smart contract to manage the voting process on the blockchain, including proposal creation, vote casting, and result tallying. This contract will interact seamlessly with the zero-knowledge proofs generated by our Noir circuit, ensuring that the system is both robust and secure.
Whether you're a seasoned blockchain developer or new to zero-knowledge proofs, this tutorial will equip you with the knowledge and tools needed to build robust, privacy-focused applications using Noir and Solidity. Let's dive in!
Installation
Installing Noirup
Run the following script in your terminal:
NOTE: The default backend for Noir (Barretenberg) doesn't provide Windows binaries at this time. For that reason, Noir cannot be installed natively. However, it is available by using Windows Subsystem for Linux (WSL). If you are a Windows user, follow this guide.
Creating the Project
Feel free to use whatever versions, just keep in mind that Nargo and the NoirJS packages are meant to be in sync. For example, Nargo 0.31.x matches[email protected]
, etc.
In this guide, we will be pinned to 0.31.0.
Create a new Nargo project by running:
NOTE: You can name the project as you want, but in production, it is common practice to name the project folder, circuits, for clarity amongst other folders in the codebase (like: contracts, scripts, and test).
Development
Writing the Circuit
We will develop a circuit that processes a vote commitment, validates the vote, and computes a nullifier, ensuring both the privacy and integrity of the vote. The circuit will also include a test to verify its correctness by simulating a valid scenario.
main()
function
The main()
function is the core of the our circuit. It processes a vote commitment, verifies the integrity of a vote, and computes a nullifier to ensure that each vote is unique and anonymous. This function leverages cryptographic techniques like Pedersen hashing and Merkle tree verification to maintain the security and privacy of the voting process.
Parameters
This function takes several parameters:
root: pub Field
: This is the public root of the Merkle tree. The Merkle tree is a data structure that allows for efficient and secure verification of the contents of a large dataset. In this context, it’s used to verify that the vote belongs to a valid tree of registered votes, ensuring the integrity of the entire voting process.index: Field
: The index in the Merkle tree where the note commitment (a commitment to the vote) is stored. It helps locate the specific vote within the tree, which is essential for verifying that the vote is part of the recognized and registered votes.hash_path: [Field; 2]
: The Merkle proof, which is an array of hashes needed to recompute the root of the Merkle tree from the note commitment. This proof is used to verify the path from the leaf (the vote commitment) to the root, ensuring that the vote has not been tampered with.secret: Field
: A private field representing the voter's secret. This secret is combined with the voter's choice to create a unique and secure commitment using the Pedersen hash. This prevents anyone from determining the actual vote while allowing the system to verify the integrity of the commitment.proposalId: pub Field
: A public field representing the ID of the proposal being voted on. It ensures that the vote is associated with the correct proposal, preventing any mix-up or misattribution of votes.vote_commitment: pub Field
: The commitment of the vote, which is a public hash that represents the voter's choice and secret. This commitment ensures that the vote remains hidden but can still be verified as authentic.vote_opening: Field
: The actual vote (e.g., 0 or 1) that the voter made. This is used to check against the commitment to ensure that the vote corresponds to what was committed to by the voter, without revealing what the vote was.salt: Field
: A random field used to generate the nullifier, which provides additional privacy by preventing the linking of votes to specific voters, even if they use the same secret and proposal ID across multiple votes.
Commitment Verification
The function starts by computing a new commitment (computed_vote_commitment
) using the vote_opening
and secret
fields. This commitment is then checked against the provided vote_commitment
to ensure that the vote has not been altered and that the opening (actual vote) corresponds to the commitment made by the voter.
Why we use Pedersen Hash: Pedersen hash is used here because it is collision-resistant and homomorphic. Collision resistance ensures that it’s nearly impossible for two different inputs to produce the same hash, which is crucial for the security of the vote. The homomorphic properties allow certain operations to be performed on the encrypted data (like summing commitments) without decrypting it, maintaining the privacy of the voter's choice.
Why we use assert
: The assert
statement is essential for enforcing that the computed commitment matches the provided commitment. This ensures that the vote has not been tampered with and that the commitment accurately reflects the voter's original choice.
Vote Range Checking
Next, the function ensures that the vote_opening
is within the valid range (e.g., 0 or 1 for binary choices). This step is crucial for preventing invalid votes (such as a vote of 2 in a yes/no scenario) from being counted.
Nullifier Calculation
The function then computes a nullifier using the root
, secret
, proposalId
, and salt
fields. The nullifier is a unique value that prevents linking a specific vote to a voter while still allowing the vote to be counted. This is crucial for maintaining voter privacy while ensuring that each vote is unique and cannot be duplicated.
Why we use Pedersen Hash: Again, Pedersen hash is used here due to its cryptographic properties, ensuring that the nullifier is unique and secure. The use of salt
adds randomness, which is crucial for preventing any linkage between different votes cast by the same voter.
Merkle Tree Integrity Check
The function generates a note_commitment
from the secret
and then uses this commitment along with the index
and hash_path
to recompute the Merkle root (check_root
). It asserts that this computed root matches the provided root
, verifying the integrity of the Merkle tree.
Why we compute the Merkle root: The Merkle root computation is essential because it ensures that the vote is included in the valid and authenticated set of votes. This helps maintain the integrity of the voting system by verifying that no unauthorized votes have been inserted into the voting process.
Finally, the function returns the computed nullifier
, which serves as a key component in preserving the voter's anonymity while ensuring that the vote is unique and counted.
If every step was followed correctly your main.nr
file should look like this:
Testing the Circuit
Build in/output files
Change directory into circuits
and build in/output files for your Noir program by running:
A Prover.toml
file will be generated in your project directory, to allow specifying input values to the program.
Execute the Noir program
Now that the project is set up, we can execute our Noir program.
Fill in input values for execution in the Prover.toml file:
Here is an explanation of each input parameter:
hash_path
: An array of hashes used to verify that a specific vote commitment is part of the Merkle tree by recomputing the Merkle root from the leaf (vote commitment) to the root.index
: Specifies the position of the vote commitment within the Merkle tree. In this case,0
indicates it's the first position.proposalId
: The unique identifier for the proposal being voted on. Here,0
signifies the first or primary proposal.root
: The Merkle root representing the combined commitment of all votes. This is crucial for verifying the integrity of the voting process.salt
: A random value used to create thenullifier
, ensuring the vote is anonymous and unlinkable, even if the same secret is reused.secret
: A private value known only to the voter, used with thevote_opening
to generate a securevote_commitment
, ensuring uniqueness and security.vote_commitment
: A cryptographic hash representing the voter's choice and secret, used to verify that the vote is authentic without revealing the actual choice.vote_opening
: The actual vote cast (e.g.,0
for "No" or1
for "Yes"), used with thesecret
to generate thevote_commitment
and verify the vote's consistency.
Now let’s execute our Noir program:
Proving Backend
Each proving backend offers its own tools for working with Noir programs, such as generating and verifying proofs, and creating verifier smart contracts.
You can find a full list of compatible proving backends in Awesome Noir.
For our purposes, we’ll install bb
, a CLI tool from the Barretenberg proving backend by Aztec Labs.
Barretenberg is a library for creating and verifying proofs. You can define the circuits used for proofs either with the Barretenberg standard library or by using an IR called ACIR.
This tool will generate proofs using ACIR and witness values as input.
Installation
Run the following script in your terminal:
Install the version of bb compatible with your Noir version; as we use Noir v0.31.0 run:
NOTE: Currently the binary downloads an SRS that can be used to prove the maximum circuit size. This maximum circuit size parameter is a constant in the code and has been set to 2^{23}as of writing. This maximum circuit size differs from the maximum circuit size that one can prove in the browser, due to WASM limits.
Prove an execution of the Noir program
Using Barretenberg we will, prove the valid execution of our Noir program running:
The proof generated will then be written to ./target/proof.
Verify the execution proof
Once a proof is generated, we can verify correct execution of our Noir program by verifying the proof file.
Compute the verification key for the Noir program by running:
And verify your proof by running:
NOTE: If successful, the verification will complete in silence; if unsuccessful, the command will trigger logging of the corresponding error.
Smart Contract Development
Writing the Voting Contract
We will develop the Solidity smart contract that manages a decentralized voting system. The Voting
contract is the core of our system. It handles proposal creation, vote casting, and vote counting while ensuring the security and integrity of the voting process. The contract leverages cryptographic techniques and a Merkle tree structure to maintain voter privacy and prevent fraud.
Key Components
Constructor
The constructor initializes the contract with a Merkle root and a verifier contract:
merkleRoot
: Represents the root of the Merkle tree, which ensures that only authorized votes are included.verifier
: An instance of theUltraVerifier
contract that verifies zero-knowledge proofs associated with each vote.
propose()
Function
This function allows users to create a new proposal with a description and a voting deadline:
description
: A brief description of the proposal.deadline
: The time until which voting on the proposal is allowed.
castVote()
Function
The castVote()
function is the most critical part of the contract, handling the process of casting and verifying votes:
- Nullifier Check: Ensures that the vote hasn't been cast already using the same
nullifierHash
, preventing double voting. - Merkle Proof Verification: A cryptographic proof that verifies the validity of the vote based on the Merkle root and other parameters.
- Vote Counting: Depending on the vote value (
1
for "for",0
for "against"), the contract updates the vote count for the proposal.
Fix for Second Preimage Attack in the castVote()
Function
To prevent second preimage attacks, the contract uses a specific leaf encoding method:
Leaf Hash Encoding: By encoding the Merkle root, proposal ID, vote, and nullifier hash together, this method ensures that each leaf in the Merkle tree is unique and secure. This mitigates the risk of an attacker finding two different inputs that hash to the same value, thus preserving the integrity of the voting process.
Here is the full contract:
If you want to add the full system, you can include some Foundry tests. To do this, you should install Foundry by following this guide.
Here is the Foundry test:
Now it’s your turn to expand the circuit, add more tests, and continue learning!
For reference you can check the GitHub repo with the full code.
5. Conclusion
In conclusion, Noir offers a streamlined and accessible approach to zero-knowledge proof development, enabling developers to build privacy-preserving applications without needing deep cryptographic knowledge. Through the creation of a decentralized voting system, we showcased how Noir simplifies the integration of complex cryptographic techniques, making it easier to implement secure and anonymous on-chain solutions.
This concludes Part II of our Noir Language Deep-dives!
Reach out to Three Sigma, and let our team of seasoned professionals guide you confidently through the Web3 landscape. Our expertise in smart contract security, economic modeling, and blockchain engineering, we will help you secure your project's future.
Contact us today and turn your Web3 vision into reality!
References: Noir Docs, Awesome Noir Repo