DEV Community

Ahmed Castro
Ahmed Castro

Posted on • Edited on

🌀Huracán: El proyecto educativo para ingenieros aprendiendo ZK

Aprender sobre ZK hoy no es una tarea fácil. Es una tecnología nueva, sin mucha documentación. Huracán nace de mi propia necesidad de aprender sobre ZK de una manera práctica, orientada a desarrolladores e ingenieros.

Huracán es un proyecto totalmente funcional, capaz de hacer transacciones privadas en Ethereum y blockchains EVM. Está basado en proyectos de privacidad actualmente en funcionamiento, pero con el código mínimo para facilitar el proceso de aprendizaje. Vamos a cubrir cómo esta tecnología puede adaptarse a nuevos casos de uso y futuras regulaciones. Además, al final del artículo comparto qué se necesita para llevar este proyecto de pruebas en testnet a uso real en producción.

Al terminar esta guía vas a poder ir a otros proyectos de la misma naturaleza entender cómo están construidos.

¿Prefieres ver el código completo? Dirígete a Github para encontrar la totalidad del código en esta guía.

Cómo está construído Huracán:

  • Circuito hecho con Circom
  • Hasheo con Poseidon
  • Contratos de lógica de depósito y retiro en solidity
  • Construcción de merkle trees en js y solidity, verificación en Circom
  • Frontend con html y js vainilla
  • web3.js para interacción con web3 y snarkjs para probar en el navegador (zk-WASM)
  • Relayer con ethers.js 6 y express para preservar el anonimato de los usuarios

Tabla de contenido

  1. Cómo funciona Huracán
  2. El circuito
  3. Los contratos
  4. El frontend
  5. El relayer
  6. ¿Cómo llevar Huracán a producción?
  7. Ideas para profundizar

1. Cómo funciona Huracán

Huracán es una herramienta DeFi que protege la identidad de sus usuarios utilizando la técnica conocida como pruebas de inclusión anónima para realizar lo que comúnmente llamamos mixer. Este sistema es capaz de demostrar que un usuario ha depositado ether en un contrato sin demostrar cual de todos fué.

Depositando en Huracán

Cada usuario que deposita Ether en Huracán es colocado como una hoja en un merkle tree dentro del contrato

Para lograr esto, necesitamos un smart contract donde se depositarán los fondos y al hacerlo generará un merkle tree donde cada hoja representa un depositante. Adicionalmente, ocuparemos un circuito que generará las pruebas de inclusión que mantendrán al usuario anónimo al momento de retirar los fondos. Y también un relayer que ejecutará la transacción en nombre del usuario anónimo para proteger su privacidad.

Retiro de Huracán

Los usuarios pueden luego retirar sus fondos demostrando que parte del merkle tree sin revelar cuál hoja les pertenece

A continuación el código, la explicación breve y los materiales de apoyo necesarios para construir y lanzar tu propio proyecto con privacidad.

2. El circuito

Material de apoyo: Smart Contracts privados con Solidity y Circom

El circuito se encarga si eres parte del merkle tree, es decir, que eres uno de los depositantes sin revelar cuál eres pues mantienes los parámetros privados pero generas una prueba de inclusión que es capaz de ser verificada por un smart contract. ¿Cuáles parámetros privados? Durante el depósito, hasheamos una llave privada y un nulificador para crear así una nueva hoja en el árbol. La llave privada es un parámetro privado que nos servirá para luego demostrar que somos nosotros los dueños de esa hora. El nulificador es otro parámetros cuyo hash será pasado al contrato de solidity al momento de redimir los fondos, esto para prevenir que un usuario retire fondos 2 veces seguidas (double spend). El resto de parámetros privados son la ayuda que ocupa el circuito para reconstruir el árbol y revisar que somos parte de él.

Iniciamos instalando la librería circomlib que contiene los circuitos de poseidon que estaremos utilizando en este tutorial.

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

