ZK te permite operar datos privados en Smart Contracts y en este tutorial vamos a realizar una aplicación ZK completa de principio a fin.

Aprenderemos de una manera práctica cómo interactúan las 3 partes principales de la arquitectura de una aplicación ZK: el circuito, el contrato y la webapp.

Vamos a recrear el juego del Ahorcado versión ZK. Por medio de este ejemplo práctico aprenderás sobre los errores más comunes y cómo solucionarlos.

Si lo prefieres, mirá el código completo en Github.

Ahorcado sin ZK, una versión ingenua

Antes de entrar de lleno en ZK observemos la siguiente implementación del juego del ahorcado en Solidity sin usar ZK.

La función hashFunction se encarga de calcular el commitment de la palabra que el usuario desea adivinar.

La función playWord se encarga de verificar si la palabra adivinada es correcta.

Aunque funciona, tiene un par de problemas fundamentales. ¿Puedes detectarlos?

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

// Contrato demostración de equemas de commit-reveal
contract SimpleHangman {
    // El commitment de la palabra por adivinar, este se calcula con keccak256(word)
    // Aunque esto esconde una palabra, no provee ninguna garantía que es una palabra válida
    bytes32 public wordHash;
    // El address del ganador, quien adivinó la palabra
    address public winner;

    // Al iniciar el juego, almacenamos un commitment a la palabra con la que jugaremos
    constructor(bytes32 _wordHash) {
        wordHash = _wordHash;
    }

    // Intenta adivinar la palabra oculta
    // Esto es sujeto a un ataque de frontrunn, el atacante puede ver la palabra en la mempool y pagar mas gas para ganar
    function playWord(string memory word) public {
        require(winner == address(0), "Game already played");
        require(hashFunction(word) == wordHash, "Invalid word");
        winner = msg.sender;
    }

    // Nos ayuda hashear la palabra, puedes usarla para calcular el commitment antes de lanzar el contrato
    function hashFunction(string memory value) public pure returns(bytes32)
    {
        return keccak256(abi.encodePacked(value));
    }
}

Estos son los dos problemas fundamentales de este contrato:

  1. Es suceptible a un ataque de frontrunn: El atacante puede ver la palabra en la mempool y pagar mas gas para ganar.
  2. No existe una forma de verificar que la palabra ingresada es válida, es decir, que contenga solo letras y no números o símbolos.

Veamos ahora como solucionarlos con ZK.

Ahorcado versión ZK

Veamos como implementar un circuito en el lenguaje Noir que resuelva los problemas anteriores.

Si aún no lo tienes, instala noir con el comando a continación.

curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash

Y actualiza a la versión mas reciente.

noirup

Inicia creando un nuevo proyecto de Noir.

nargo new zk_hangman
cd zk_hangman

Crea un circuito que nos servirá para demostrar que cierta palabra secreta solo contenga caracteres válidos. Además, adjunta el ganador cómo parámetro público para no estar sujeto a frontrunn.

src/main.nr

use dep::std;

// Circuito de ahorcado que evita frontrunn y verifica que la palabra sea valida
fn main(
    word: [Field; 10], // Palabra a adivinar, un maximo de 10 caracteres
    word_length: pub Field, // Longitud de la palabra, cantidad de caracteres
    winner: pub Field // Wallet del ganador, debe estar integrado en los parametros de la transaccion para evitar frontrunn
) -> pub Field {

    // Convierte la palabra a bytes para ser compatible con la implementacion de la libreria de keccak256 que usaremos
    // Ademas, verificamos que la palabra no contenga caracteres no alfabeticos
    let mut word_bytes = [0; 10];
    for i in 0..10 {
        if i < word_length as u8 {
            let current_char = word[i] as u8;
            let is_uppercase = (current_char >= 65) & (current_char <= 90);
            let is_lowercase = (current_char >= 97) & (current_char <= 122);
            assert(is_uppercase | is_lowercase);
        }
        word_bytes[i] = word[i] as u8;
    }

    // Obtenemos el hash de la palabra
    let hash_bytes = std::hash::keccak256(word_bytes, 10);

    // Convertimos el hash a un numero de 256 bits para ahorrar el tamano de la prueba
    let mut computed_hash = 0 as Field;
    for i in 0..30 {
        computed_hash = computed_hash * 256 + (hash_bytes[i] as Field);
    }

    println(computed_hash);
    println(hash_bytes);
    // Devolvemos el hash de la palabra, recuerda que los valores de retorno son parametros publicos en el contrato
    computed_hash
}

