Ir al contenido principal

Uso de conocimiento cero para un estado secreto

servidor
fuera de la cadena
centralizado
conocimiento-cero
zokrates
mud
Avanzado
Ori Pomerantz
15 de marzo de 2025
30 minuto leído

No hay secretos en la cadena de bloques. Todo lo que se publica en la cadena de bloques está abierto a la lectura para todo el mundo. Esto es necesario, porque la cadena de bloques se basa en que cualquiera pueda verificarla. Sin embargo, los juegos a menudo dependen de un estado secreto. Por ejemplo, el juego de buscaminasopens in a new tab no tiene absolutamente ningún sentido si se puede simplemente ir a un explorador de la cadena de bloques y ver el mapa.

La solución más sencilla es utilizar un componente de servidor para mantener el estado secreto. Sin embargo, la razón por la que usamos la cadena de bloques es para evitar trampas por parte del desarrollador del juego. Tenemos que garantizar la honestidad del componente del servidor. El servidor puede proporcionar un hash del estado y utilizar pruebas de conocimiento cero para demostrar que el estado utilizado para calcular el resultado de un movimiento es el correcto.

Después de leer este artículo, usted sabrá cómo crear este tipo de servidor que contiene el estado secreto, un cliente para mostrar el estado y un componente en cadena para la comunicación entre ambos. Las herramientas principales que usaremos serán:

HerramientaPropósitoVerificado en la versión
Zokratesopens in a new tabPruebas de conocimiento cero y su verificación1.1.9
Typescriptopens in a new tabLenguaje de programación tanto para el servidor como para el cliente5.4.2
Nodeopens in a new tabEjecución del servidor20.18.2
Viemopens in a new tabComunicación con la cadena de bloques2.9.20
MUDopens in a new tabGestión de datos en cadena2.0.12
Reactopens in a new tabInterfaz de usuario del cliente18.2.0
Viteopens in a new tabServir el código del cliente4.2.1

Ejemplo de buscaminas

Buscaminasopens in a new tab es un juego que incluye un mapa secreto con un campo minado. El jugador elige cavar en una ubicación específica. Si esa ubicación tiene una mina, se acaba el juego. De lo contrario, el jugador obtiene el número de minas en las ocho casillas que rodean esa ubicación.

Esta aplicación está escrita usando MUDopens in a new tab, un marco de trabajo que nos permite almacenar datos en cadena usando una base de datos de clave-valoropens in a new tab y sincronizar esos datos automáticamente con componentes fuera de cadena. Además de la sincronización, MUD facilita el control de acceso y que otros usuarios extiendanopens in a new tab nuestra aplicación sin necesidad de permisos.

Ejecutar el ejemplo de buscaminas

Para ejecutar el ejemplo de buscaminas:

  1. Asegúrese de tener instalados los requisitos previosopens in a new tab: Nodeopens in a new tab, Foundryopens in a new tab, gitopens in a new tab, pnpmopens in a new tab y mprocsopens in a new tab.

  2. Clone el repositorio.

    1git clone https://github.com/qbzzt/20240901-secret-state.git
  3. Instale los paquetes.

    1cd 20240901-secret-state/
    2pnpm install
    3npm install -g mprocs

    Si Foundry se instaló como parte de pnpm install, necesita reiniciar el shell de la línea de comandos.

  4. Compile los contratos

    1cd packages/contracts
    2forge build
    3cd ../..
  5. Inicie el programa (incluida una cadena de bloques de anvilopens in a new tab) y espere.

    1mprocs

    Tenga en cuenta que el inicio tarda mucho tiempo. Para ver el progreso, primero use la flecha hacia abajo para desplazarse a la pestaña contracts para ver los contratos MUD que se están desplegando. Cuando reciba el mensaje Waiting for file changes… (Esperando cambios en el archivo), los contratos se desplegarán y el progreso posterior se producirá en la pestaña server. Allí, espere hasta que reciba el mensaje Verifier address: 0x.... (Dirección del verificador: 0x...).

    Si este paso se realiza correctamente, verá la pantalla de mprocs, con los diferentes procesos a la izquierda y la salida de la consola para el proceso actualmente seleccionado a la derecha.

    La pantalla de mprocs

    Si hay un problema con mprocs, puede ejecutar los cuatro procesos manualmente, cada uno en su propia ventana de línea de comandos:

    • Anvil

      1cd packages/contracts
      2anvil --base-fee 0 --block-time 2
    • Contratos

      1cd packages/contracts
      2pnpm mud dev-contracts --rpc http://127.0.0.1:8545
    • Servidor

      1cd packages/server
      2pnpm start
    • Cliente

      1cd packages/client
      2pnpm run dev
  6. Ahora puede navegar al clienteopens in a new tab, hacer clic en New Game (Nuevo juego) y comenzar a jugar.