Ahora creamos nuestro circuito proveWithdrawal que prueba que hemos depositado en el contrato sin revelar quién somos.

proveWithdrawal.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 commitmentHasher() {
    signal input privateKey;
    signal input nullifier;
    signal output commitment;
    signal output nullifierHash;
    component commitmentHashComponent;
    commitmentHashComponent = Poseidon(2);
    commitmentHashComponent.inputs[0] <== privateKey;
    commitmentHashComponent.inputs[1] <== nullifier;
    commitment <== commitmentHashComponent.out;
    component nullifierHashComponent;
    nullifierHashComponent = Poseidon(1);
    nullifierHashComponent.inputs[0] <== nullifier;
    nullifierHash <== nullifierHashComponent.out;
}

template proveWithdrawal(levels) {
    signal input root;
    signal input recipient;
    signal input privateKey;
    signal input nullifier;
    signal input pathElements[levels];
    signal input pathIndices[levels];
    signal output nullifierHash;

    signal leaf;
    component commitmentHasherComponent;
    commitmentHasherComponent = commitmentHasher();
    commitmentHasherComponent.privateKey <== privateKey;
    commitmentHasherComponent.nullifier <== nullifier;
    leaf <== commitmentHasherComponent.commitment;
    nullifierHash <== commitmentHasherComponent.nullifierHash;

    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 main {public [root, recipient]} = proveWithdrawal(2);
Enter fullscreen mode Exit fullscreen mode

Para compilar el circuito necesitamos tener instalado ambos circom y snarkjs. Si no lo tienes instalado usa la guía de instalación de Circom.

Guía de instalación de Circom

Coloca estos comandos para instalar circom y snarkjs.

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

Ejecuta los comandos para generar la trusted setup y generar los archivos artefactos que estaremos usando más adelante en el frontend.

circom proveWithdrawal.circom --r1cs --wasm --sym
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 proveWithdrawal.r1cs pot12_final.ptau proveWithdrawal_0000.zkey
snarkjs zkey contribute proveWithdrawal_0000.zkey proveWithdrawal_0001.zkey --name="1st Contributor Name" -v
snarkjs zkey export verificationkey proveWithdrawal_0001.zkey verification_key.json
Enter fullscreen mode Exit fullscreen mode

Ahora podemos generar el contrato verificador en verfier.sol.

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

3. Los contratos

Material de apoyo: Solidity en 15 minutos

Los contratos son la garantía transparente que todo ha funcionado de manera correcta. Nos permite llevar un conteo de cuánto se ha depositado y también verifican que las pruebas sean válidas para así liberar los fondos. Es importante que todo lo que ocurre en los smart contracts es público, es la parte de nuestro sistema que no es anónima.

Haremos uso de tres contratos. El primero es el contrato verificador que recién generamos en el archivo verifier.sol, lánzalo ahora.

El segundo contrato es el de poseidon, si estás en Scroll Sepolia simplemente usa el que yo ya lancé en 0x52f28FEC91a076aCc395A8c730dCa6440B6D9519. Si quieres usar otra blockchain descolapsa y sigue los pasos:

Lanza el contrato de poseidon

La versión de poseidon que usamos en nuestro circuito y contrato deben ser exactamente compatibles. Por lo tanto usamos la versión en circomlibjs tal y como lo muestro, solo asegúrate de colocar tu llave privada y url RPC en TULLAVEPRIVADA y TUURLRPC.

git clone https://github.com/iden3/circomlibjs.git
cd circomlibjs
npm install
cd ..
node --input-type=module --eval "import { writeFileSync } from 'fs'; import('./circomlibjs/src/poseidon_gencontract.js').then(({ createCode }) => { const output = createCode(2); writeFileSync('poseidonBytecode', output); })"
cast send --rpc-url TUURLRPC --private-key TULLAVEPRIVADA --create $(cat bytecode)
Enter fullscreen mode Exit fullscreen mode

En Scroll, agregué --legacy --gas-price 5000000000, probablemente para cuando veas este video no ocuparás agregarlo. Igual, no ocupas esto en otras chains. En todo caso te dejo cómo quedaría el último comando completo:

cast send --rpc-url TUURLRPC --legacy --gas-price 5000000000 --private-key TULLAVEPRIVADA --create $(cat bytecode)
Enter fullscreen mode Exit fullscreen mode

Ahora lanza el contrato Huracan pasando como parámetro la dirección del contrato verificador y de poseidon.

// SPDX-License-Identifier: MIT

pragma solidity >=0.7.0 <0.9.0;

interface IPoseidon {
    function poseidon(uint[2] memory inputs) external returns(uint[1] memory output);
}

interface ICircomVerifier {
    function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[3] calldata _pubSignals) external view returns (bool);
}