Ahora compila el circuito y genera los artefactos que serán usados desde la webapp.

nargo compile
bb write_vk -b ./target/zk_hangman.json -o ./target --oracle_hash keccak

Además genera el contrato verificador en Solidity.

bb write_solidity_verifier -k ./target/vk -o Verifier.sol

Lanza el Contrato de Ahorcado con ZK

Primero, lanza el contrato verificador Verifier.sol ubicado en el directorio circuits/. Una vez lanzado, lanza el siguiente contrato pasando la dirección del verificador como parámetro en el constructor.

SimpleHangman.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

// Interfaz de contrato verificador ZK
interface IVerifier {
    function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool);
}

// Contrato demostración de equemas de commit-reveal
contract SimpleHangman {
    // El commitment de la palablra por adivinar, este se calcula con keccak256(word)
    bytes32 public wordHash;
    // La cantidad de letras en la palabra secreta
    uint public wordLength;
    // El address de quien adivinó la palablra
    address public winner;
    // Contrato verificador de las pruebas ZK
    IVerifier verifier;

    constructor(address verifierAddress) {
        verifier = IVerifier(verifierAddress);
    }

    // Al iniciar el juego, almacenamos un commitment a la palabra con la que jugaremos
    function init(bytes calldata _proof, bytes32[] calldata _publicInputs) public {
        require(verifier.verify( _proof, _publicInputs), "Invalid proof");
        wordLength = uint(_publicInputs[0]);
        wordHash = _publicInputs[2];
    }

    // Intenta adivinar la palabra oculta
    function playWord(bytes calldata _proof, bytes32[] calldata _publicInputs) public {
        require(wordLength > 0, "Game hasn't been initialized");
        require(verifier.verify( _proof, _publicInputs), "Invalid proof");
        require(winner == address(0), "Game already played");
        bytes32 _wordHash = _publicInputs[2];
        require(_wordHash == wordHash, "Invalid word");
        winner = address(uint160(uint256(_publicInputs[1])));
    }

    // Nos ayuda hashear la palabra, puedes usarla para calcular el commitment antes de lanzar el contrato
    function hashFunction(string memory value) public pure returns(bytes32)
    {
        return keccak256(abi.encodePacked(value));
    }
}

Crea el Frontend

Instala las dependencias y la base mínima de tu proyecto.

curl -fsSL https://bun.sh/install | bash
bun i @noir-lang/[email protected] @noir-lang/[email protected] @aztec/[email protected]

El frontend consta de los siguientes 5 archivos que explicaremos a continuación:

  • vite.config.js
  • index.html
  • index.js
  • zk_stuff.js
  • web3_stuff.js

Crea el archivo vite.config.js para poder levantar el proyecto usando el servidor vite.

vite.config.js

export default { optimizeDeps: { esbuildOptions: { target: "esnext" } } };

El archivo HTML define la interfaz de usuario y conecta al script principal de la aplicación.

index.html

</span>

  
    .outer {
        display: flex;
        justify-content: space-between;
        width: 100%;
    }
    .inner {
        width: 45%;
        border: 1px solid black;
        padding: 10px;
        word-wrap: break-word;
    }
    #connected_section, #forms, #forms {
      display: none;
    }
  
  
  <span class="na">type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/web3/1.3.5/web3.min.js">


  
  <span class="na">type="module" src="/index.js">
  Noir app

  
   id="web3_message">
   id="connect_button" onclick="connectWallet()" style="display: none;">Connect Wallet

   id="connected_section">
     id="wallet_address">

    
     id="forms">
      Admin: Set Word
       id="admin_word" type="text" placeholder="Enter word (max 10 chars)" maxlength="10" />
       id="admin_submit">Submit as Admin
      Player: Guess Word
       id="player_word" type="text" placeholder="Enter word (max 10 chars)" maxlength="10" />
       id="winner-address" type="text" pattern="^0x[a-fA-F0-9]{40}$" value="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" />
       id="player_submit">Submit as Player
    
  

  
   class="outer">
     id="logs" class="inner">Logs
     id="results" class="inner">Proof

Este es archivo principal de la aplicación, este conecta a la lógica relacionada con ambos web3 yZK.

index.js

import { loadDapp, submitAdminProof, submitPlayerProof } from './web3_stuff.js';
import { generateProof, show } from './zk_stuff.js';

// Inicializa todo lo relacionado con web3
loadDapp();