Tablas

Necesitamos varias tablasopens in a new tab en cadena.

  • Configuration: esta tabla es un singleton, no tiene clave y tiene un único registro. Se utiliza para contener información de configuración del juego:

    • height: la altura de un campo de minas
    • width: el ancho de un campo de minas
    • numberOfBombs: el número de bombas en cada campo de minas
  • VerifierAddress: esta tabla también es un singleton. Se utiliza para contener una parte de la configuración, la dirección del contrato del verificador (verifier). Podríamos haber puesto esta información en la tabla Configuration, pero es establecida por un componente diferente, el servidor, por lo que es más fácil ponerla en una tabla separada.

  • PlayerGame: la clave es la dirección del jugador. Los datos son:

    • gameId: valor de 32 bytes que es el hash del mapa en el que el jugador está jugando (el identificador del juego).
    • win: un booleano que indica si el jugador ganó el juego.
    • lose: un booleano que indica si el jugador perdió el juego.
    • digNumber: el número de excavaciones exitosas en el juego.
  • GamePlayer: esta tabla contiene el mapeo inverso, desde gameId a la dirección del jugador.

  • Map: la clave es una tupla de tres valores:

    • gameId: valor de 32 bytes que es el hash del mapa en el que el jugador está jugando (el identificador del juego).
    • coordenada x
    • coordenada y

    El valor es un único número. Es 255 si se detectó una bomba. De lo contrario, es el número de bombas alrededor de esa ubicación más uno. No podemos usar solo el número de bombas, porque por defecto todo el almacenamiento en la EVM y todos los valores de las filas en MUD son cero. Necesitamos distinguir entre "el jugador aún no ha cavado aquí" y "el jugador cavó aquí y descubrió que no hay bombas alrededor".

Además, la comunicación entre el cliente y el servidor se produce a través del componente en cadena. Esto también se implementa usando tablas.

  • PendingGame: solicitudes no atendidas para iniciar un nuevo juego.
  • PendingDig: solicitudes no atendidas para cavar en un lugar específico en un juego específico. Esta es una tabla fuera de cadenaopens in a new tab, lo que significa que no se escribe en el almacenamiento de la EVM, solo se puede leer fuera de la cadena usando eventos.

Ejecución y flujos de datos

Estos flujos coordinan la ejecución between el cliente, el componente en cadena y el servidor.

Inicialización

Cuando ejecuta mprocs, ocurren estos pasos:

  1. mprocsopens in a new tab ejecuta cuatro componentes:

  2. El paquete contracts despliega los contratos de MUD y luego ejecuta el script PostDeploy.s.solopens in a new tab. Este script establece la configuración. El código de github especifica un campo minado de 10x5 con ocho minas en élopens in a new tab.

  3. El servidoropens in a new tab comienza configurando MUDopens in a new tab. Entre otras cosas, esto activa la sincronización de datos, de modo que exista una copia de las tablas relevantes en la memoria del servidor.

  4. El servidor suscribe una función para que se ejecute cuando cambie la tabla Configurationopens in a new tab. Esta funciónopens in a new tab se llama después de que PostDeploy.s.sol se ejecute y modifique la tabla.

  5. Cuando la función de inicialización del servidor tiene la configuración, llama a zkFunctionsopens in a new tab para inicializar la parte de conocimiento cero del servidor. Esto no puede suceder hasta que obtengamos la configuración porque las funciones de conocimiento cero deben tener el ancho y el alto del campo minado como constantes.

  6. Una vez que se inicializa la parte de conocimiento cero del servidor, el siguiente paso es desplegar el contrato de verificación de conocimiento cero en la cadena de bloquesopens in a new tab y establecer la dirección del verificador en MUD.

  7. Finalmente, nos suscribimos a las actualizaciones para que veamos cuándo un jugador solicita iniciar un nuevo juegoopens in a new tab o cavar en un juego existenteopens in a new tab.

Nuevo juego