contract Huracan {
    ICircomVerifier circomVerifier;
    uint nextIndex;
    uint public constant LEVELS = 2;
    uint public constant MAX_SIZE = 4;
    uint public NOTE_VALUE = 0.001 ether;
    uint[] public filledSubtrees = new uint[](LEVELS);
    uint[] public emptySubtrees = new uint[](LEVELS);
    address POSEIDON_ADDRESS;
    uint public root;

    mapping(uint => uint) public commitments;
    mapping(uint => bool) public nullifiers;

    event Deposit(uint index, uint commitment);

    constructor(address poseidonAddress, address circomVeriferAddress) {
        POSEIDON_ADDRESS = poseidonAddress;
        circomVerifier = ICircomVerifier(circomVeriferAddress);

        for (uint32 i = 1; i < LEVELS; i++) {
            emptySubtrees[i] = IPoseidon(POSEIDON_ADDRESS).poseidon([
                emptySubtrees[i-1],
                0
            ])[0];
        }
    }

    function deposit(uint commitment) public payable {
        require(msg.value == NOTE_VALUE, "Invalid value sent");
        require(nextIndex != MAX_SIZE, "Merkle tree is full. No more leaves can be added");
        uint currentIndex = nextIndex;
        uint currentLevelHash = commitment;
        uint left;
        uint right;

        for (uint32 i = 0; i < LEVELS; i++) {
            if (currentIndex % 2 == 0) {
                left = currentLevelHash;
                right = emptySubtrees[i];
                filledSubtrees[i] = currentLevelHash;
            } else {
                left = filledSubtrees[i];
                right = currentLevelHash;
            }
            currentLevelHash = IPoseidon(POSEIDON_ADDRESS).poseidon([left, right])[0];
            currentIndex /= 2;
        }

        root = currentLevelHash;
        emit Deposit(nextIndex, commitment);
        commitments[nextIndex] = commitment;
        nextIndex = nextIndex + 1;
    }

    function withdraw(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[3] calldata _pubSignals) public {
        circomVerifier.verifyProof(_pA, _pB, _pC, _pubSignals);
        uint nullifierHash = _pubSignals[0];
        uint rootPublicInput = _pubSignals[1];
        address recipient = address(uint160(_pubSignals[2]));

        require(root == rootPublicInput, "Invalid merke root");
        require(!nullifiers[nullifierHash], "Vote already casted");

        nullifiers[nullifierHash] = true;

        (bool sent, bytes memory data) = recipient.call{value: NOTE_VALUE}("");
        require(sent, "Failed to send Ether");
        data;
    }
}
Enter fullscreen mode Exit fullscreen mode

4. El frontend

Material de apoyo: Interfaces con privacidad en Solidity y zk-WASM

El frontend es la interfaz gráfica con la que estaremos interactuando. En esta demostración estaremos usando html y js vainilla para que los desarrolladores podamos adaptarla a cualquier frontend framework que estemos usando. Una característica muy importante del frontend es que debe ser capaz de producir las pruebas zk sin soltar información privada en el internet. Por eso es importante la tecnología zk-WASM que permite de una manera eficiente construir pruebas en nuestro navegador.

