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
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
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();
input.json
{
"privateKey": "111"
}
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
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
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();
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"
}
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
Así se mira nuestro árbol completo:
└─ 172702405816516791996779728912308790882282610188111072512380034048458433129
├─ 8238706810845716733547504554580992539732197518335350130391048624023669338026
│ ├─ 13377623690824916797327209540443066247715962236839283896963055328700043345550
│ └─ 3370092681714607727019534888747304108045661953819543369463810453568040251648
└─ 11117482755699627218224304590393929490559713427701237904426421590969988571596
├─ 19430878135540641438890585969007029177622584902384053006985767702837167003933
└─ 2288143249026782941992289125994734798520452235369536663078162770881373549221
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
yvote
: El voto elegido por el usuario. -
pathElements
ypathIndicies
: La información mínima necesaria para reconstruir la raíz, esto incluye lospathElements
, o sea los nodos hoja o rama, y lospathIndices
, 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);
input.json
{
"privateKey": "111",
"root": "172702405816516791996779728912308790882282610188111072512380034048458433129",
"proposalId": "0",
"vote": "1",
"pathElements": ["3370092681714607727019534888747304108045661953819543369463810453568040251648", "11117482755699627218224304590393929490559713427701237904426421590969988571596"],
"pathIndices": ["0","0"]
}
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
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
A continuación generamos el contrato verificador en solidity.
snarkjs zkey export solidityverifier proveVote_0001.zkey verifier.sol
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;
}
}
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
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
.
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)