Esto es lo que sucede cuando el jugador solicita un nuevo juego.

  1. Si no hay ningún juego en curso para este jugador, o hay uno pero con un gameId de cero, el cliente muestra un botón de nuevo juegoopens in a new tab. Cuando el usuario presiona este botón, React ejecuta la función newGameopens in a new tab.

  2. newGameopens in a new tab es una llamada de System. En MUD, todas las llamadas se enrutan a través del contrato World y, en la mayoría de los casos, se llama a <namespace>__<function name>. En este caso, la llamada es a app__newGame, que MUD luego enruta a newGame en GameSystemopens in a new tab.

  3. La función en cadena comprueba que el jugador no tiene un juego en curso y, si no lo hay, agrega la solicitud a la tabla PendingGameopens in a new tab.

  4. El servidor detecta el cambio en PendingGame y ejecuta la función suscritaopens in a new tab. Esta función llama a newGameopens in a new tab, que a su vez llama a createGameopens in a new tab.

  5. Lo primero que hace createGame es crear un mapa aleatorio con el número adecuado de minasopens in a new tab. Luego, llama a makeMapBordersopens in a new tab para crear un mapa con bordes en blanco, que es necesario para Zokrates. Finalmente, createGame llama a calculateMapHash, para obtener el hash del mapa, que se utiliza como ID del juego.

  6. La función newGame agrega el nuevo juego a gamesInProgress.

  7. Lo último que hace el servidor es llamar a app__newGameResponseopens in a new tab, que está en cadena. Esta función está en un System diferente, ServerSystemopens in a new tab, para permitir el control de acceso. El control de acceso se define en el archivo de configuración de MUDopens in a new tab, mud.config.tsopens in a new tab.

    La lista de acceso solo permite que una única dirección llame al System. Esto restringe el acceso a las funciones del servidor a una única dirección, por lo que nadie puede suplantar al servidor.

  8. El componente en cadena actualiza las tablas pertinentes:

    • Crea el juego en PlayerGame.
    • Establezca el mapeo inverso en GamePlayer.
    • Elimine la solicitud de PendingGame.
  9. El servidor identifica el cambio en PendingGame, pero no hace nada porque wantsGameopens in a new tab es falso.

  10. En el cliente, gameRecordopens in a new tab se establece en la entrada PlayerGame para la dirección del jugador. Cuando PlayerGame cambia, gameRecord también cambia.

  11. Si hay un valor en gameRecord y el juego no se ha ganado ni perdido, el cliente muestra el mapaopens in a new tab.

Cavar

  1. El jugador hace clic en el botón de la celda del mapaopens in a new tab, lo que llama a la función digopens in a new tab. Esta función llama a dig en cadenaopens in a new tab.

  2. El componente en cadena realiza una serie de comprobaciones de corduraopens in a new tab y, si tiene éxito, agrega la solicitud de excavación a PendingDigopens in a new tab.

  3. El servidor detecta el cambio en PendingDigopens in a new tab. Si es válidoopens in a new tab, llama al código de conocimiento ceroopens in a new tab (explicado a continuación) para generar tanto el resultado como una prueba de que es válido.

  4. El servidoropens in a new tab llama a digResponseopens in a new tab en cadena.

  5. digResponse hace dos cosas. En primer lugar, comprueba la prueba de conocimiento ceroopens in a new tab. Luego, si la prueba es correcta, llama a processDigResultopens in a new tab para procesar realmente el resultado.

  6. processDigResult comprueba si el juego se ha perdidoopens in a new tab o ganadoopens in a new tab, y actualiza Map, el mapa en cadenaopens in a new tab.

  7. El cliente recoge las actualizaciones automáticamente y actualiza el mapa que se muestra al jugadoropens in a new tab, y si procede, le dice al jugador si ha ganado o perdido.

Uso de Zokrates

En los flujos explicados anteriormente, omitimos las partes de conocimiento cero, tratándolas como una caja negra. Ahora abrámosla y veamos cómo está escrito ese código.

Aplicar hash al mapa

Podemos usar este código JavaScriptopens in a new tab para implementar Poseidonopens in a new tab, la función hash de Zokrates que usamos. Sin embargo, si bien esto sería más rápido, también sería más complicado que simplemente usar la función hash de Zokrates para hacerlo. Este es un tutorial, por lo que el código está optimizado para la simplicidad, no para el rendimiento. Por lo tanto, necesitamos dos programas Zokrates diferentes, uno para simplemente calcular el hash de un mapa (hash) y otro para crear realmente una prueba de conocimiento cero del resultado de la excavación en una ubicación en el mapa (dig).

La función hash

Esta es la función que calcula el hash de un mapa. Repasaremos este código línea por línea.

1import "hashes/poseidon/poseidon.zok" as poseidon;
2import "utils/pack/bool/pack128.zok" as pack128;

Estas dos líneas importan dos funciones de la biblioteca estándar de Zokratesopens in a new tab. La primera funciónopens in a new tab es un hash Poseidonopens in a new tab. Toma una matriz de elementos de campoopens in a new tab y devuelve un campo.

El elemento de campo en Zokrates suele tener menos de 256 bits, pero no por mucho. Para simplificar el código, restringimos el mapa a un máximo de 512 bits y aplicamos un hash a una matriz de cuatro campos, y en cada campo usamos solo 128 bits. La función pack128opens in a new tab convierte una matriz de 128 bits en un campo para este propósito.