Ahora crea la siguiente estructura de archivos:

js/
  blockchain_stuff.js
  snarkjs.min.js
json_abi/
  Huracan.json
  Poseidon.json
zk_artifacts/
  proveWithdrawal_final.zkey
  proveWithdrawal.wasm
index.html
Enter fullscreen mode Exit fullscreen mode
  • js/snarkjs.min.js: descarga este archivo que contiene la librería de snark.js
  • json_abi/Huracan.json: el ABI del contrato CircomCustomLogic que recién lanzamos, por ejemplo en Remix, lo puedes hacer dando clic en el botón "ABI" en la pestaña de compilación.
  • json_abi/Poseidon.json: coloca esto
  • zk_artifacts: coloca en esta carpeta los artefactos generados anteriormente. Nota: Cambia el nombre de proveWithdrawal_0001.zkey por proveWithdrawal_final.zkey
  • index.html, js/blockchain_stuff.js y js/zk_stuff.js los detallo a continuación

El archivo de HTML contiene la interfaz necesaria para que los usuarios interactén con Huracán.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
</head>
<body>
  <input id="connect_button" type="button" value="Connect" onclick="connectWallet()" style="display: none"></input>
  <p id="account_address" style="display: none"></p>
  <p id="web3_message"></p>
  <p id="contract_state"></p>
  <input type="input"  value="" id="depositPrivateKey" placeholder="private key"></input>
  <input type="input"  value="" id="depositNullifier" placeholder="nullifier"></input>
  <input type="button" value="Deposit" onclick="_deposit()"></input>
  <br>
  <input type="input"  value="" id="withdrawPrivateKey" placeholder="private key"></input>
  <input type="input"  value="" id="withdrawNullifier" placeholder="nullifier"></input>
  <input type="input"  value="" id="withdrawRecipient" placeholder="recipient"></input>
  <input type="button" value="Withdraw" onclick="_withdraw()"></input>
  <br>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/web3/1.3.5/web3.min.js"></script>
  <script type="text/javascript" src="js/zk_stuff.js"></script>
  <script type="text/javascript" src="js/blockchain_stuff.js"></script>
  <script type="text/javascript" src="js/snarkjs.min.js"></script>
</body>
</html>

<script>
  function _deposit()
  {
    depositPrivateKey = document.getElementById("depositPrivateKey").value
    depositNullifier = document.getElementById("depositNullifier").value
    deposit(depositPrivateKey, depositNullifier)
  }

  function _withdraw()
  {
    withdrawPrivateKey = document.getElementById("withdrawPrivateKey").value
    withdrawNullifier = document.getElementById("withdrawNullifier").value
    withdrawRecipient = document.getElementById("withdrawRecipient").value
    withdraw(withdrawPrivateKey, withdrawNullifier, withdrawRecipient)
  }
</script>
Enter fullscreen mode Exit fullscreen mode

Ahora colocamos toda lo logica relacionada a web3, es decir la conexión a la wallet en el browser, lectura del estado y llamado de funciones.

js/blockchain_stuff.js

const NETWORK_ID = 534351

const HURACAN_ADDRESS = "0x8BD32BDC921f5239c0f5d9eaf093B49A67C3b9d0"
const HURACAN_ABI_PATH = "./json_abi/Huracan.json"

const POSEIDON_ADDRESS = "0x52f28FEC91a076aCc395A8c730dCa6440B6D9519"
const POSEIDON_ABI_PATH = "./json_abi/Poseidon.json"

const RELAYER_URL = "http://localhost:8080"

var huracanContract
var poseidonContract

var accounts
var web3
let leaves

function metamaskReloadCallback() {
  window.ethereum.on('accountsChanged', (accounts) => {
    document.getElementById("web3_message").textContent="Se cambió el account, refrescando...";
    window.location.reload()
  })
  window.ethereum.on('networkChanged', (accounts) => {
    document.getElementById("web3_message").textContent="Se el network, refrescando...";
    window.location.reload()
  })
}

