ZK y Noir: El Tutorial Completo

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) payable { 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: Es suceptible a un ataque de frontrunn: El atacante puede ver la palabra en la mempool y pagar mas gas para ganar. 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 transacción para evitar frontrunn ) -> pub Field { // Convierte la palabra a bytes para ser compatible con la implementación de la librería de keccak256 que usaremos // Además, verificamos que la palabra no contenga caracteres no alfabéticos let mut word_bytes = [0; 10]; for i in 0..10 { if i = 65) & (current_char = 97) & (current_char 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/noir_wasm@1.0.0-beta.3 @noir-lang/noir_js@1.0.0-beta.3 @aztec/bb.js@0.82.2 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: { targe

Apr 23, 2025 - 20:28
 0
ZK y Noir: El Tutorial Completo

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) payable {
        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 transacción para evitar frontrunn
) -> pub Field {

    // Convierte la palabra a bytes para ser compatible con la implementación de la librería de keccak256 que usaremos
    // Además, verificamos que la palabra no contenga caracteres no alfabéticos
    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 tamaño 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 parámetros públicos 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/noir_wasm@1.0.0-beta.3 @noir-lang/noir_js@1.0.0-beta.3 @aztec/bb.js@0.82.2

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



  
  
  


  
  
  

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