1 def hashMap(bool[${width+2}][${height+2}] map) -> field {

Esta línea inicia una definición de función. hashMap obtiene un único parámetro llamado map, una matriz bool(eana) bidimensional. El tamaño del mapa es width+2 por height+2 por razones que se explican a continuación.

Podemos usar ${width+2} y ${height+2} porque los programas de Zokrates se almacenan en esta aplicación como cadenas de plantillaopens in a new tab. El código entre ${ y } es evaluado por JavaScript, y de esta manera el programa puede ser utilizado para diferentes tamaños de mapa. El parámetro del mapa tiene un borde de una ubicación de ancho a su alrededor sin ninguna bomba, que es la razón por la que necesitamos agregar dos al ancho y al alto.

El valor de retorno es un campo que contiene el hash.

1 bool[512] mut map1d = [false; 512];

El mapa es bidimensional. Sin embargo, la función pack128 no funciona con matrices bidimensionales. Así que primero aplanamos el mapa en una matriz de 512 bytes, usando map1d. Por defecto, las variables de Zokrates son constantes, pero necesitamos asignar valores a esta matriz en un bucle, así que la definimos como mutopens in a new tab.

Necesitamos inicializar la matriz porque Zokrates no tiene undefined. La expresión [false; 512] significa una matriz de 512 valores falseopens in a new tab.

1 u32 mut counter = 0;

También necesitamos un contador para distinguir entre los bits que ya hemos rellenado en map1d y los que no.

1 for u32 x in 0..${width+2} {

Así es como se declara un bucle foropens in a new tab en Zokrates. Un bucle for de Zokrates tiene que tener límites fijos, porque aunque parece un bucle, el compilador en realidad lo "desenrolla". La expresión ${width+2} es una constante de tiempo de compilación porque width es establecida por el código TypeScript antes de que llame al compilador.

1 for u32 y in 0..${height+2} {
2 map1d[counter] = map[x][y];
3 counter = counter+1;
4 }
5 }

Para cada ubicación en el mapa, ponga ese valor en la matriz map1d e incremente el contador.

1 field[4] hashMe = [
2 pack128(map1d[0..128]),
3 pack128(map1d[128..256]),
4 pack128(map1d[256..384]),
5 pack128(map1d[384..512])
6 ];

El pack128 para crear una matriz de cuatro valores de campo a partir de map1d. En Zokrates, array[a..b] significa el segmento de la matriz que comienza en a y termina en b-1.

1 return poseidon(hashMe);
2}

Use poseidon para convertir esta matriz en un hash.

El programa hash

El servidor necesita llamar a hashMap directamente para crear identificadores de juego. Sin embargo, Zokrates solo puede llamar a la función main en un programa para iniciar, por lo que creamos un programa con un main que llama a la función hash.

1${hashFragment}
2
3def main(bool[${width+2}][${height+2}] map) -> field {
4 return hashMap(map);
5}

El programa de excavación

Este es el corazón de la parte de conocimiento cero de la aplicación, donde producimos las pruebas que se utilizan para verificar los resultados de la excavación.

1${hashFragment}
2
3// El número de minas en la ubicación (x,y)
4def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
5 return if map[x+1][y+1] { 1 } else { 0 };
6}

Por qué el borde del mapa

Las pruebas de conocimiento cero usan circuitos aritméticosopens in a new tab, que no tienen un equivalente fácil a una declaración if. En su lugar, utilizan el equivalente del operador condicionalopens in a new tab. Si a puede ser cero o uno, puede calcular if a { b } else { c } como ab+(1-a)c.

Debido a esto, una declaración if de Zokrates siempre evalúa ambas ramas. Por ejemplo, si tiene este código:

1bool[5] arr = [false; 5];
2u32 index=10;
3return if index>4 { 0 } else { arr[index] }

Se producirá un error, porque necesita calcular arr[10], aunque ese valor se multiplicará más tarde por cero.

Esta es la razón por la que necesitamos un borde de una ubicación de ancho alrededor de todo el mapa. Necesitamos calcular el número total de minas alrededor de una ubicación, y eso significa que necesitamos ver la ubicación una fila arriba y abajo, a la izquierda y a la derecha, de la ubicación donde estamos cavando. Lo que significa que esas ubicaciones tienen que existir en la matriz del mapa que se proporciona a Zokrates.