// En esta aplicación, existen dos funciones que se ejecutan cuando el admin o el jugador presionan el botón de submit, ambas producen una prueba ZK que luego es enviada al contrato de Solidity

// Evento para cuando el admin crea la palabra, nota que el ganador es 0x0000000000000000000000000000000000000000 pues no será usado en el contrato de Solidity
document.getElementById("admin_submit").addEventListener("click", async () => {
  const word = document.getElementById("admin_word").value;
  const { proofBytes, publicInputs, rawProof } = await generateProof(
    word, 
    "0x0000000000000000000000000000000000000000"
  );

  await submitAdminProof(proofBytes, publicInputs);
  show("results", rawProof);
});

// Evento para cuando el jugador adivina la palabra, nota que el ganador puede ser definido por el jugador
document.getElementById("player_submit").addEventListener("click", async () => {
  const word = document.getElementById("player_word").value;
  const winnerAddress = document.getElementById("winner-address").value;
  const { proofBytes, publicInputs, rawProof } = await generateProof(
    word,
    winnerAddress
  );

  await submitPlayerProof(proofBytes, publicInputs);
  show("results", rawProof);
});

zk_stuff.js se encarga de generar las pruebas ZK para ambos el admin y el jugador.

zk_stuff.js

// Librerías de Aztec para generar pruebas ZK con Noir
import { UltraHonkBackend } from '@aztec/bb.js';
import { Noir } from '@noir-lang/noir_js';
import circuit from './circuit/target/zk_hangman.json';

// Librerías extra para compatibilidad con zkWASM
import initNoirC from "@noir-lang/noirc_abi";
import initACVM from "@noir-lang/acvm_js";
import acvm from "@noir-lang/acvm_js/web/acvm_js_bg.wasm?url";
import noirc from "@noir-lang/noirc_abi/web/noirc_abi_wasm_bg.wasm?url";
await Promise.all([initACVM(fetch(acvm)), initNoirC(fetch(noirc))]);

// Función para mostrar logs y resultados en HTML
export const show = (id, content) => {
  const container = document.getElementById(id);
  container.appendChild(document.createTextNode(content));
  container.appendChild(document.createElement("br"));
};

// Función para generar pruebas ZK para ambos el admin y el jugador
export async function generateProof(word, winnerAddress) {
  // Inicializa Noir con el circuito precompilado en la carpeta circuit
  const noir = new Noir(circuit);
  const backend = new UltraHonkBackend(circuit.bytecode);

  // Convierte la palabra a un array de caracteres y la rellena con 0s hasta que tenga 10 caracteres
  const wordArray = Array.from(word)
    .map(char => char.charCodeAt(0).toString())
    .concat(Array(10 - word.length).fill("0"));

  // Generamos la prueba ZK
  show("logs", "Generating witness... ⏳");
  const { witness } = await noir.execute({ 
    word: wordArray,
    word_length: word.length,
    winner: winnerAddress
  });
  show("logs", "Generated witness... ✅");

  show("logs", "Generating proof... ⏳");
  const proof = await backend.generateProof(witness, { keccak: true });
  show("logs", "Generated proof... ✅");

  // Opcional: Verificamos la prueba ZK antes de enviarla al contrato de Solidity
  show('logs', 'Verifying proof... ⌛');
  const isValid = await backend.verifyProof(proof, { keccak: true });
  show("logs", `Proof is ${isValid ? "valid" : "invalid"}... ✅`);

  const proofBytes = '0x' + Array.from(Object.values(proof.proof))
    .map(n => n.toString(16).padStart(2, '0'))
    .join('');

  // Devolvemos la prueba ZK para ser enviada al contrato de Solidity
  return {
    proofBytes,
    publicInputs: proof.publicInputs,
    rawProof: proof.proof
  };
}

web3_stuff.js se encarga de inicializar web3 y de interactuar con el contrato de Solidity. Asegúrate de cambiar el address del contrato CONTRACT_ADDRESS con SimpleHangman que recién lanzamos.

web3_stuff.js

const NETWORK_ID = 17000 // Para este tutorial usaremos Holesky, para usar otra red solo cambia este valor
const CONTRACT_ADDRESS = "0xfb89Fb2a693e71B237cE2E6A4CC2EEbFb59034c9" // Dirección del contrato de demostración, cambia esta dirección por la del contrato de ahorcado ZK que recién creamos