const getWeb3 = async () => {
  return new Promise((resolve, reject) => {
    if(document.readyState=="complete")
    {
      if (window.ethereum) {
        const web3 = new Web3(window.ethereum)
        window.location.reload()
        resolve(web3)
      } else {
        reject("must install MetaMask")
        document.getElementById("web3_message").textContent="Error: Porfavor conéctate a Metamask";
      }
    }else
    {
      window.addEventListener("load", async () => {
        if (window.ethereum) {
          const web3 = new Web3(window.ethereum)
          resolve(web3)
        } else {
          reject("must install MetaMask")
          document.getElementById("web3_message").textContent="Error: Please install Metamask";
        }
      });
    }
  });
};

const getContract = async (web3, address, abi_path) => {
  const response = await fetch(abi_path);
  const data = await response.json();

  const netId = await web3.eth.net.getId();
  contract = new web3.eth.Contract(
    data,
    address
    );
  return contract
}

async function loadDapp() {
  metamaskReloadCallback()
  document.getElementById("web3_message").textContent="Please connect to Metamask"
  var awaitWeb3 = async function () {
    web3 = await getWeb3()
    web3.eth.net.getId((err, netId) => {
      if (netId == NETWORK_ID) {
        var awaitContract = async function () {
          huracanContract = await getContract(web3, HURACAN_ADDRESS, HURACAN_ABI_PATH)
          poseidonContract = await getContract(web3, POSEIDON_ADDRESS, POSEIDON_ABI_PATH)
          document.getElementById("web3_message").textContent="You are connected to Metamask"
          onContractInitCallback()
          web3.eth.getAccounts(function(err, _accounts){
            accounts = _accounts
            if (err != null)
            {
              console.error("An error occurred: "+err)
            } else if (accounts.length > 0)
            {
              onWalletConnectedCallback()
              document.getElementById("account_address").style.display = "block"
            } else
            {
              document.getElementById("connect_button").style.display = "block"
            }
          });
        };
        awaitContract();
      } else {
        document.getElementById("web3_message").textContent="Please connect to Goerli";
      }
    });
  };
  awaitWeb3();
}

async function connectWallet() {
  await window.ethereum.request({ method: "eth_requestAccounts" })
  accounts = await web3.eth.getAccounts()
  onWalletConnectedCallback()
}

loadDapp()

const onContractInitCallback = async () => {
  document.getElementById("web3_message").textContent="Reading merkle tree data...";
  leaves = []
  let i =0
  let maxSize = await huracanContract.methods.MAX_SIZE().call()
  for(let i=0; i<maxSize; i++)
  {
    leaves.push(await huracanContract.methods.commitments(i).call())
  }
  document.getElementById("web3_message").textContent="All ready!";
}

const onWalletConnectedCallback = async () => {
}


//// Functions ////

const deposit = async (depositPrivateKey, depositNullifier) => {
  let commitment = await poseidonContract.methods.poseidon([depositPrivateKey,depositNullifier]).call()

  let value = await huracanContract.methods.NOTE_VALUE().call()

  document.getElementById("web3_message").textContent="Please confirm transaction.";

  const result = await huracanContract.methods.deposit(commitment)
    .send({ from: accounts[0], gas: 0, value: value })
    .on('transactionHash', function(hash){
      document.getElementById("web3_message").textContent="Executing...";
    })
  .on('receipt', function(receipt){
    document.getElementById("web3_message").textContent="Success.";    })
  .catch((revertReason) => {
    console.log("ERROR! Transaction reverted: " + revertReason.receipt.transactionHash)
  });
}