1def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {

Por defecto, las pruebas de Zokrates incluyen sus entradas. No sirve de nada saber que hay cinco minas alrededor de un lugar a menos que realmente sepa de qué lugar se trata (y no puede simplemente hacerlo coincidir con su solicitud, porque entonces el probador podría usar valores diferentes y no decírselo). Sin embargo, necesitamos mantener el mapa en secreto, mientras se lo proporcionamos a Zokrates. La solución es usar un parámetro private (privado), uno que no se revela por la prueba.

Esto abre otra vía para el abuso. El probador podría usar las coordenadas correctas, pero crear un mapa con cualquier número de minas alrededor de la ubicación y posiblemente en la ubicación misma. Para evitar este abuso, hacemos que la prueba de conocimiento cero incluya el hash del mapa, que es el identificador del juego.

1 return (hashMap(map),

El valor de retorno aquí es una tupla que incluye la matriz de hash del mapa, así como el resultado de la excavación.

1 if map2mineCount(map, x, y) > 0 { 0xFF } else {

Usamos 255 como un valor especial en caso de que la ubicación misma tenga una bomba.

1 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
2 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
3 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
4 }
5 );
6}

Si el jugador no ha encontrado una mina, sume los recuentos de minas del área alrededor de la ubicación y devuelva eso.

Uso de Zokrates desde TypeScript

Zokrates tiene una interfaz de línea de comandos, pero en este programa lo usamos en el código TypeScriptopens in a new tab.

La biblioteca que contiene las definiciones de Zokrates se llama zero-knowledge.tsopens in a new tab.

1import { initialize as zokratesInitialize } from "zokrates-js"

Importe los enlaces JavaScript de Zokratesopens in a new tab. Solo necesitamos la función initializeopens in a new tab porque devuelve una promesa que se resuelve con todas las definiciones de Zokrates.

1export const zkFunctions = async (width: number, height: number) : Promise<any> => {

Al igual que el propio Zokrates, también exportamos una sola función, que también es asíncronaopens in a new tab. Cuando finalmente regresa, proporciona varias funciones como veremos a continuación.

1const zokrates = await zokratesInitialize()

Inicialice Zokrates, obtenga todo lo que necesita de la biblioteca.

1const hashFragment = `
2 import "utils/pack/bool/pack128.zok" as pack128;
3 import "hashes/poseidon/poseidon.zok" as poseidon;
4 .
5 .
6 .
7 }
8 `
9
10const hashProgram = `
11 ${hashFragment}
12 .
13 .
14 .
15 `
16
17const digProgram = `
18 ${hashFragment}
19 .
20 .
21 .
22 `
Mostrar todo

A continuación tenemos la función hash y dos programas de Zokrates que vimos anteriormente.

1const digCompiled = zokrates.compile(digProgram)
2const hashCompiled = zokrates.compile(hashProgram)

Aquí compilamos esos programas.

1// Cree las claves para la verificación de conocimiento cero.
2// En un sistema de producción, querrá utilizar una ceremonia de configuración.
3// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
4const keySetupResults = zokrates.setup(digCompiled.program, "")
5const verifierKey = keySetupResults.vk
6const proverKey = keySetupResults.pk

En un sistema de producción, podríamos usar una ceremonia de configuraciónopens in a new tab más complicada, pero esto es suficiente para una demostración. No es un problema que los usuarios puedan conocer la clave del probador, ya que aun así no pueden usarla para probar cosas a menos que sean ciertas. Como especificamos la entropía (el segundo parámetro, ""), los resultados siempre serán los mismos.

Nota: la compilación de programas de Zokrates y la creación de claves son procesos lentos. No es necesario repetirlos cada vez, solo cuando cambia el tamaño del mapa. En un sistema de producción, los haría una vez y luego almacenaría el resultado. La única razón por la que no lo hago aquí es por simplicidad.

calculateMapHash

1const calculateMapHash = function (hashMe: boolean[][]): string {
2 return (
3 "0x" +
4 BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
5 .toString(16)
6 .padStart(64, "0")
7 )
8}

La función computeWitnessopens in a new tab realmente ejecuta el programa de Zokrates. Devuelve una estructura con dos campos: output, que es la salida del programa como una cadena JSON, y witness, que es la información necesaria para crear la prueba de conocimiento cero del resultado. Aquí solo necesitamos la salida.

La salida es una cadena de la forma "31337", un número decimal entre comillas. Pero la salida que necesitamos para viem es un número hexadecimal de la forma 0x60A7. Así que usamos .slice(1,-1) para eliminar las comillas y luego BigInt para convertir la cadena restante, que es un número decimal, a un BigIntopens in a new tab. .toString(16) convierte este BigInt en una cadena hexadecimal, y "0x"+ agrega el marcador para números hexadecimales.

1// Cava y devuelve una prueba de conocimiento cero del resultado
2// (código del lado del servidor)

La prueba de conocimiento cero incluye las entradas públicas (x e y) y los resultados (hash del mapa y número de bombas).

1 const zkDig = function(map: boolean[][], x: number, y: number) : any {
2 if (x<0 || x>=width || y<0 || y>=height)
3 throw new Error("Intentando cavar fuera del mapa")

Es un problema comprobar si un índice está fuera de los límites en Zokrates, así que lo hacemos aquí.

1const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])

