DEV Community

Ahmed Castro for Filosofía Código EN

Posted on

Private smart contrcts with Solidity & Circom

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

input.json

{
    "privateKey": "111"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is how the full tree looks like:

└─ 172702405816516791996779728912308790882282610188111072512380034048458433129
   ├─ 8238706810845716733547504554580992539732197518335350130391048624023669338026
   │  ├─ 13377623690824916797327209540443066247715962236839283896963055328700043345550
   │  └─ 3370092681714607727019534888747304108045661953819543369463810453568040251648
   └─ 11117482755699627218224304590393929490559713427701237904426421590969988571596
      ├─ 19430878135540641438890585969007029177622584902384053006985767702837167003933
      └─ 2288143249026782941992289125994734798520452235369536663078162770881373549221
Enter fullscreen mode Exit fullscreen mode

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 and vote: The vote chosen by the user.
  • pathElements and pathIndices: The minimal information needed to reconstruct the root. This includes pathElements, 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);
Enter fullscreen mode Exit fullscreen mode

input.json

{
    "privateKey": "111",
    "root": "172702405816516791996779728912308790882282610188111072512380034048458433129",
    "proposalId": "0",
    "vote": "1",
    "pathElements": ["3370092681714607727019534888747304108045661953819543369463810453568040251648", "11117482755699627218224304590393929490559713427701237904426421590969988571596"],
    "pathIndices": ["0","0"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, we generate the verifier contract in Solidity.

snarkjs zkey export solidityverifier proveVote_0001.zkey verifier.sol
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Votos anónimos con Circom y Solidity

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)