const withdraw = async (privateKey, nullifier, recipient) => {
  document.getElementById("web3_message").textContent="Generating proof...";

  let commitment = await poseidonContract.methods.poseidon([privateKey,nullifier]).call()

  let index = null
  for(let i=0; i<leaves.length;i++)
  {
    if(commitment == leaves[i])
    {
      index = i
    }
  }

  if(index == null)
  {
    console.log("Commitment not found in merkle tree")
    return
  }

  let root = await huracanContract.methods.root().call()
  let proof = await getWithdrawalProof(index, privateKey, nullifier, recipient, root)

  await sendProofToRelayer(proof.pA, proof.pB, proof.pC, proof.publicSignals)
}

const sendProofToRelayer = async (pA, pB, pC, publicSignals) => {
  fetch(RELAYER_URL + "/relay?pA=" + pA + "&pB=" + pB + "&pC=" + pC + "&publicSignals=" + publicSignals)
  .then(res => res.json())
  .then(out =>
    console.log(out))
  .catch();
}
Enter fullscreen mode Exit fullscreen mode

Finalmente el archivo que contiene la logica relacionada a ZK. Este archivo se encarga de generar las pruebas ZK.

js/zk_stuff.js

async function getMerklePath(leaves) {
  if (leaves.length === 0) {
    throw new Error('Leaves array is empty');
  }

  let layers = [leaves];

  // Build the Merkle tree
  while (layers[layers.length - 1].length > 1) {
    const currentLayer = layers[layers.length - 1];
    const nextLayer = [];

    for (let i = 0; i < currentLayer.length; i += 2) {
      const left = currentLayer[i];
      const right = currentLayer[i + 1] ? currentLayer[i + 1] : left; // Handle odd number of nodes
      nextLayer.push(await poseidonContract.methods.poseidon([left,right]).call())
    }
    layers.push(nextLayer);
  }

  const root = layers[layers.length - 1][0];

  function getPath(leafIndex) {
    let pathElements = [];
    let pathIndices = [];
    let currentIndex = leafIndex;

    for (let i = 0; i < layers.length - 1; i++) {
      const currentLayer = layers[i];
      const isLeftNode = currentIndex % 2 === 0;
      const siblingIndex = isLeftNode ? currentIndex + 1 : currentIndex - 1;

      pathIndices.push(isLeftNode ? 0 : 1);
      pathElements.push(siblingIndex < currentLayer.length ? currentLayer[siblingIndex] : currentLayer[currentIndex]);

      currentIndex = Math.floor(currentIndex / 2);
    }

    return {
      PathElements: pathElements,
      PathIndices: pathIndices
    };
  }

  // You can get the path for any leaf index by calling getPath(leafIndex)
  return {
    getMerklePathForLeaf: getPath,
    root: root
  };
}

function addressToUint(address) {
  const hexString = address.replace(/^0x/, '');
  const uint = BigInt('0x' + hexString);
  return uint;
}

async function getWithdrawalProof(index, privateKey, nullifier, recipient, root) {
  let merklePath = await getMerklePath(leaves)
  let pathElements = merklePath.getMerklePathForLeaf(index).PathElements;
  let pathIndices = merklePath.getMerklePathForLeaf(index).PathIndices;
  let proverParams = {
    "privateKey": privateKey,
    "nullifier": nullifier,
    "recipient": addressToUint(recipient),
    "root": root,
    "pathElements": pathElements,
    "pathIndices": pathIndices
  }

  const { proof, publicSignals } = await snarkjs.groth16.fullProve( 
    proverParams, 
    "../zk_artifacts/proveWithdrawal.wasm", "../zk_artifacts/proveWithdrawal_final.zkey"
  );

  let pA = proof.pi_a
  pA.pop()
  let pB = proof.pi_b
  pB.pop()
  let pC = proof.pi_c
  pC.pop()

  document.getElementById("web3_message").textContent="Proof generated please confirm transaction.";

  return {
    pA: pA,
    pB: pB,
    pC: pC,
    publicSignals: publicSignals
  }
}
Enter fullscreen mode Exit fullscreen mode

5. El relayer