Ejecute el programa de excavación.

1 const proof = zokrates.generateProof(
2 digCompiled.program,
3 runResults.witness,
4 proverKey)
5
6 return proof
7 }

Use generateProofopens in a new tab y devuelva la prueba.

1const solidityVerifier = `
2 // Tamaño del mapa: ${width} x ${height}
3 \n${zokrates.exportSolidityVerifier(verifierKey)}
4 `

Un verificador de Solidity, un contrato inteligente que podemos desplegar en la cadena de bloques y usar para verificar las pruebas generadas por digCompiled.program.

1 return {
2 zkDig,
3 calculateMapHash,
4 solidityVerifier,
5 }
6}

Finalmente, devuelve todo lo que otro código pueda necesitar.

Pruebas de seguridad

Las pruebas de seguridad son importantes porque un error de funcionalidad eventualmente se revelará. Pero si la aplicación es insegura, es probable que eso permanezca oculto durante mucho tiempo antes de que lo revele alguien que haga trampas y se apropie de recursos que pertenecen a otros.

Permisos

Hay una entidad privilegiada en este juego, el servidor. Es el único usuario autorizado a llamar a las funciones en ServerSystemopens in a new tab. Podemos usar castopens in a new tab para verificar que las llamadas a funciones con permisos solo se permiten como la cuenta del servidor.

La clave privada del servidor está en setupNetwork.tsopens in a new tab.

  1. En la computadora que ejecuta anvil (la cadena de bloques), establezca estas variables de entorno.

    1WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    2UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    3AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
  2. Use cast para intentar establecer la dirección del verificador como una dirección no autorizada.

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY

    No solo cast informa de un fallo, sino que puede abrir MUD Dev Tools (Herramientas de desarrollo de MUD) en el juego en el navegador, hacer clic en Tables (Tablas) y seleccionar app__VerifierAddress. Vea que la dirección no es cero.

  3. Establezca la dirección del verificador como la dirección del servidor.

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEY

    La dirección en app__VerifiedAddress debería ser ahora cero.

Todas las funciones de MUD en el mismo System pasan por el mismo control de acceso, por lo que considero que esta prueba es suficiente. Si no lo cree, puede comprobar las otras funciones en ServerSystemopens in a new tab.

Abusos de conocimiento cero

Las matemáticas para verificar Zokrates están fuera del alcance de este tutorial (y de mis habilidades). Sin embargo, podemos ejecutar varias comprobaciones en el código de conocimiento cero para verificar que, si no se hace correctamente, falla. Todas estas pruebas requerirán que cambiemos zero-knowledge.tsopens in a new tab y reiniciemos toda la aplicación. No es suficiente reiniciar el proceso del servidor, porque pone la aplicación en un estado imposible (el jugador tiene un juego en curso, pero el juego ya no está disponible para el servidor).

Respuesta incorrecta

La posibilidad más simple es proporcionar la respuesta incorrecta en la prueba de conocimiento cero. Para ello, entramos en zkDig y modificamos la línea 91opens in a new tab:

1proof.inputs[3] = "0x" + "1".padStart(64, "0")

Esto significa que siempre afirmaremos que hay una bomba, independientemente de la respuesta correcta. Intente jugar con esta versión y verá en la pestaña servidor de la pantalla pnpm dev este error:

1 cause: {
2 code: 3,
3 message: 'execution reverted: revert: Fallo de la verificación de conocimiento cero',
4 data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
5000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
6e206661696c'
7 },

Así que este tipo de trampa falla.

Prueba incorrecta

¿Qué sucede si proporcionamos la información correcta, pero simplemente tenemos los datos de prueba incorrectos? Ahora, reemplace la línea 91 por:

1proof.proof = {
2 a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
3 b: [
4 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
5 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
6 ],
7 c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
8}

Todavía falla, pero ahora falla sin razón porque ocurre durante la llamada al verificador.

¿Cómo puede un usuario verificar el código de confianza cero?

Los contratos inteligentes son relativamente fáciles de verificar. Normalmente, el desarrollador publica el código fuente en un explorador de bloques, y el explorador de bloques verifica que el código fuente se compila en el código de la transacción de despliegue del contrato. En el caso de los System de MUD, esto es ligeramente más complicadoopens in a new tab, pero no mucho.