// Définimos el ABI del contrato para ambas funciones de init y playWord
const CONTRACT_ABI = [
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "_proof",
        "type": "bytes"
      },
      {
        "internalType": "bytes32[]",
        "name": "_publicInputs",
        "type": "bytes32[]"
      }
    ],
    "name": "init",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "_proof",
        "type": "bytes"
      },
      {
        "internalType": "bytes32[]",
        "name": "_publicInputs",
        "type": "bytes32[]"
      }
    ],
    "name": "playWord",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
];

let web3;
let accounts;
let contract;
let isAdmin = false;

// Función para recargar la página cuando se cambia de cuenta o de red
function metamaskReloadCallback() {
  window.ethereum.on('accountsChanged', () => {
    window.location.reload();
  });
  window.ethereum.on('chainChanged', () => {
    window.location.reload();
  });
}

// Función para inicializar web3
const getWeb3 = async () => {
  if (!window.ethereum) {
    throw new Error("Please install MetaMask");
  }
  return new Web3(window.ethereum);
};

// Carga el contrato de Ahoracado
const getContract = async (web3) => {
  return new web3.eth.Contract(CONTRACT_ABI, CONTRACT_ADDRESS);
};

// Inicializa la aplicación, conecta a la wallet e inicializa el contrato
async function loadDapp() {
  try {
    metamaskReloadCallback();
    web3 = await getWeb3();

    const netId = await web3.eth.net.getId();
    if (netId !== NETWORK_ID) {
      document.getElementById("web3_message").textContent = "Please connect to Holesky network";
      return;
    }

    contract = await getContract(web3);

    accounts = await web3.eth.getAccounts();
    if (accounts.length > 0) {
      onWalletConnected();
    } else {
      document.getElementById("web3_message").textContent = "Please connect wallet";
      document.getElementById("connect_button").style.display = "block";
      document.getElementById("connected_section").style.display = "none";
    }
  } catch (error) {
    console.error("Error loading dapp:", error);
    document.getElementById("web3_message").textContent = error.message;
  }
}

// Función para conectar la wallet
async function connectWallet() {
  try {
    accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
    onWalletConnected();
  } catch (error) {
    console.error("Error connecting wallet:", error);
  }
}

// Callback para cuando se conecta la wallet
function onWalletConnected() {
  document.getElementById("connect_button").style.display = "none";
  document.getElementById("web3_message").textContent = "Connected!";
  document.getElementById("wallet_address").textContent = `Wallet: ${accounts[0]}`;
  document.getElementById("connected_section").style.display = "block";
  document.getElementById("forms").style.display = "block";
}

// Función llamada por el Admin para crear la palabra
async function submitAdminProof(proofBytes, publicInputs) {
  console.log(proofBytes);
  console.log(publicInputs);
  try {
    await contract.methods.init(proofBytes, publicInputs)
      .send({ from: accounts[0] })
      .on('transactionHash', (hash) => {
        document.getElementById("web3_message").textContent = "Transaction pending...";
      })
      .on('receipt', (receipt) => {
        document.getElementById("web3_message").textContent = "Success!";
      });
  } catch (error) {
    console.error("Error submitting admin proof:", error);
    document.getElementById("web3_message").textContent = "Transaction failed";
  }
}

// Función llamada por el jugador para adivinar la palabra
async function submitPlayerProof(proofBytes, publicInputs) {
  try {
    await contract.methods.playWord(proofBytes, publicInputs)
      .send({ from: accounts[0] })
      .on('transactionHash', (hash) => {
        document.getElementById("web3_message").textContent = "Transaction pending...";
      })
      .on('receipt', (receipt) => {
        document.getElementById("web3_message").textContent = "Success!";
      });
  } catch (error) {
    console.error("Error submitting player proof:", error);
    document.getElementById("web3_message").textContent = "Transaction failed";
  }
}

export { loadDapp, connectWallet, submitAdminProof, submitPlayerProof };

Ahora prueba tu zkDapp

Ejecuta lo siguiente en carpeta donde se encuentra tu archivo package.json.

bunx vite

Ahora deberías poder acceder a tu webapp. Desde tu wallet owner del contrato ingresa la palabra secreta para iniciar el juego. Desde cualquier otra intenta adivinar la palabra. Si la adivinaste, enviar la transacción on-chain sin riesgo a ser frontrunneado.

Playing th zk hangman

Una vez terminado el juego, puedes obervar el ganador on-chain.

ZK hangman result

Te dejo estos links para seguir aprendiendo sobre ZK:

¡Gracias por ver este artículo!

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