¿De qué sirve usar pruebas de anonimato zk si al final nosotros mismos la ejecutamos? Si hacemos esto perderíamos la privacidad porque en Ethereum todo es público. Por eso ocupamos a un relayer. Un intermediario que ejecuta la transacción on-chain a nombre de del usuario anónimo.

Iniciamos creando el archivo del backend.

relayer.mjs

import fs from "fs"
import cors from "cors"
import express from "express"
import { ethers } from 'ethers';

const app = express()
app.use(cors())

const JSON_CONTRACT_PATH = "./json_abi/Huracan.json"
const CHAIN_ID = "534351"
const PORT = 8080
var contract
var provider
var signer

const { RPC_URL, HURACAN_ADDRESS, RELAYER_PRIVATE_KEY, RELAYER_ADDRESS } = process.env;

const loadContract = async (data) => {
  data = JSON.parse(data);
  contract = new ethers.Contract(HURACAN_ADDRESS, data, signer);
}

async function initAPI() {
  provider = new ethers.JsonRpcProvider(RPC_URL);
  signer = new ethers.Wallet(RELAYER_PRIVATE_KEY, provider);

  fs.readFile(JSON_CONTRACT_PATH, 'utf8', function (err,data) {
    if (err) {
      return console.log(err);
    }
    loadContract(data)
  });

  app.listen(PORT, () => {
    console.log(`Listening to port ${PORT}`)
  })
}

async function relayMessage(pA, pB, pC, publicSignals)
{
  console.log(pA)
  console.log(pB)
  console.log(pC)
  console.log(publicSignals)
  const transaction = {
    from: RELAYER_ADDRESS,
    to: HURACAN_ADDRESS,
    value: '0',
    gasPrice: "700000000", // 0.7 gwei
    nonce: await provider.getTransactionCount(RELAYER_ADDRESS),
    chainId: CHAIN_ID,
    data: contract.interface.encodeFunctionData(
      "withdraw",[pA, pB, pC, publicSignals]
    )
  };
  const signedTransaction = await signer.populateTransaction(transaction);
  const transactionResponse = await signer.sendTransaction(signedTransaction);
  console.log('🎉 The hash of your transaction is:', transactionResponse.hash);
}

app.get('/relay', (req, res) => {
  console.log(req)
  var pA = req.query["pA"].split(',')
  var pBTemp = req.query["pB"].split(',')
  const pB = [
    [pBTemp[0], pBTemp[1]],
    [pBTemp[2], pBTemp[3]]
  ];
  var pC = req.query["pC"].split(',')
  var publicSignals = req.query["publicSignals"].split(',')

  relayMessage(pA, pB, pC, publicSignals)

  res.setHeader('Content-Type', 'application/json');
  res.send({
    "message": "the proof was relayed"
  })
})

initAPI()
Enter fullscreen mode Exit fullscreen mode

Instala la librería de coors para puedas correr el relayer de manera local.

npm install cors express ethers
Enter fullscreen mode Exit fullscreen mode

Ahora lanza el servidor reemplazando TUURLRPC, TUHURACANADDRESS, TULLAVEPRIVADA, TUADDRESS en el comandos a continuación.

RPC_URL=TUURLRPC HURACAN_ADDRESS=TUHURACANADDRESS RELAYER_PRIVATE_KEY=TULLAVEPRIVADA RELAYER_ADDRESS=TUADDRESS node relayer.mjs
Enter fullscreen mode Exit fullscreen mode

Ahora estás listo para depositar y retirar fondos en Huracán desde la interfaz web.

Huracán web

6. ¿Cómo llevar Huracán a producción?

a. Guarda el histórico de raíces on-chain

Al solo estar guardando la raíz más reciente, la prueba generada debe usar esta. Esto significa que si justo después de generar una prueba de retiro alguien hace un depósito, y por consiguiente modifica la raíz, la prueba generada será inválida y se tendrá que generar una nueva.