Esto es más difícil con el conocimiento cero. El verificador incluye algunas constantes y ejecuta algunos cálculos sobre ellas. Esto no le dice qué se está probando.

1 function verifyingKey() pure internal returns (VerifyingKey memory vk) {
2 vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
3 vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);

La solución, al menos hasta que los exploradores de bloques se animen a añadir la verificación de Zokrates a sus interfaces de usuario, es que los desarrolladores de aplicaciones pongan a disposición los programas de Zokrates y que, al menos, algunos usuarios los compilen ellos mismos con la clave de verificación adecuada.

Para ello:

  1. Instale Zokratesopens in a new tab.

  2. Cree un archivo, dig.zok, con el programa Zokrates. El siguiente código asume que ha mantenido el tamaño original del mapa, 10x5.

    1 import "utils/pack/bool/pack128.zok" as pack128;
    2 import "hashes/poseidon/poseidon.zok" as poseidon;
    3
    4 def hashMap(bool[12][7] map) -> field {
    5 bool[512] mut map1d = [false; 512];
    6 u32 mut counter = 0;
    7
    8 for u32 x in 0..12 {
    9 for u32 y in 0..7 {
    10 map1d[counter] = map[x][y];
    11 counter = counter+1;
    12 }
    13 }
    14
    15 field[4] hashMe = [
    16 pack128(map1d[0..128]),
    17 pack128(map1d[128..256]),
    18 pack128(map1d[256..384]),
    19 pack128(map1d[384..512])
    20 ];
    21
    22 return poseidon(hashMe);
    23 }
    24
    25
    26 // El número de minas en la ubicación (x,y)
    27 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 {
    28 return if map[x+1][y+1] { 1 } else { 0 };
    29 }
    30
    31 def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) {
    32 return (hashMap(map) ,
    33 if map2mineCount(map, x, y) > 0 { 0xFF } else {
    34 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
    35 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
    36 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
    37 }
    38 );
    39 }
    Mostrar todo
  3. Compile el código Zokrates y cree la clave de verificación. La clave de verificación debe crearse con la misma entropía utilizada en el servidor original, en este caso, una cadena vacíaopens in a new tab.

    1zokrates compile --input dig.zok
    2zokrates setup -e ""
  4. Cree el verificador de Solidity por su cuenta y verifique que es funcionalmente idéntico al de la cadena de bloques (el servidor añade un comentario, pero eso no es importante).

    1zokrates export-verifier
    2diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol

Decisiones de diseño

En cualquier aplicación suficientemente compleja existen objetivos de diseño contrapuestos que requieren concesiones. Veamos algunas de las compensaciones y por qué la solución actual es preferible a otras opciones.

Por qué conocimiento cero

Para el buscaminas no se necesita realmente el conocimiento cero. El servidor siempre puede guardar el mapa y revelarlo todo cuando termine el juego. Entonces, al final del juego, el contrato inteligente puede calcular el hash del mapa, verificar que coincide y, si no es así, penalizar al servidor o descartar el juego por completo.

No utilicé esta solución más sencilla porque solo funciona para juegos cortos con un estado final bien definido. Cuando un juego es potencialmente infinito (como en el caso de los mundos autónomosopens in a new tab), se necesita una solución que demuestre el estado sin revelarlo.

Como tutorial, este artículo necesitaba un juego corto que fuera fácil de entender, pero esta técnica es más útil para juegos más largos.

¿Por qué Zokrates?

Zokratesopens in a new tab no es la única biblioteca de conocimiento cero disponible, pero es similar a un lenguaje de programación normal, imperativoopens in a new tab y admite variables booleanas.

Para su aplicación, con diferentes requisitos, es posible que prefiera usar Circumopens in a new tab o Cairoopens in a new tab.

Cuándo compilar Zokrates

En este programa compilamos los programas Zokrates cada vez que se inicia el servidoropens in a new tab. Esto es claramente un desperdicio de recursos, pero este es un tutorial, optimizado para la simplicidad.

Si estuviera escribiendo una aplicación a nivel de producción, comprobaría si tengo un archivo con los programas de Zokrates compilados en este tamaño de campo minado y, en caso afirmativo, lo usaría. Lo mismo ocurre con el despliegue de un contrato de verificador en cadena.

Creación de las claves del verificador y del probador

La creación de clavesopens in a new tab es otro cálculo puro que no es necesario realizar más de una vez para un tamaño de campo minado determinado. Nuevamente, se hace solo una vez por simplicidad.

