DEV Community

Ahmed Castro
Ahmed Castro

Posted on • Updated on

Smart contracts privados con Solidity y Circom

ZK nos permite hacer aplicaciones con datos y ejecución privada. Esto abre la puerta a muchos nuevos casos de uso, como el que crearemos en esta guía: un sistema de votación anónimo y seguro combinando Circom y Solidity.

Circom y dependencias

Si aún no tienes circom, instálalo con los comandos a continuación. Yo estoy usando node v20 pero debería funcionar con otras versiones.

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

También vamos a ocupar las librerías de circom donde se encuentra la función de poseidon que vamos a estar usando.

git clone https://github.com/iden3/circomlib.git
Enter fullscreen mode Exit fullscreen mode

1. Creación de llaves públicas

El método que usaremos para realizar votos anónimos y seguros es comprobando que somos parte de un grupo sin revelar nuestra identidad. Por ejemplo, voy a votar por el presidente de Honduras, demostrando que soy un hondureño pero sin revelar cuál hondureño soy. A esto le llamamos "prueba de inclusión en un set".

La manera más práctica de realizar esto en zk y blockchain es por medio de árboles merkle. Vamos a colocar a los votantes como hojas del árbol y vamos a demostrar que somos una de ellas sin revelar cuál.

El árbol es público así que usaremos un set de llaves pública-privadas para que cada votante pueda ejecutar su voto una sola vez.

Quizás te preguntarás si podemos usar las llaves públicas de nuestra wallet de ethereum (e.g. de metamask). En guías futuras como esta estaré tocando ese tema tal y como lo hice con noir. Para llegar a ese punto necesitarás los fundamentos de esta guía. Así que pendientes y suscríbanse!

Creemos ahora las llaves públicas para las siguientes llaves privadas a través del circuito privateKeyHasher.circom a continuación:

  • 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

Compilamos y computamos el circuito con los comandos a continuación, podrás ver el resultado en la 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

El resultado de las 4 llaves privadas debería ser el siguiente:

Llave privada Llave pública
111 13377623690824916797327209540443066247715962236839283896963055328700043345550
222 3370092681714607727019534888747304108045661953819543369463810453568040251648
333 19430878135540641438890585969007029177622584902384053006985767702837167003933
444 2288143249026782941992289125994734798520452235369536663078162770881373549221

¿Es necesario hacer esto a través de circom? La respuesta es no. Al hacerlo en circom estamos haciendo mucha computación innecesaria, por ahora lo haremos de esta manera para asegurar que la implementación del algoritmo de hasheo poseidon que usaremos más adelate sea compatible. Esto no es recomendado para proyectos en producción.

2. Creación del árbol

Ahora tenemos las cuatro hojas de nuestro árbol posicionadas de la siguiente manera

└─ ???
   ├─ ???
   │  ├─ 13377623690824916797327209540443066247715962236839283896963055328700043345550
   │  └─ 3370092681714607727019534888747304108045661953819543369463810453568040251648
   └─ ???
      ├─ 19430878135540641438890585969007029177622584902384053006985767702837167003933
      └─ 2288143249026782941992289125994734798520452235369536663078162770881373549221
Enter fullscreen mode Exit fullscreen mode

A continuación vamos a generar el árbol merkle rama por rama. Recordemos que los árbol merkle se generan hasheando cada una de sus hojas y ramas en pares hasta llegar a la raíz.

Para generar el árbol completo ejecutaremos la siguiente funcion que hashea dos hojas para generar su raíz. Lo haremos un total de 3 veces pues son las necesarias para obtener la raíz de un árbol con 4 hojas: raíz = 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

Estos son los inputs necesarios para generar la primera rama. De una manera similar puedes generar la otra rama y la raíz.

input.json

{
    "leftLeaf": "13377623690824916797327209540443066247715962236839283896963055328700043345550",
    "rightLeaf": "3370092681714607727019534888747304108045661953819543369463810453568040251648"
}
Enter fullscreen mode Exit fullscreen mode

Similar al paso anterior, con los siguientes comandos se compilará el circuito y se imprimirá el la raíz dada sus dos hojas.

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

Así se mira nuestro árbol completo:

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

3. Generar la prueba de un voto anónimo

Para generar un voto ocupamos pasar los siguientes parámetros al circuito:

  • privateKey: La llave privada del usuario.
  • root: La raíz del arbol nos asegura que estamos operando en el conjunto correcto. Adicionalmente, para más claridad, podríamos agregar el contrato y la chain en la que se ejecutará el voto. Esta variable será pública, accesible al smart contract.
  • proposalId y vote: El voto elegido por el usuario.
  • pathElements y pathIndicies: La información mínima necesaria para reconstruir la raíz, esto incluye los pathElements, o sea los nodos hoja o rama, y los pathIndices, que nos muestran cuál camino tomar para hashear donde 0 simboliza los nodos de la izquierda y 1 los de la derecha.

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

Probamos si todo funciona bien:

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

Si no hubo ningún problema, no se debería imprimir nada en la terminal.

4. Verificar un voto on-chain, desde Solidity

Con los siguientes comandos llevamos a cabo la ceremonia inicial también conocida como la 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

A continuación generamos el contrato verificador en solidity.

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

Al ejectuar este comando se generará un contrato verificador en el archivo verifier.sol. Lánza ahora ese contrato on-chain.

A continuación lanza el siguiente contrato on chain que contiene la lógica de la votación y verificación de pruebas. Pásale el address del contrato verificador que recién lanzamos como parámetro en el 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

Ahora crea la primera propuesta para votación llamando la función propose(). Por ejemplo puedes probar haciendo una votación con ¿Comemos pizza? como descripción y con 1811799232 como deadline para que venza en 2027.

Ahora generamos una prueba en el formato necesario para verificarla en Remix.

node proveVote_js/generate_witness.js proveVote_js/proveVote.wasm input.json witness.wtns
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

Pasemos el resultado de la terminal como parámetro en remix y veremos cómo el voto fue ejecutado al obtener la data de la proposal 0 a través del mapping proposals.

Votos anónimos con Circom y Solidity

Observamos que nuestro voto fué contado sin revelar quién fué el emisor. Intenta emitir el mismo voto de nuevo y verás que no será posible, la transacción revertirá. Esto porque ya nulificamos el voto para que cada votante solo pueda emitir un solo voto.

Fuentes y documentación oficial:

¡Gracias por leer esta guía!

Sígueme en dev.to y en Youtube para todo lo relacionado al desarrollo en Blockchain en Español.

Top comments (0)