Cambios necesarios: Guardar todo el histórico de las raíces on chain en, por ejemplo un mapping mapping(uint id => uint root) public roots; y al momento de generar una prueba usar la más reciente. Si alguien hace un depósito y cambia la raíz no habrá problema pues la verificación se hará ente cualquier raíz que haya sido guardada históricamente usando una función, por ejemplo isKnownRoot(uint root).

b. Indexa el merkle tree en un lugar accesible

Para generar una prueba de inclusión, ocupamos leer el estado actual del árbol. Actualmente lo leemos de la variable commitments pero este proceso es lento y requiere muchas llamadas RPC si el tamaño del árbol es grande.

Cambios necesarios: Almacenar e indexar en un lugar accesible la totalidad del árbol. Pienso que el lugar ideal para esto es un subgraph.

c. Incentiva al relayer

Es necesario ofrecer una recompensa al relayer pues él es quien paga las comisiones de transacción on-chain.

Cambios necesarios: Al momento de generar la prueba, otorga un porcentaje de la note al relayer. Puedes hacer esto agregándo un parámetro extra en los circuitos, por ejemplo signal input fee; y en solidity enviar ese valor al msg.sender o a quien el relayer determine.

d. Usa librerías adecuadas

En la webapp, en vez de html y js vainilla deberías usar un frontend framework como react, angular o vue para ofrecer una mejor experiencia a los usuarios y desarrolladores.

En el relayer, en vez de express se debería de usar un backend más robusto y hostearlo en una máquina equipada para soportar un número de transacciones alto y con mecanismos anti-DoS, con un firewall adecuado ya que los fondos del relayer para usar como gas son un motivo para hackear el servidor.

e. Define el tamaño del merkle tree

Este ejemplo funciona para 4 depositantes, tendrás que reflejar los cambios en el circuito y contrato para hacerlo funcionar.

Cambios necesarios: Comienza cámbiando la cantidad de niveles en el circuito actualmente está establecida en 2 pues es la cantidad necesaria para un árbol con 4 hojas. Además actualiza las constantes LEVELS y MAX_SIZE en el contrato. Si tu árbol es muy grande, puedes ahorrar gas en el lanzamiento hardcodeando los valores default de un árbol vacío en vez de usar un ciclo como lo he mostrado.

f. Recuerda, muy importante, todo lo que hemos usado está en etapas experimentales

Los circuitos y contratos en esta guía no están debidamente auditados, al igual que las librerías que se usan. Como por ejemplo Poseidon, que es una nueva función de hasheo que es muy prometedora y que usamos en vez del tradicional Pedersen.

Y también recuerda, en este tutorial no hemos hecho una trusted setup segura. Para hacer esto es recomendado hacer una ceremonia abierta, con tiempo suficiente para abrir la participación.

7. Ideas para profundizar

a. Pruebas de exclusión

De la misma manera que en este ejemplo manejamos pruebas de inclusión, podemos hacer pruebas de exclusión que demuestren que que no somos parte de un grupo blacklisteado. Esto puede ayudar a ser compatibles con futuras regulaciones que determinen los estados.

b. Usar ERC20s en vez de Ether

En vez de usar la ether como moneda nativa en Solidity, podemos usar un ERC20 específico. Los cambios únicamente se harán en los contratos de solidity y webapp. Pues los circuitos pueden quedar exactamente igual.

c. Experimentar con Re-staking

Una vez integres ERC20s me parece que una buen paso para continuar es experimentar con la generación de ganancias pasivas usando LSTs.

d. ¡Piensa en otros casos de uso!

Las pruebas de inclusión anónimas tienen muchos casos de uso, inclusive fuera de DeFi. Piensa cómo puedes implementar lo que has aprendido en sistemas de votos y gobernanza, redes sociales, videojuegos, etc...

¡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 (2)

Collapse
 
lidiacana profile image
Lidia Canales

En el paso 5, falta agregar npm install express ethers

Collapse
 
turupawn profile image
Ahmed Castro

Gracias! Agregado