Además, podríamos usar una ceremonia de configuraciónopens in a new tab. La ventaja de una ceremonia de configuración es que se necesita la entropía o algún resultado intermedio de cada participante para hacer trampas en la prueba de conocimiento cero. Si al menos un participante de la ceremonia es honesto y elimina esa información, las pruebas de conocimiento cero están a salvo de ciertos ataques. Sin embargo, no hay ningún mecanismo para verificar que la información se ha eliminado de todas partes. Si las pruebas de conocimiento cero son de importancia crítica, querrá participar en la ceremonia de configuración.

Aquí nos basamos en poderes perpetuos de tauopens in a new tab, que tuvo docenas de participantes. Probablemente sea lo suficientemente seguro y mucho más simple. Tampoco agregamos entropía durante la creación de la clave, lo que facilita a los usuarios verificar la configuración de conocimiento cero.

Dónde verificar

Podemos verificar las pruebas de conocimiento cero ya sea en cadena (lo que cuesta gas) o en el cliente (usando verifyopens in a new tab). Elegí la primera, porque esto le permite verificar el verificador una vez y luego confiar en que no cambiará mientras la dirección del contrato para él permanezca igual. Si la verificación se hiciera en el cliente, tendría que verificar el código que recibe cada vez que descarga el cliente.

Además, aunque este juego es para un solo jugador, muchos juegos de cadena de bloques son multijugador. La verificación en cadena significa que solo se verifica la prueba de conocimiento cero una vez. Hacerlo en el cliente requeriría que cada cliente verificara de forma independiente.

¿Aplanar el mapa en TypeScript o Zokrates?

En general, cuando el procesamiento se puede hacer en TypeScript o Zokrates, es mejor hacerlo en TypeScript, que es mucho más rápido y no requiere pruebas de conocimiento cero. Esta es la razón, por ejemplo, de que no proporcionemos a Zokrates el hash y hagamos que verifique que es correcto. La aplicación de hash debe hacerse dentro de Zokrates, pero la coincidencia entre el hash devuelto y el hash en cadena puede ocurrir fuera de él.

Sin embargo, todavía aplanamos el mapa en Zokratesopens in a new tab, mientras que podríamos haberlo hecho en TypeScript. La razón es que las otras opciones son, en mi opinión, peores.

  • Proporcione una matriz unidimensional de booleanos al código de Zokrates y use una expresión como x*(height+2) +y para obtener el mapa bidimensional. Esto haría el códigoopens in a new tab algo más complicado, así que decidí que la ganancia de rendimiento no vale la pena para un tutorial.

  • Envíe a Zokrates tanto la matriz unidimensional como la bidimensional. Sin embargo, esta solución no nos aporta nada. El código de Zokrates tendría que verificar que la matriz unidimensional que se le proporciona es realmente la representación correcta de la matriz bidimensional. Así que no habría ninguna ganancia de rendimiento.

  • Aplanar la matriz bidimensional en Zokrates. Esta es la opción más sencilla, así que la elegí.

Dónde almacenar los mapas

En esta aplicación, gamesInProgressopens in a new tab es simplemente una variable en la memoria. Esto significa que si el servidor muere y necesita ser reiniciado, toda la información que almacenó se pierde. No solo los jugadores no pueden continuar su juego, sino que ni siquiera pueden comenzar un nuevo juego porque el componente en cadena cree que todavía tienen un juego en progreso.

Este es claramente un mal diseño para un sistema de producción, en el que almacenaría esta información en una base de datos. La única razón por la que utilicé una variable aquí es because este es un tutorial y la simplicidad es la principal consideración.

Conclusión: ¿en qué condiciones es esta la técnica adecuada?

Así que ahora ya sabe cómo escribir un juego con un servidor que almacena un estado secreto que no pertenece a la cadena. Pero, ¿en qué casos debería hacerlo? Hay dos consideraciones principales.

  • Juego de larga duración: como se mencionó anteriormente, en un juego corto puede publicar el estado una vez que el juego ha terminado y hacer que todo se verifique entonces. Pero esa no es una opción cuando el juego toma un tiempo largo o indefinido y el estado necesita permanecer en secreto.

  • Cierta centralización aceptable: las pruebas de conocimiento cero pueden verificar la integridad, que una entidad no está falsificando los resultados. Lo que no pueden hacer es garantizar que la entidad seguirá disponible y responderá a los mensajes. En situaciones donde la disponibilidad también necesita ser descentralizada, las pruebas de conocimiento cero no son una solución suficiente y se necesita computación multipartitaopens in a new tab.

Vea aquí más de mi trabajoopens in a new tab.

Reconocimientos

  • Alvaro Alonso leyó un borrador de este artículo y aclaró algunos de mis malentendidos sobre Zokrates.

Cualquier error restante es mi responsabilidad.

Última actualización de la página: 6 de septiembre de 2025

¿Le ha resultado útil este tutorial?