Cómo desarrollar una DApp de conocimiento cero
Las pruebas de conocimiento cero fueron introducidas por primera vez en el año 1985 como solución a problemas de seguridad. Sin embargo, no fue hasta recientemente cuando suscitaron un gran interés en el ámbito de las cadenas de bloque debido a la solución que ofrecen a problemas de escalabilidad.
En la actualidad, hay una tendencia al alza en la aplicación de esta tecnología en algunas aplicaciones descentralizadas gracias a que sus propiedades son de utilidad para los desarrolladores a la hora de crear aplicaciones más seguras y privadas.
Esta entrada ofrece una introducción a cómo desarrollar una aplicación capaz de generar y validar pruebas de conocimiento cero en el contexto del juego Conecta Cuatro para poder demostrar a un verificador que se tiene información sobre la partida sin tener que revelarla.
En este artículo
Perspectiva general
Para dar conformidad a esta aplicación se ha desarrollado con Turborepo un monorepositorio con tres espacios de trabajo principales.
Por una parte, un circuito zk-SNARK desarrollado con la librería Circom, por otra, un entorno de desarrollo de Hardhat de contratos inteligentes escritos en lenguaje Solidity (que actúan como verificadores de las pruebas) y, por último, una aplicación web escrita en TypeScript y desarrollada con Next.js.
Dado que la aplicación consiste en un juego de dos jugadores, se ha introducido también un agente de aprendizaje por refuerzo para el modelado en PyTorch de una política de juego capaz de actuar como uno de los jugadores.
En el diagrama de abajo intento concretar lo máximo posible el flujo de trabajo de esta aplicación, el cuál explicaré detenidamente en el transcurso de las siguientes secciones.
¿Qué es una zk-DApp?
Una zk-DApp es una aplicación descentralizada con la particularidad de que ofrece la opción de generar y validar pruebas de conocimiento cero. Este tipo de pruebas responden a un protocolo que posibilita que un verificador pueda asegurar la validez de una afirmación por parte de un probador, sin que éste tenga que revelar información que considere privada.
Existen diferentes clases de pruebas de conocimiento cero aunque en nuestro caso nos centraremos en el tipo zk-SNARK, acrónimo de zero-knowledege Succinct Non-interactive ARgument of Knowledge, el cuál tiene las siguientes propiedades:
-
zero-knowledge: el verificador no tiene ningún conocimiento de la prueba salvo de si es válida o no.
-
succinct: el tamaño de la prueba es pequeño y por lo tanto su tiempo de verificación es reducido.
-
non-interactive: no necesita de interacción entre el probador y el verificador más allá de una única ronda de comunicación.
-
argument of knowledge: prueba que consiste en una aserción criptográfica de que la afirmación del probador es válida.
Circuito zk-SNARK
Las pruebas zk-SNARK solamente se pueden generar directamente desde un circuito aritmético de campo finito F_p, es decir, un circuito capaz de realizar operaciones de suma y multiplicación en un campo finito de un tamaño determinado.
Por lo tanto, todas las operaciones son calculadas módulo a un valor concreto, por ejemplo, en Ethereum se necesita operar con circuitos aritméticos F_p tomando el número primo:
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
La conversión de un problema computacional a un circuito aritmético no siempre es aparente o evidente aunque, en la mayoría de los casos, sí es factible. La idea principal es reducir este problema a una serie de ecuaciones.
Por lo tanto, en este contexto podemos decir que el verificador de la prueba podrá comprobar criptográficamente que el probador conoce una información que cumple un sistema de ecuaciones.
En Circom, estas ecuaciones se llaman restricciones y deben ser únicamente ecuaciones cuadráticas del tipo a * b + c = 0, siendo a, b y c combinaciones lineales de señales, es decir, una restricción debe tener únicamente una multiplicación y una suma de una constante.
Las señales son las entradas y salidas de los circuitos, el cálculo de las cuales da como resultado el vector del testigo, por lo tanto cualquier salida es también una entrada al vector testigo. El testigo es, entonces, el conjunto de señales del circuito (de entrada, intermedias y de salida) y se utiliza para la generación de la prueba.
En resumen, Circom nos ayudará a crear un sistema de restricciones. Estas restricciones se llaman restricciones Rank 1 (R1CS).
El circuito tendrá dos cometidos principales, por un lado, (i) generar la prueba, para lo que se introducen las restricciones y, por otro lado, (ii) calcular el testigo, para lo que se resuelven las señales intermedias y la salida.
Como cada restricción o ecuación debe tener una multiplicación, implementar los problemas de esta manera reviste cierta complejidad, tanto es así que en ocasiones se requiere de señales auxiliares que pueden o no quedar restringidas pero a las que hay que asignarles un valor.
En Circom se considera peligroso asignar un valor a una señal sin que se genere una restricción, dado que un sistema puede quedar infrarestringido y resolver pruebas que no estén bien diseñadas, así que normalmente se harán ambas cosas a no ser que se trate de una expresión que no se pueda incluir en una restricción.
Por esta razón, Circom incluye operadores para la asignación de señales, para la generación de restricciones y operadores que realizan ambas acciones simultáneamente:
-
Si queremos escribir una ecuación que solo asigne el resultado de una multiplicación a una señal, utilizaremos
c <-- a * b. -
Si solo queremos restringir la señal
cpara que sea el resultado de la multiplicación deayb, utilizaremos la expresiónc === a * b. -
Finalmente, para hacer ambas cosas al mismo tiempo, utilizaremos la ecuación
c <== a * b, la cual calcula la multiplicación, la asigna y restringe el resultado ac.
A continuación, presentaré el circuito de Circom para generar pruebas de conocimiento cero sobre la información de una partida determinada de Conecta Cuatro.
Implementación del circuito
Nuestro circuito se utilizará para generar el testigo y la prueba de una partida que ya ha finalizado con la victoria de uno de los jugadores.
Con este circuito, el probador podrá demostrar al verificador que conoce al ganador y la combinación ganadora sin tener que revelar dicha información.
El circuito debe cumplir con los siguientes propósitos:
-
Integridad del valor de las celdas: asegurar que cada una de las celdas del tablero contenga el valor 0, 1 o 2.
-
Validación del ganador: asegurar que la señal del ganador proporcionada sea 1 o 2.
-
Prevención de victorias preexistentes: asegurar que no hubiera líneas ganadoras antes del último movimiento realizado.
-
Recuento válido de fichas (orden de turnos): asegurar que el jugador 1 tenga el mismo número de fichas que el jugador 2 o exactamente una más.
-
Verificación de las coordenadas ganadoras: asegurar que las cuatro coordenadas proporcionadas no estén fuera de los límites y contengan al ganador.
-
Continuidad de la línea recta: asegurar que las cuatro coordenadas proporcionadas formen una línea recta continua.
-
Comprobación de gravedad (sin fichas flotantes): asegurar que no haya fichas flotantes en el tablero.
-
Validación del último movimiento: asegurar que las coordenadas del último movimiento no estén fuera de los límites y contengan al ganador.
-
Integridad de la colocación de la última ficha: asegurar que la última ficha colocada no tenga fichas justo encima.
Para lograr esto, el circuito requiere cuatro entradas privadas: la observación del tablero, el ganador (1 o 2), un array con los índices de las cuatro fichas consecutivas, y otro con las coordenadas del último movimiento.
template ConnectFour() {
// private input signal representing the board
signal input board[6][7];
// winner of the game, either 1 or 2
signal input winner;
// array of arrays with the indexes of the four consecutive counters
signal input coordinates[4][2];
// array with the indexes of the last move
signal input lastMove[2];
// ...
}
component main = ConnectFour();Como se puede observar, en Circom se utiliza un template para definir un circuito genérico en el que se pueden instanciar otros esquemas. Sin embargo, se utiliza la palabra clave main para definir el punto de entrada o componente principal del circuito.
A continuación, analizaremos cada uno de los objetivos enumerados anteriormente para mostrar cómo programar todas las restricciones.
En primer lugar, para garantizar que las celdas contengan los valores correctos, utilizaremos un bucle for para iterar sobre el valor de cada celda del tablero:
// 1 - cell value integrity
component isZeroOneOrTwoCVI[6][7];
for (var row = 0; row < 6; row++) {
for (var col = 0; col < 7; col++) {
isZeroOneOrTwoCVI[row][col] = IsZeroOneOrTwo();
isZeroOneOrTwoCVI[row][col].in <== board[row][col];
}
}La ecuación que resuelve este problema es el polinomio de tercer grado (x - 0) * (x - 1) * (x - 2) = 0. No obstante, dado que Circom solo permite ecuaciones cuadráticas, primero asignamos el resultado de (x - 0) * (x - 1) a un elemento del array isZeroOrOne (que consta de 42 elementos, uno por cada celda del tablero). Posteriormente, multiplicamos dicho valor por (x - 2) y restringimos el resultado a cero, garantizando así que el valor de la celda en cada iteración sea exclusivamente 0, 1 o 2.
// 1 - assert all cells are either 0, 1 or 2
signal isZeroOrOne[6][7];
for (var row = 0; row < 6; row++) {
for (var col = 0; col < 7; col++) {
isZeroOrOne[row][col] <== (board[row][col] - 0) * (board[row][col] - 1);
isZeroOrOne[row][col] * (board[row][col] - 2) === 0;
}
}Si ejecutamos el script pnpm run print:constraints obtendremos todas las restricciones del circuito, lo que nos ayudará a entender mejor el funcionamiento del circuito y a depurar nuestra implementación.
Nos centraremos en las dos primeras restricciones que se corresponden a la primera iteración del for para row = 0 y col = 0, es decir, la comprobación de que la celda en el margen superior izquierdo del tablero equivale a 0, 1 o 2:
circuits:print:constraints: [INFO] snarkJS: [ 21888242871839275222246405745257275088548364400416034343698204186575808495616main.board[0][0] ] * [ 218882428718392752222464057452572750885483644004160343436982041865758084956161 +main.board[0][0] ] - [ 21888242871839275222246405745257275088548364400416034343698204186575808495616main.isZeroOrOne[0][0] ] = 0
circuits:print:constraints: [INFO] snarkJS: [ 218882428718392752222464057452572750885483644004160343436982041865758084956151 +main.board[0][0] ] * [ main.isZeroOrOne[0][0] ] - [ ] = 0Considerando el campo finito de las operaciones en nuestro circuito aritmético, estas ecuaciones son equivalentes a:
[ 1 * main.board[0][0] ] * [ -1 + main.board[0][0] ] - [ main.isZeroOneOrTwo[0][0].isZeroOrOne ] = 0
[ -2 + main.board[0][0] ] * [ main.isZeroOneOrTwo[0][0].isZeroOrOne ] = 0Como main.board[0][0] equivale a la señal de entrada in, y haciendo un ejercicio sencillo de álgebra, tendríamos las siguientes ecuaciones, que se corresponden con las restricciones del circuito AssertIsZeroOneOrTwo:
isZeroOrOne = in * (in - 1)
(in - 2) * isZeroOrOne = 0De manera similar, debemos verificar que la señal de entrada winner sea 1 o 2:
// 2 - winner validation
component isZeroOneOrTwoWV = IsZeroOneOrTwo();
isZeroOneOrTwoWV.in <== winner;Ahora, para garantizar que exista al menos una combinación ganadora (cuatro fichas consecutivas en el tablero), debemos implementar un algoritmo que recorra las columnas y filas en cada orientación posible: horizontal, vertical, diagonal y antidiagonal.
Por ejemplo, para la orientación horizontal, utilizaríamos la siguiente implementación:
// 3 - pre-existing win prevention
component checkLine[69];
signal preWinAcc[70];
preWinAcc[0] <== 0;
var idx = 0;
// horizontal check
for (var row = 0; row < 6; row++) {
for (var col = 0; col < 4; col++) {
// pass the line's coordinates to the template so it knows its own position
checkLine[idx] = CheckLineAndPreExistingWin(row, col, row, col + 1, row, col + 2, row, col + 3);
checkLine[idx].winner <== winner;
checkLine[idx].lastMove <== lastMove;
for(var i = 0; i < 4; i++) {
checkLine[idx].cells[i] <== board[row][col + i];
}
preWinAcc[idx + 1] <== preWinAcc[idx] + checkLine[idx].isPreExistingWin;
idx++;
}
}El enfoque consiste en recorrer el tablero para identificar líneas horizontales; por lo tanto, instanciamos la plantilla CheckLineAndPreExistingWin en cada iteración, pasando las coordenadas de las cuatro celdas como parámetros.
Este circuito (i) confirmará que todos los valores de las celdas coinciden con el ganador y (ii) verificará si las coordenadas del último movimiento se encuentran dentro de esa línea. Fundamentalmente, si estas coordenadas no estuvieran dentro de la línea, las entradas se considerarían inválidas; en tal caso, CheckLineAndPreExistingWin devolvería 1, y 0 en caso contrario.
Como se muestra en la línea 15 del fragmento anterior, declaramos y asignamos al primer elemento disponible del array de señales preWinAccumulator la suma del elemento anterior y la salida del componente checkLine para esa iteración: preWinAccumulator[idx + 1] <== preWinAccumulator[idx] + checkLine[idx].isPreExistingWin.
Por último, una vez procesadas todas las combinaciones, el circuito verifica que no había ninguna linea ganador antes de que se jugase la última ficha:
preWinAcc[idx] === 0;Una regla fundamental del Conecta Cuatro es la secuencia por turnos; por lo tanto, el circuito impone que el jugador 1 tenga el mismo número de fichas que el jugador 2 o exactamente una más. Esta lógica utiliza un acumulador (p1Acc y p2Acc) para sumar las apariciones de las fichas de cada jugador. La restricción final, countDiff * (countDiff - 1) === 0, garantiza matemáticamente que la diferencia sea estrictamente 0 o 1.
Dado que disponemos del recuento de fichas de cada jugador, verificamos asimismo que, si se comunica al circuito que la partida terminó en empate, esto sea cierto.
A continuación, el circuito implementa la lógica para garantizar que las coordenadas proporcionadas en la señal de entrada no estén fuera de los límites. El algoritmo recorre el tablero y, cuando tanto los índices de la columna como los de la fila coinciden con los del array de señales coordinates, asigna un 1 a un elemento específico del array de señales; de lo contrario, asigna 0. Finalmente, tras completar la iteración, el circuito confirma que la suma de este array de señales es igual a 1; si las coordenadas estuvieran fuera de los límites, la suma sería 0.
Además, en caso de que se incluya un ganador, en cada iteración se comprueba en las lineas 21 y 22 que la coordenada corresponde a una ficha de ese jugador.
Para evitar la selección arbitraria de celdas cuando hay un ganador, el circuito debe validar que las cuatro coordenadas elegidas no solo sean correctas, sino que también formen una línea recta continua en una de las direcciones permitidas. Al calcular deltaRow y deltaCol entre puntos consecutivos, el circuito impone una dirección constante. Además, restringe estos pasos al rango {-1, 0, 1} mediante ecuaciones cuadráticas, garantizando que la secuencia siga las orientaciones estándar de Conecta Cuatro.
También se restringe que el incremento de las coordenadas sea el correcto cuando hay un ganador.
Para simular la gravedad física, debemos asegurar que no haya fichas flotantes en el tablero. La siguiente lógica recorre la rejilla para verificar que cada ficha, a menos que esté en la fila inferior, esté apoyada sobre otra ficha directamente debajo de ella. La restricción (1 - isEmpty.out) * isBelowEmpty.out === 0 afirma que si una celda está ocupada, la celda de abajo no puede estar vacía.
// 7 - gravity check (no flying pieces)
component isEmpty[5][7];
component isBelowEmpty[5][7];
for (var row = 0; row < 5; row++) { // Rows 0-4
for (var col = 0; col < 7; col++) {
isEmpty[row][col] = IsZero();
isEmpty[row][col].in <== board[row][col];
isBelowEmpty[row][col] = IsZero();
isBelowEmpty[row][col].in <== board[row + 1][col];
// constraint: if the cell has a value, its cell below must not be empty
(1 - isEmpty[row][col].out) * isBelowEmpty[row][col].out === 0;
}
}El circuito debe asegurar explícitamente que las coordenadas del último movimiento sean válidas, confirmando que no están fuera de los límites y que contienen al ganador. Mediante el uso de componentes IsEqual para comparar la señal lastMove con los índices del tablero, el circuito verifica la presencia de la ficha del ganador en esa ubicación específica. Finalmente, legalLastMoveAcc[42] === 1 confirma que se encontró exactamente una coincidencia válida dentro de los límites del tablero.
También se constata en las líneas 32-33 y 35-36 que la celda del último movimiento contiene un 1 si ganó el jugador 1, o un 2 si ganó el jugador 2 o si la partida terminó en empate.
Finalmente, debemos asegurar que la última ficha colocada sea realmente la ficha superior de su columna. El circuito comprueba la celda directamente encima de las coordenadas de lastMove; al afirmar que no hay fichas justo encima de ella, la lógica garantiza que no se realizaron movimientos posteriores en esa columna, demostrando la integridad del estado final del juego.
Testeado del circuito
Circom también facilita la librería circom_tester que nos permite testear el circuito en lenguaje JavaScript, o TypeScript, mediante la abstracción del circuito utilizando la función wasmTester:
before(async function () {
circuit = await wasmTester('connect_four.circom');
});El objetivo del testeo es asegurarnos de que el circuito tenga éxito calculando un testigo a partir de unas entradas válidas (entradas que se corresponden con los valores del tablero) y que no lo tenga cuando las entradas, o al menos una de ellas, no sean válidas.
Por ejemplo, para asegurarnos del éxito del circuito al pasar entradas válidas implementamos el siguiente código en un describe de Mocha y comprobamos la generación del testigo y las restricciones:
it('calculating a witness for a valid board, winner and horizontal winning move coordinates', async function () {
const input = {
board: [
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[1, 2, 0, 0, 0, 0, 0],
[2, 2, 0, 0, 0, 0, 0],
[2, 2, 1, 1, 1, 1, 1]
],
coordinates: [
[5, 3],
[5, 4],
[5, 5],
[5, 6]
],
lastMove: [5, 3],
winner: 1
};
const witness = await circuit.calculateWitness(input, true);
await circuit.checkConstraints(witness);
});Como vemos, este test debería ser exitoso porque solamente hay una combinación ganadora en el tablero, cuyo usuario corresponde al ganador especificado al igual que sus coordenadas, o indices en el array.
Además, se implementan otros tests en los que se intenta generar un testigo utilizando inputs inválidos, por ejemplo:
Por último, se puede comprobar el resultado del test mediante pnpm run test --filter=circuits.
Generación de archivos
Para obtener el testigo y la prueba en el dispositivo, necesitamos la generación de un archivo binario wasm del circuito y una clave probatoria en formato zkey, que además servirá para la generación del verificador PLONK escrito en Solidity (que utilizaremos para verificar las pruebas desde la aplicación).
El esquema PLONK nos permite producir la clave probatoria sin tener que organizar una ceremonia de configuración confiable (trusted setup ceremony) para obtener valores random que nos permitan establecer un sistema probatorio de conocimiento cero seguro.
Este esquema también necesita de una ceremonia de configuración confiable, en cambio, en vez de ser una específica, se trata de una universal y actualizable.
Puede encontrar más información acerca del esquema PLONK en esta entrada del blog de iden3 y en esta publicación.
En resumen, se pueden utilizar archivos existentes de Perpetual Powers of Tau, en este enlace se listan los de Hermez, y obtener el archivo ptau válido para el número de restricciones que tenga nuestro circuito.
Para generar la clave probatoria también necesitaremos un archivo en formato r1cs (rank-1 constraint system) de nuestro circuito.
Tanto el archivo en formato wasm, para el cálculo del testigo, como el r1cs, se pueden obtener ejecutando el script pnpm run compile (o pnpm run compile --filter=circuits) que utiliza el compilador de circom con unas opciones para la generación de archivos:
"compile": "mkdir -p build && circom connect_four.circom --r1cs --wasm --sym --json -o build"Este script mostrará el número de restricciones no lineales:
Para saber qué archivo ptau utilizar podemos empezar utilizando el archivo que soporta un número de restricciones compatibles con el número de restricciones no lineales, es decir ptau para la potencia de 12 (2^12 restricciones como máximo), y pasar a intentar obtener tanto de la clave probatoria como del verificador PLONK.
Para ello utilizaremos el script pnpm run generate, el cual invocará en serie los siguientes scripts:
"generate:proving-key": "snarkjs plonk setup build/connect_four.r1cs ptau/powersOfTau28_hez_final_12.ptau build/proving_key.zkey",
"generate:verifier-contract": "snarkjs zkey export solidityverifier build/proving_key.zkey '../contracts/src/PlonkVerifier.sol'"Entiendo que a partir de la versión 0.7.0 de SnarkJS (hasta al menos la actual, es decir, la 0.7.2) se necesita la explicitación de, al menos, una señal pública, sea una entrada pública o la definición de una salida del circuito
main, tras una refactorización deltemplateutilizado para la generación de contratos verificadores en lenguaje Solidity.En caso de no haber ninguna, el argumento con el array de señales públicas en la función
verifyProoftendrá asignada una longitud de cero (Array with zero length specified) por defecto.
Al ejecutar este script obtenemos la siguiente salida:
El archivo ptau elegido de 4096 restricciones se queda corto dado que, como podemos ver arriba sobresaltado, el cómputo hecho en el esquema PLONK para las restricciones da una cuenta de 6261, por lo que utilizaremos la potencia de 13:
Tras editar el script para utilizar el archivo ptau correcto podremos generar con éxito la clave probatoria y el verificador PLONK.
Existe una aplicación web llamada zkREPL que resulta muy útil durante el desarrollo de circuitos dado que nos permite compilar y poner a prueba un circuito en segundos sin instalaciones ni manejo de dependencias.
Contratos inteligentes
Una vez tenemos el contrato verificador, el cual se genera a partir de una plantilla de verificador PLONK de SnarkJS, necesitamos desarrollar un contrato que sirva de enlace entre la aplicación web y el verificador PLONK desplegado.
Es decir, desde nuestro aplicación se llamará a este contrato, el cual a su vez realizará una llamada al verificador. Una vez nuestro contrato obtenga un resultado, emitirá un evento con él, y nuestra interfaz de usuario lo obtendrá para poder mostrarlo.
Como vemos en la función del constructor, a continuación, para poder desplegar este contrato debemos desplegar antes el verificador y pasarle a este contrato su dirección.
De esta manera, cuando necesitemos verificar una prueba nuestro contrato podrá llamar la función verifyProof del verificador, como vemos en la linea 27, para acto seguido emitir un evento con el resultado.
El script de generación del contrato verificador
generate:verifier-contracten el espacio de trabajocircuitsguarda este contrato enapps/contracts/src.
Despliegue de los contratos
Por último, se despliegan los contratos en la red Sepolia con la finalidad de dar funcionalidad a la aplicación web una vez sea desplegada.
Interfaz de usuario
La simulación del juego se ha implementado en lenguaje TypeScript utilizando Next.js y permite disputar una partida de Conecta Cuatro en tres modalidades, usuario contra modelo de IA, modelo de IA contra usuario y usuario contra usuario. En esta sección explicaré como he desarrollado esta interfaz.
Implementación de la lógica del juego
Al elegir en el menú inicial una modalidad de juego se renderiza o bien el componente UserVsAIBoard o bien el componente UserVsUserBoard. Nos centraremos en el componente UserVsAIBoard para explicar la lógica de la aplicación dado que incluye, además, una sesión de inferencia para predecir una acción en el tablero.
Este componente incluye dos hooks, useAI y useGameOver, para tratar los turnos del modelo de IA y para intentar captar el momento en el que finaliza el juego (ya sea en empate o victoria), respectivamente.
export function UserVsAIBoard({
isAIFirst = false
}: {
isAIFirst?: boolean;
}): JSX.Element {
const { isSlow, setIsSlow } = useAI();
useGameOver();
const player1 = isAIFirst ? 'AI' : 'User';
const player2 = !isAIFirst ? 'AI' : 'User';
return (
<div className={styles.container}>
<TurnIndicator
isSlow={isSlow}
player1={player1}
player2={player2}
setIsSlow={setIsSlow}
/>
<Board />
</div>
);
}Además, contamos con el componente Board para visualizar el tablero, el cual también permitirá al usuario formalizar una elección de juego.
El hook useGameOver contiene dos efectos con los que se comprueba, cada vez que cambian sus dependencias, si hay un ganador o si se ha producido un empate.
export function useGameOver(): void {
const { status, board, numCounters, winner, mode } =
useAppSelector(connectFourSelector);
const dispatch = useAppDispatch();
// check for a winner
useEffect(() => {
if (status !== 'GAME_OVER') {
const winner_ = checkWinner(board);
if (winner_ !== null) {
const newStatus: ConnectFourStatus = 'GAME_OVER';
dispatch(setWinner(winner_));
dispatch(setStatus(newStatus));
}
}
}, [status, board, dispatch, mode]);
// check for a draw
useEffect(() => {
if (numCounters === 42 && winner === null) {
const newStatus: ConnectFourStatus = 'GAME_OVER';
dispatch(setStatus(newStatus));
}
}, [numCounters, dispatch, mode, status, winner]);
}En caso de que la partida termine, se actualiza un estado que persiste en la aplicación a través de Redux Toolkit. El estado contiene el tablero, el estado del juego, el modo, el turno, el número de fichas jugadas y el ganador. De esta forma, el estado se puede recuperar en cualquier componente con el hook useAppSelector sin tener que pasar propiedades en cascada (prop drilling).
Durante cada turno del usuario, cada celda del tablero actuará como un botón habilitado; su evento de clic consistirá en validar su posición en el tablero y, en caso de que sea una posición legal para una ficha, se actualizará el tablero en el estado y se cambiará el turno.
Para gestionar la lógica de alternancia en turnos consecutivos, durante el turno de la IA, se invoca la función predict dentro de un efecto del hook useAI, tal como se muestra a continuación:
Esta aplicación hace uso de un modelo ONNX desplegado en la aplicación web, que es el resultado de un ejercicio de aprendizaje por refuerzo en el que se ha entrenado un agente DQN para optimizar una red neuronal, con capas convolucionales a la entrada y totalmente conectadas a la salida, que da conformidad a una política de juego para ambos turnos.
He escrito un cuaderno de Jupyter en el que intento explicar, paso a paso, cómo llevar a cabo esta tarea.
getPrediction proporciona un índice de columna a partir del cual se calcula el índice de fila donde colocar la ficha. Para obtener esta predicción, se utiliza la librería onnxruntime-web, que permite realizar inferencias en tiempo real en el dispositivo utilizando un lenguaje poco común en el campo de la ciencia de datos, como es en este caso TypeScript.
Como vimos en el código de useAI anteriormente, getPrediction es el punto de entrada para crear una sesión de inferencia del modelo. Podemos ver el código de esta función a continuación:
/**
* Gets the game board and returns a prediction of a model for a column index.
* @param board - Connect Four board.
* @returns A prediction of a model for a column index.
*/
export async function getPrediction(
board: ConnectFourBoard
): Promise<number | null> {
// get input tensor
const inputTensor = getBoardTensor(board);
// run model
const output = await runConnectFourModel(inputTensor);
// enforce a valid action
const action = getPredictedValidAction(board, output);
return action;
}Esta función realiza tres tareas: (i) obtener una versión del tablero como un tensor de tipo Float32, (ii) pasarlo a runConnectFourModel para obtener un array con las predicciones del modelo para cada columna y, finalmente, (iii) asegurar mediante la función getPredictedValidAction que se elija la acción válida con la mayor probabilidad de éxito de entre todas las disponibles en el array.
Esta última operación podría evitarse implementando el juego de modo que, si el modelo comete un error y otorga un mayor margen de éxito a un índice de columna donde no caben más fichas, se declare el fin de la partida y el jugador contrario sea el ganador.
En nuestro caso, el juego se ha programado para que se tome la acción válida con la mayor probabilidad entre las predicciones del modelo.
La entrada que admite nuestro modelo tiene las dimensiones [1, 2, 6, 7], que se refieren al tamaño del lote (batch size), el número de canales de la red convolucional, el espacio de observación y el espacio de acción.
Como podemos ver, para obtener el tensor adecuado a partir de la observación de nuestro tablero de dimensiones [6, 7], no solo debemos añadir una dimensión adicional para el número de muestras, sino también otra para el número de canales. El resultado será la transformación del tablero en una matriz binaria doble que indica las posiciones de las fichas de cada jugador.
Por ejemplo, esta transformación resultaría en convertir el tablero a en los tableros b y c, correspondientes al jugador 1 y al jugador 2, respectivamente:
a
b
c
La lógica de esta transformación en TypeScript se implementa en la función getBoardTensor, que consiste en una serie de iteraciones para la concatenación de todos los valores y su posterior conversión en un tensor con tipo.
Para obtener una inferencia de nuestro modelo ONNX, primero debemos crear la ort.InferenceSession pasando la ruta al modelo y un objeto de configuración de sesión; posteriormente, se realiza la inferencia.
Finalmente, obtenemos el índice de la predicción válida con el valor máximo dentro del array de inferencia del modelo, tal como se muestra a continuación en la función getPredictedValidAction:
/**
* Gets the predicted valid action or null if the board is full.
* @param board - Connect Four board.
* @param output - Array of numbers with the predicted values for each column.
* @returns The predicted valid action or null.
*/
export function getPredictedValidAction(
board: ConnectFourBoard,
output: number[]
): number | null {
// get all valid actions
const validActions = getValidActions(board);
if (validActions === null) {
return null;
}
// get maximum valid value
const maxValidValue = Math.max(...validActions.map((idx) => output[idx]));
// get the index of the maximum valid value
const predictedValidAction = output.indexOf(maxValidValue);
return predictedValidAction;
}Generación y verificación de la prueba
Una vez finalizado el juego, siempre que uno de los jugadores haya resultado victorioso y el usuario esté conectado a la red Sepolia, la aplicación habilitará el botón Verify. Esto dará al usuario la opción de generar y verificar la prueba de conocimiento cero.
El componente que se renderiza al activar dicho botón utiliza tres acciones de la librería Wagmi (prepareWriteContract, writeContract y waitForTransaction) para transferir la prueba y las señales públicas al contrato de verificación.
Al hacer clic en Verify, la máquina de estados activa GENERATING_PROOF para la computación ZK local y, una vez enviada la prueba a la blockchain, pasa a WAITING_FOR_TX. Tras la confirmación, alcanza TX_SUCCESS, lo que inicia la transición a FETCHING_EVENT mientras la aplicación escanea los registros de la transacción en busca de los datos emitidos. El ciclo concluye en VERIFIED para finalizar el resultado del juego o en ERROR si falla algún paso.
El handler del evento de clic genera principalmente el testigo y la prueba de conocimiento cero mediante la función generateProof, devolviendo la prueba y un subconjunto del testigo que solo contiene las señales públicas y la salida. Posteriormente, realiza una transferencia al contrato de verificación adaptando esta información.
La librería SnarkJS nos permite calcular todas las señales públicas y la prueba en la misma función (fullProve), utilizando las entradas del circuito, el archivo wasm y la clave de prueba (proving key); estos dos últimos archivos se generaron previamente durante la fase de generación de archivos.
Finalmente, obtenemos el calldata mediante la función exportSolidityCallData, de la cual extraemos la representación hexadecimal de la prueba y las señales públicas para la transferencia al contrato.
Una vez confirmada la transferencia, procedemos a obtener los eventos emitidos por el contrato:
return (
<>
<Button
disabled={status !== 'IDLE' && status !== 'ERROR'}
onClick={handleVerify} // eslint-disable-line -- allow promise-returning function
title="Generate a zk-SNARK and get a PLONK verifier to attest to its validity"
type="button"
>
Verify
</Button>
{txHash ? <FetchEvent abi={abi} address={address} /> : null}
</>
);Para obtener estos eventos emitidos, he implementado un hook que invoca periódicamente la función para recuperar los eventos del contrato cada pollInterval, siempre que la variable de control shouldStopPolling lo permita.
En nuestro caso, la obtención de eventos se detendrá una vez que se haya obtenido el resultado de la verificación en el evento emitido desde nuestra transacción con la prueba o una vez que haya expirado un periodo de tiempo. La lógica de la cuenta atrás consiste simplemente en un efecto con un setTimeout que se establece en 30 segundos:
useEffect(() => {
if (status === 'FETCHING_EVENT') {
timerId.current = setTimeout(() => {
dispatch(setVerificationStatus('ERROR'));
setIsModalOpen(true);
}, 30000);
}
return () => {
if (timerId.current !== null) {
clearTimeout(timerId.current);
}
};
}, [dispatch, status]);Finalmente, el resultado obtenido del contrato o un mensaje de error se mostrará en una ventana modal:
<Modal
className={modalStyles.modal}
handler={handleClose}
isOpen={isModalOpen}
overlayClassName={modalStyles.overlay}
>
{status === 'ERROR' ? (
<p>
The transaction was successful, but the verification result could not
be retrieved.
</p>
) : (
<>
<p>The proof has been verified successfully.</p>
<p>
A PLONK verifier has attested to the{' '}
<b>{isValid ? 'validity' : 'invalidity'}</b> of the zk-SNARK proof.
</p>
</>
)}
</Modal>Para mejorar la experiencia de usuario, la consecución de toda esta serie de acciones se comunica al usuario de la aplicación mediante mensajes toast en un margen de la pantalla utilizando el hook useToast (disponible en el workspace ui dentro de packages), tal como podemos ver a continuación para el caso de la obtención de eventos.
useToast({
isLoading: status === 'FETCHING_EVENT',
isError: status === 'ERROR' && isModalOpen,
isSuccess: status === 'VERIFIED',
loadingText:
'Retrieving event emitted by the PLONK verifier smart contract...',
successText: 'The event has been succesfully retrieved.',
errorText: 'The event could not be retrieved.'
});API pública
Como se mencionó anteriormente, esta aplicación utiliza un servicio de nodos de Infura para obtener información de la red Sepolia. De este modo, se evitan los errores provocados por la limitación de peticiones (rate limiting) que suelen ocurrir al utilizar únicamente el proveedor público.
Para no filtrar al cliente la clave API, que nos provee Infura, se ha implementado una ruta API como función serverless que nos permite insertar la clave en el lado del servidor.
Para ello se configura un jsonRpcProvider en el contexto de Wagmi cuyo endpoint es esa ruta.
function WagmiProvider({ children }: PropsWithChildren): JSX.Element {
const { chains, publicClient, webSocketPublicClient } = configureChains(
[sepolia, localhost],
[
jsonRpcProvider({
rpc: (chain) => {
if (chain.id === sepolia.id) {
return { http: `/api/sepolia` };
}
return null;
}
}),
publicProvider()
]
);
const config = createConfig({
autoConnect: true,
connectors: [new MetaMaskConnector({ chains })],
publicClient,
webSocketPublicClient
});
return <WagmiConfig config={config}>{children}</WagmiConfig>;
}Además, tambien se ha editado la configuración de seguridad de la clave mediante el uso de allowlists para elaborar una lista en la que solo se concede acceso al servicio de Infura mediante esta clave si la llamada es a la dirección del contrato.
Cada vez que la aplicación realiza una consulta a la red Sepolia, la petición del jsonRpcProvider es capturada por un handler en nuestra ruta de API. Este handler envía entonces una nueva petición al endpoint de Infura, incorporando esta vez la clave PROJECT_ID.
Finalmente, el handler de la ruta de la API devuelve la respuesta del proveedor de Infura a la petición realizada inicialmente por la librería Viem (que Wagmi utiliza internamente).
Ciclo de vida de la transacción
Hemos analizado la secuencia de peticiones JSON-RPC generadas por las acciones de @wagmi/core prepareWriteContract, writeContract, waitForTransaction, y el método getContractEvents del publicClient de Viem.
Se trata de un flujo determinista que garantiza que la transacción se ha realizado antes de recuperar los registros. La siguiente imagen muestra las diez peticiones iniciadas durante el proceso de verificación de una partida de Conecta Cuatro:
Al observar las peticiones en el navegador, podemos confirmar que la URL a la que se dirigen es la ruta de API configurada previamente.
Fase 1: Simulación previa
Antes de emitir la transacción, el cliente realiza una simulación para asegurar que la llamada a la función verifyProof es válida y para determinar los límites de gas precisos.
eth_call: la llamada inicial simula la ejecución del contrato para validar los parámetros.
eth_call: la llamada posterior se utiliza específicamente para la estimación de gas, asegurando que la transacción no sea revertida debido a límites de gas insuficientes.
eth_blockNumber: recupera el número de bloque más reciente para establecer el punto de referencia para las simulaciones.
Fase 2: Monitorización de la transacción
Una vez que el hash de la transacción es generado por writeContract, waitForTransaction inicia una secuencia de monitorización para rastrear el estado de la transacción desde el mempool hasta su inclusión en un bloque.
eth_getTransactionByHash: recupera el objeto completo de la transacción para verificar que ha sido enviada correctamente.
eth_getTransactionReceipt: comienza la consulta para el recibo de la transacción. En esta petición inicial, la respuesta devuelvenull, lo que indica que la transacción aún no ha sido incluida en un bloque.
eth_getBlockByNumber: recupera los metadatos del bloque actual para sincronizar el estado del cliente con la red.
Fase 3: Transición de estado y confirmación
El cliente continúa monitoreando el estado de la red para detectar cuándo se ha finalizado la transacción.
eth_blockNumber: esta petición monitoriza las transiciones del número de bloque.
eth_blockNumber: De nuevo, la misma petición pero, como se observa en los logs, el número de bloque avanza de0x9c58f5a0x9c58f6, señalando que un nuevo bloque ha sido minado con éxito.
eth_getTransactionReceipt: tras la transición del número de bloque, esta petición recupera el recibo final. A diferencia de la consulta inicial, esta devuelve un objeto completo que contiene el estado de la transacción y los registros de eventos.
Fase 4: Recuperación de eventos específicos
Con la confirmación de la transacción y el número de bloque exacto identificado en el recibo, la aplicación realiza una consulta final y dirigida de los registros.
eth_getLogs: al especificar elfromBlockytoBlockexactos obtenidos en el paso anterior, el cliente realiza una petición de los eventosProofVerification, los cuales son filtrados para garantizar que los datos devueltos sean específicos de nuestro hash de transacción.
Si utilizamos el método decodeEventLog de Viem, el objeto de result y el ABI del contrato para procesar la respuesta obtenida, y posteriormente imprimimos el nombre del evento y sus argumentos, como podemos ver a continuación, el verificador Plonk confirma la validez de la prueba emitida:
// Decoded ProofVerification Event
Event Name: ProofVerification
Arguments: {
user: '0x37A8b628888c272f6Fb9bE8F1a8eb8e454E0dA1C',
proofHash: '0x3a703e0b16d8386961158e25f64ce384a57e414675558f1dcbf1f7b2f3ec58c4',
success: true,
timestamp: 1770913608n
}
En este momento la aplicación muestra un mensaje con el resultado de la verificación. Si hubieran pasado 30 segundos y no se hubiera obtenido el evento correspondiente a la transacción de la verificación de la prueba, no se habrían realizado más peticiones y se habría mostrado un mensaje informando al usuario.
Despliegue de la aplicación
Se ha desplegado esta aplicación web en Vercel y se puede acceder a ella en este enlace. Si desea aprender a desplegar un monorepositorio de Turborepo en Vercel encontrará información en este artículo.