How to build a zero-knowledge DApp

View on GitHub

Zero-knowledge proofs were first introduced in the year 1985 as a solution to security problems. However, it was not until recently that they aroused special interest in the field of blockchains due to the solution they offer to scalability problems.

Currently, there is an upward trend in the application of this technology in some decentralised applications thanks to the fact that its properties are useful to developers in order to create more secure and private applications.

This post offers an introduction to how to develop an application capable of generating and validating zero-knowledge proofs in the context of the Connect Four game in order to demonstrate to a verifier that a prover has information about the game without having to reveal it.

In this article

Overview

A monorepository with Turborepo that has three main workspaces has been developed in order to create this application.

Firstly, a zk-SNARK circuit developed with the library Circom, secondly, a Hardhat development environment of smart contracts written in Solidity language (which act as verifiers of the proofs) and, lastly, a web application authored in TypeScript and developed with Next.js.

Since the application consists of a two-player game, a reinforcement learning agent has also been introduced for modeling in PyTorch a game policy capable of acting as one of the players.

In the diagram below I try to specify as much as possible the workflow of this application, which I will explain in detail in the course of the following sections.

What is a zk-DApp?

A zk-DApp is a decentralised application with the particularity that it offers the option of generating and validating zero-knowledge proofs. This type of proof responds to a protocol that makes it possible for a verifier to ensure the validity of a statement made by a prover, without the prover having to reveal information that is considered private.

There are different types of zero-knowledge proofs, although in our case we will focus on the type zk-SNARK, acronym for zero-knowledege Succinct Non-interactive ARgument of Knowledge, which has the following properties:

  • zero-knowledge: The verifier has no knowledge of the proof except whether it is valid or not.

  • succinct: the size of the proof is small and therefore its verification time is reduced.

  • non-interactive: does not require interaction between the prover and the verifier beyond a single round of communication.

  • argument of knowledge: proof consisting of a cryptographic assertion that the prover's assertion is valid.

zk-SNARK Circuit

The zk-SNARK proofs can only be generated directly from a finite field F_p arithmetic circuit, i.e., a circuit capable of performing addition and multiplication operations in a finite field of a given size.

Therefore, all operations are calculated modulo a specific value, for example, it is needed in Ethereum to operate with arithmetic circuits F_p taking the prime number:

p = 21888242871839275222246405745257275088548364400416034343698204186575808495617

The conversion of a computational problem to an arithmetic circuit is not always apparent or evident although, in most cases, it is feasible. The main idea is to reduce this problem to a series of equations.

Therefore, in this context we can say that the verifier of the proof will be able to cryptographically verify that the prover knows information that satisfies a system of equations.

In Circom, these equations are called constraints and must only be quadratic equations of the type a * b + c = 0, being a, b andc linear combinations of signals, i.e., a constraint must have only one multiplication and one addition of a constant.

The signals are the inputs and outputs of the circuits, the calculation of which results in the witness vector, therefore any output is also an input to the witness vector. The witness is, then, the set of circuit signals (input, intermediate and output) and is used to generate the proof.

In short, Circom will help us create a system of restrictions. These constraints are called Rank 1 constraints (R1CS).

The circuit will have two main tasks, on the one hand, (i) generate the proof, for which the restrictions are introduced and, on the other hand, (ii) calculate the witness, for which the intermediate signals and the output are resolved.

As each restriction or equation must have a multiplication, implementing the problems in this way is somewhat complex, so much so that sometimes auxiliary signals are required that may or may not be restricted but to which a value must be assigned.

In Circom it is considered dangerous to assign a value to a signal without generating a constraint to it, since a system can be underconstrained and solve tests that are not well designed, so normally both will be done unless it is an expression that cannot be included in a restriction.

For this reason, Circom contains operators for signal assignment, for the generation of constraints, and operators that perform both actions simultaneously:

  • If we wish to write an equation that only assigns the result of a multiplication to a signal, we use c <-- a * b.

  • If we only want to restrict the signal c to be the result of the multiplication of a and b, we use the expression c === a * b.

  • Finally, to perform both actions at once, we use c <== a * b, which computes the multiplication, assigns the result, and imposes the constraint on c.

Next, I will introduce the Circom circuit for generating zero-knowledge proofs regarding a specific game of Connect Four.

Circuit implementation

Our circuit is designed to generate the witness and the proof for a game that has already concluded with a victory for one of the players.

Through this circuit, the prover can demonstrate to the verifier that they know the winner and the winning combination without revealing this specific information.

The circuit must satisfy the following purposes:

  1. Cell value integrity: ensure that each of the cells on the board contains the value 0, 1, or 2.

  2. Winner validation: ensure that the provided winner signal is either 1 or 2.

  3. Pre-existing win prevention: ensure there were no winning lines before the last move was played.

  4. Valid piece count (turn order): ensure player 1 has either the same number of pieces as player 2 or exactly one more.

  5. Winning coordinates verification: ensure the four provided coordinates are not out of bounds and contain the winner.

  6. Straight line continuity: ensure the four provided coordinates form a continuous straight line.

  7. Gravity check (no flying pieces): ensure there are no flying pieces on the board.

  8. Last move validation: ensure the last move's coordinates are not out of bounds and contain the winner.

  9. Final piece placement integrity: ensure the last piece placed has no pieces right above it.

  10. State commitment: ensure the proof is uniquely tied to the specific game board by compressing the 6x7 grid into a base-3 integer and exposing its Poseidon hash as a public output.

In order to achieve this, the circuit requires four private inputs: the observation of the board, the winner (1 or 2), an array with the indices of the four consecutive counters, and another with the coordinates of the last move.

circuits/connect_four.circom
template ConnectFour() {
    // private input: 2D array representing the 6x7 board state
    signal input board[6][7];
    // private input: identity of the winning player (0 for draw, 1 or 2 for player)
    signal input winner;
    // private input: 4x2 array containing the row/col indices of the winning line
    signal input coordinates[4][2];
    // private input: 1x2 array containing the row/col indices of the last move
    signal input lastMove[2];
    // public output: Poseidon hash of the base-3 packed board for state commitment
    signal output boardHash;
 
    // ...
}
 
component main = ConnectFour();

As we can see, a template is used in Circom to define a generic circuit, within which other templates can be instantiated. However, the main keyword is used to define the entry point or primary component of the circuit.

We will now examine each objective listed above to demonstrate how to program all the constraints.

Firstly, to ensure the cells contain the correct values, we use a for loop to iterate through the value of every cell on the board and instantiate the IsZeroOneOrTwo template at every iteration:

circuits/connect_four.circom
// 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];
    }
}

The equation that solves this problem is the third-degree polynomial (x - 0) * (x - 1) * (x - 2) = 0. However, since Circom only permits quadratic equations, we first assign the result of (x - 0) * (x - 1) to an element of the isZeroOrOne array (which contains 42 elements, one for each cell on the board). We then multiply this value by (x - 2) and constrain the result to zero, thereby ensuring that the cell value in each iteration can only be 0, 1, or 2.

circuits/connect_four.circom
// 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;
    }
}

If we execute the script pnpm run print:constraints we will obtain all the constraints of the circuit, which will help us better understand how the circuit works and debug our implementation.

We will focus on the first two constraints that correspond to the first iteration of the for loop for row = 0 and col = 0, i.e., the check that the cell in the upper left margin of the board equals 0, 1 or 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] ] - [  ] = 0

Considering the finite field of operations in our arithmetic circuit, these equations are equivalent to:

[ 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 ] = 0

As main.board[0][0] equals the input signal in, and doing a simple algebra exercise, we would have the following equations, which correspond to the restrictions of the circuit AssertIsZeroOneOrTwo:

isZeroOrOne = in * (in - 1)
(in - 2) * isZeroOrOne = 0

In a similar fashion, we must verify that the winner input signal is either 1 or 2:

circuits/connect_four.circom
// 2 - winner validation
component isZeroOneOrTwoWV = IsZeroOneOrTwo();
isZeroOneOrTwoWV.in <== winner;

Now, to ensure that there is at least one winning combination (four consecutive counters on the board), we must implement an algorithm that iterates through columns and rows for every possible orientation: horizontal, vertical, diagonal, and anti-diagonal.

For example, for the horizontal orientation, we would use the following implementation:

circuits/connect_four.circom
// 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++;
    }
}

The approach involves looping over the board to identify horizontal lines; therefore, we instantiate the CheckLineAndPreExistingWin template at every iteration, passing the coordinates of the four cells as parameters.

This circuit will (i) assert that all cell values match the winner and (ii) verify whether the coordinates of the last move are within that line. Crucially, if these coordinates do not fall within the line, the inputs are considered invalid; consequently, CheckLineAndPreExistingWin will yield 1 in that case and 0 otherwise.

circuits/connect_four.circom

As shown in line 15 of the previous snippet, we assert and assign to the first available element in the preWinAccumulator signal array the sum of the preceding element and the output of the checkLine component for that iteration: preWinAccumulator[idx + 1] <== preWinAccumulator[idx] + checkLine[idx].isPreExistingWin.

Lastly, once all combinations have been processed, the circuit verifies that there were no winning lines before the last piece was placed:

circuits/connect_four.circom
preWinAcc[idx] === 0;

A fundamental rule of Connect Four is the turn-based sequence; therefore, the circuit enforces that player 1 has either the same number of pieces as player 2 or exactly one more. This logic uses an accumulator (p1Acc and p2Acc) to sum the occurrences of each player's tokens. The final constraint, countDiff * (countDiff - 1) === 0, mathematically ensures the difference is strictly 0 or 1.

circuits/connect_four.circom

Since we have the record of each player's pieces count, we also verify that, if the match is reported to the circuit as having ended in a draw, this is correct.

Next, the circuit implements logic to ensure that the coordinates provided in the input signal are not out of bounds. The algorithm iterates through the board and, when both the column and row indices match those in the coordinates signal array, it assigns 1 to a specific signal array element; otherwise, it assigns 0. Finally, after the iteration is complete, the circuit asserts that the sum of this signal array equals 1; if the coordinates were out of bounds, the sum would be 0.

circuits/connect_four.circom

In addition, if a winner is indicated, in each iteration **it is checked **in lines 21 and 22 that the coordinate corresponds to a piece belonging to that player.

To prevent arbitrary cell selection when there is a winner, the circuit must validate that the four chosen coordinates are not only correct but also form a continuous straight line in one of the permitted directions. By calculating deltaRow and deltaCol between consecutive points, the circuit enforces a constant direction. Furthermore, it constrains these steps to the range {-1, 0, 1} using quadratic equations, ensuring the sequence follows standard Connect Four orientations.

circuits/connect_four.circom

It is also asserted that the coordinates step is correct in case there is a winner.

To simulate physical gravity, we must ensure there are no flying pieces on the board. The following logic iterates through the grid to verify that every piece, unless it is on the bottom row, is supported by another piece directly beneath it. The constraint (1 - isEmpty.out) * isBelowEmpty.out === 0 asserts that if a cell is occupied, the cell below cannot be empty.

circuits/connect_four.circom
// 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;
    }
}

The circuit must explicitly ensure the last move's coordinates are valid, confirming they are not out of bounds and contain the winner. By using IsEqual components to match the lastMove signal against board indices, the circuit verifies the presence of the winner's token at that specific location. Finally, legalLastMoveAcc[42] === 1 confirms that exactly one valid match was found within the board limits.

circuits/connect_four.circom

It is also asserted in lines 32-33 and 35-36 that the last move cell contains a 1 if player 1 won, or a 2 if player 2 won or if the match ended in a draw.

We must also ensure the last piece placed is actually the top-most piece in its column. The circuit checks the cell directly above the lastMove coordinates. By asserting that there are no pieces right above it, the logic guarantees that no subsequent moves were made in that column, proving the integrity of the final game state.

circuits/connect_four.circom

Finally, we must bind the proof to a specific game state to prevent proof malleability or the use of a "fake" board. To maintain data privacy, the board state remains a private input, and instead, the circuit packs the 6x7 grid into a single, unique base-3 integer.

This integer acts as the input for a Poseidon hash function, which generates a commitment exposed as a public output. By asserting this value, the verifier validates that the proof corresponds strictly to the observed board state without revealing the internal cell values or winning coordinates.

circuits/connect_four.circom
// 10 - state commitment
signal boardInOneNumber;
signal sums[43]; 
sums[0] <== 0;
 
var power = 1;
for (var row = 0; row < 6; row++) {
    for (var col = 0; col < 7; col++) {
        var idx = row * 7 + col;
        // use base-3 packing to avoid collision since cell values are 0, 1, 2
        sums[idx + 1] <== sums[idx] + board[row][col] * power;
        power *= 3; 
    }
}
boardInOneNumber <== sums[42];
 
component finalHasher = Poseidon(1);
finalHasher.inputs[0] <== boardInOneNumber;
 
boardHash <== finalHasher.out;

If we were to take a direct approach and hash every cell individually, our code would look like this:

component hasher = Poseidon(42); // 6 * 7 cells
for (var row = 0; row < 6; row++) {
    for (var col = 0; col < 7; col++) {
        hasher.inputs[row * 7 + col] <== board[row][col];
    }
}
boardHash <== hasher.out;

While this logic is straightforward, it is more expensive. Calling Poseidon(42) (one for each cell) creates over 60,000 constraints, forcing the circuit to require a much larger Powers of Tau (217). This significantly increases proof generation latency.

By implementing the base-3 bit packing method, the board grid is compressed via linear arithmetic, resulting in a single hash input. This slashes total constraints by ~90%, allowing the circuit to fit within Powers of Tau 14. This optimization ensures a faster, more accessible execution for the end user.

Circuit testing

Circom also facilitates the library circom_tester which allows us to test the circuit in JavaScript (or TypeScript) language by abstracting the circuit using the function wasmTester:

circuits/connect-four.test.ts
before(async function () {
  circuit = await wasmTester('connect_four.circom');
});

The goal of testing is to ensure that the circuit is successful by calculating a witness from valid inputs (inputs that correspond to the values on the board) and that it is not successful when the inputs, or at least one of them, that are not valid.

For example, to ensure the success of the circuit in passing valid inputs we implement the following code in a Mocha describe and we check the generation of the witness and constraints:

circuits/connect-four.test.ts
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);
});

As we can see, this test should be successful because there is only one winning combination on the board, whose user corresponds to the specified winner as well as its coordinates or array indices.

In addition, a few other tests are implemented in which an attempt is made to generate a witness using invalid inputs, for instance:

circuits/connect-four.test.ts

Finally, the test result can be checked with pnpm run test --filter=circuits.

File generation

To generate the witness and proof on-device, the system requires the circuit WASM binary and the proving key (.zkey). The latter is also used to export the PLONK verifier in Solidity, enabling on-chain proof validation.

The PLONK scheme streamlines the creation of the proving key by eliminating the need for a project-specific trusted setup ceremony to generate the random parameters of the zero-knowledge system.

While this scheme still relies on a trusted configuration, it utilizes a universal and updateable ceremony rather than one restricted to a single circuit.

You can find more information about the PLONK scheme in this post from iden3's blog and in this publication.

In summary, you can use existing Perpetual Powers of Tau files, this link lists those of Hermez, and get the valid ptau file for the number of restrictions that our circuit has.

To generate the proving key, the system also requires the circuit's r1cs (rank-1 constraint system) file. This file represents the system of linear equations that defines the circuit's logic, serving as the mathematical blueprint for the zero-knowledge proof generation process.

Both the file in format wasm, for the calculation of the witness, and the r1cs, can be obtained by running the script pnpm run compile (or pnpm run compile --filter=circuits) which uses the circom compiler with some options for generating files:

"compile": "mkdir -p build && circom connect_four.circom --r1cs --wasm --sym --json -o build"

This script will show the number of nonlinear constraints:

In order to know which ptau file to use we can start by using the file that supports the number of restrictions compatible of nonlinear constraints, i.e., ptau for the power of 13 (2^13 restrictions at most), and proceed to try to obtain both the proving key and the PLONK verifier.

We will use the script pnpm run generate for this, which will invoke the following scripts consecutively:

"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'"

Running this script will show the following output:

The selected ptau file supports up to 8,192 constraints, which is insufficient for the PLONK scheme computation. As previously noted, the circuit yields 9,515 constraints, requiring the use of Powers of Tau 14 (214 = 16,384):

Once the script is updated to use the appropriate ptau file, the system can successfully generate the proving key and the PLONK verifier.

There is a web application called zkREPL which is very useful during circuit development since it allows us to compile and test a circuit in seconds without installations or managing dependencies.

Smart contracts

Once the verifier contract is generated from the SnarkJS PLONK template, the verification result can be retrieved by invoking its public view function verifyProof.

However, we will implement a bridge contract to interface between the web application and the deployed PLONK verifier. This architecture ensures an on-chain trail by logging the proof hash, the sender address and the verification result for every transaction.

That is, our application will call this contract, which in turn will make a call to the verifier. Once our contract gets a result, it will emit an event with it, our UI will get it so it can be displayed.

As we can see in the constructor function, below, in order to deploy this contract we must first deploy the verifier and give this contract its address.

contracts/src/ZKConnectFour.sol

This way, when we need to verify a proof our contract can call the function verifyProof of the verifier, as we can see in line 27, and then emit the event.

The verifier contract generation script generate:verifier-contract in the workspace circuits saves this contract in apps/contracts/src.

Deployment of contracts

Finally, contracts are deployed on the network Sepolia with the purpose of giving functionality to the web application once it is deployed.

User interface

The game simulation has been implemented in TypeScript language using Next.js and allows a user to play a game of Connect Four in three modalities, user against AI model, AI model against user and user against user. In this section I will explain how I have developed this interface.

Game logic implementation

When choosing a game mode in the initial menu, either the component UserVsAIBoard or UserVsUserBoard are rendered. We will focus on the component UserVsAIBoard to explain the logic of the application since it also includes an inference session to predict an action on the board.

This component includes two hooks, useAI and useGameOver, to deal with the turns of the AI model and to try to capture the moment in which the game ends (whether in a draw or victory), respectively.

apps/web/src/components/user-vs-ai-board.tsx
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>
  );
}

Additionally, we have the component Board to display the dashboard, which also allows the user to formalise a game choice.

The hook useGameOver contains two effects which check, every time their dependencies change, whether there is a winner or if the match has resulted in a tie.

apps/web/src/hooks/use-game-over.tsx
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]);
}

In the event that the game ends, a state is updated and persists in the application via Redux Toolkit. This state contains the board, game status, mode, turn, counter count, and the winner. Consequently, the state can be retrieved in any component using the useAppSelector hook, avoiding the need for prop drilling.

During each user turn, every cell on the board functions as an enabled button. The click event validates the position; if it is a legal move, the board state is updated and the turn is switched.

apps/web/src/components/cell.tsx

To manage the logic of alternation in consecutive turns, during the AI's turn, the function predict is invoked within an effect of the useAI hook, as demonstrated below:

apps/web/src/hooks/use-ai.tsx

This application makes use of an ONNX model deployed in the web application, which is the result of a reinforcement learning exercise in which a DQN agent has been trained to optimize a neural network, with convolutional layers at the input and fully connected at the output, that results in a game policy for both turns.

I have written a Jupyter notebook in which I try to explain, step by step, how to carry out this task.

getPrediction provides a column index from which the row index for placing the counter is calculated. To obtain this prediction, the library onnxruntime-web is used, allowing real-time inferences directly on the device using a language uncommon in data science, such as TypeScript.

As shown in the useAI code above, getPrediction serves as the entry point for creating a model inference session. The implementation of this function is provided below:

apps/web/src/utils/get-prediction.ts
/**
 * 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;
}

This function performs three tasks: (i) obtaining a version of the board as a Float32 typed tensor, (ii) passing it to runConnectFourModel to retrieve an array of model predictions for each column, and (iii) ensuring via getPredictedValidAction that the valid action with the highest probability of success is selected.

This final operation could be bypassed by implementing the game such that if the model predicts a full column, the game ends and the opponent is declared the winner.

In our implementation, the game is coded to select the valid action with the highest probability among the model's predictions.

The model's input dimensions are [1, 2, 6, 7], representing the batch size, the number of channels for the convolutional network, the observation space, and the action space.

To derive the appropriate tensor from a [6, 7] board, we must add dimensions for both the sample count and the number of channels. The result is the transformation of the board into a double binary matrix indicating each player's positions.

For example, this transformation converts board a into boards b and c, corresponding to player 1 and player 2:

a

b

c

The logic for this transformation in TypeScript is implemented in the getBoardTensor function, which consists of a series of iterations to concatenate all values and subsequently convert them into a typed tensor.

apps/web/src/utils/get-prediction.ts

To obtain an inference from our ONNX model, we must first create the ort.InferenceSession by passing the route to the model and a session configuration object; subsequently, the inference is performed.

apps/web/src/utils/get-prediction.ts

Finally, we obtain the index of the valid prediction with the maximum value from the model inference array, as demonstrated below in the getPredictedValidAction function:

apps/web/src/utils/get-valid-actions.ts
/**
 * 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;
}

Proof generation and verification

Once the game is over, provided a player has emerged victorious and the user is connected to the Sepolia network, the application will enable the Verify button. This grants the user the option to generate and verify the zero-knowledge proof.

The component rendered when this button is enabled utilises three actions from the Wagmilibrary (prepareWriteContract, writeContract, and waitForTransaction) to transfer the proof and public signals to the verification contract.

apps/web/src/components/enabled-verify-button.tsx

Upon clicking Verify, the state machine triggers GENERATING_PROOF for local ZK-computation, then moves to WAITING_FOR_TX once the proof is submitted to the blockchain. Upon confirmation, it hits TX_SUCCESS, prompting the transition to FETCHING_EVENT while the app scans the transaction logs for emitted data. The cycle concludes in VERIFIED to finalise the game result or ERROR if any step fails.

The click event handler primarily generates the witness and the zero-knowledge proof using the generateProof function, returning the proof and a subset of the witness containing only public signals and the output. Subsequently, it executes a transfer to the verification contract with an adaptation of this data.

The SnarkJS library allows us to calculate all public signals and the proof within the same function (fullProve), utilising the circuit inputs, the wasm file, and the proving key—both previously generated during the file generation stage.

Finally, we retrieve the calldata using the exportSolidityCallData function, which provides the hexadecimal representation of the proof and public signals for the contract transfer.

apps/web/src/utils/generate-proof.ts

Once the transfer has been confirmed, we proceed to retrieve the events emitted by the contract:

apps/web/src/components/enabled-verify-button.tsx
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}
  </>
);

To retrieve these emitted events, I have implemented a hook that periodically invokes the function to fetch contract events every pollInterval, provided the shouldStopPolling control variable allows it.

apps/web/src/hooks/use-event.ts

In our case, the fetching of events will stop once the verification result has been obtained from the event emitted by our transaction with the proof, or once a timeout period has expired. The countdown logic simply consists of an effect with a setTimeout set to 30 seconds:

apps/web/src/components/fetch-event.ts
useEffect(() => {
  if (status === 'FETCHING_EVENT') {
    timerId.current = setTimeout(() => {
      dispatch(setVerificationStatus('ERROR'));
      setIsModalOpen(true);
    }, 30000);
  }
  return () => {
    if (timerId.current !== null) {
      clearTimeout(timerId.current);
    }
  };
}, [dispatch, status]);

Finally, the result obtained from the contract or an error message will be displayed in a modal window:

apps/web/src/components/fetch-event.ts
<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>

To improve the user experience, the completion of this series of actions is communicated to the user via toast messages at the edge of the screen. This is implemented using the useToast hook (found in the ui workspace within packages), as demonstrated below in the event retrieval case.

apps/web/src/components/fetch-event.ts
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.'
});

Public API

As mentioned previously, this application utilises an Infura node service to retrieve information from the Sepolia network. This avoids errors caused by rate limiting that often occur when relying solely on public providers.

In order not to leak to the client the API key, which Infura provides, an API route as a serverless function has been implemented which allows us to insert the key on the server side.

For this, a jsonRpcProvider is set in the context of Wagmi whose endpoint is that route.

apps/web/src/components/providers.tsx
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>;
}

Additionally, the key security settings have also been edited by using allowlists to create a list in which access to the Infura service is only granted using this key if the call is to the contract address.

Every time the application makes a query to the Sepolia network, the jsonRpcProvider request is intercepted by a handler in our API route. This handler then forwards a new request to the Infura endpoint, including the PROJECT_ID key for authentication.

apps/web/src/pages/api/sepolia.ts

Finally, the API route handler returns the response from the Infura provider to the request initially issued by the Viem library (which Wagmi uses behind the scenes).

Transaction lifecycle

We analysed the sequence of JSON-RPC requests generated by the @wagmi/core actions (prepareWriteContract, writeContract, waitForTransaction) and the Viem publicClient getContractEvents method.

This is a deterministic flow that ensures the transaction is finalised before retrieving logs. The following image shows the ten requests initiated during the verification process of a Connect Four match:

Looking at the requests in the browser, we can confirm that the URL to which they are directed is the API route configured previously.

Phase 1: Pre-flight Simulation

Before broadcasting the transaction, the client performs a simulation to ensure the verifyProof function call is valid and to determine accurate gas limits.

  • eth_call: the initial call simulates the contract execution to validate parameters.
  • eth_call: the subsequent call is used specifically for gas estimation, ensuring the transaction does not revert due to insufficient gas limits.
  • eth_blockNumber: retrieves the most recent block number to establish the reference point for the simulations.

Phase 2: Transaction Monitoring

Once the transaction hash is generated by writeContract, waitForTransaction initiates a monitoring sequence to track the transaction's status from the mempool to inclusion in a block.

  • eth_getTransactionByHash: retrieves the full transaction object to verify it has been successfully submitted.
  • eth_getTransactionReceipt: polling begins for the transaction receipt. In this initial request, the response returns null, indicating the transaction has not yet been included in a block.
  • eth_getBlockByNumber: fetches metadata for the current block to synchronise the client state with the network.

Phase 3: State Transition & Confirmation

The client continues to monitor the network state to detect when the transaction is finalised.

  • eth_blockNumber: This request monitors block number transitions.
  • eth_blockNumber: This repeated request reveals the block number advancing from 0x9c58f5 to 0x9c58f6, signalling that a new block has been successfully mined.
  • eth_getTransactionReceipt: following the block number transition, this request retrieves the final receipt. Unlike the initial poll, this returns a comprehensive object containing the transaction status and the raw event logs.

Phase 4: Targeted Event Retrieval

With the confirmation of the transaction and the exact block number identified in the receipt, the application performs a final, targeted query for the logs.

  • eth_getLogs: by specifying the exact fromBlock and toBlock retrieved in the previous step, the client performs a request for ProofVerification events which are filtered to ensure that the returned data is specific to our transaction hash.

If we use Viem's decodeEventLog along with the result object and the contract's ABI to parse the response, and we log the event name and arguments, as we can see below, the Plonk verifier confirms the validity of the emitted proof:

// Decoded ProofVerification Event
Event Name: ProofVerification
Arguments: {
  user: '0x37A8b628888c272f6Fb9bE8F1a8eb8e454E0dA1C',
  proofHash: '0x3a703e0b16d8386961158e25f64ce384a57e414675558f1dcbf1f7b2f3ec58c4',
  success: true,
  timestamp: 1770913608n
}

At this point, the application displays a message with the verification result. If 30 seconds had passed and the event corresponding to the proof verification transaction had not been obtained, no further requests would have been made and a message would have been displayed informing the user.

Application deployment

This web application has been deployed in Vercel and can be accessed at this link. If you want to learn how to deploy a Turborepo monorepository in Vercel you will find information in this article.


Related posts

Traceability

Introducing Olive Oil Trust

Introduction to a series of posts about Olive Oil Trust

Traceability

Introducing Olive Oil Trust: smart contracts

Olive Oil Trust smart contracts are implemented in order to adopt a set of rules...

DeFi

How to build a DEXs analytics application

Introduction to obtaining and representing DEXs data using TheGraph and React.js

Microservices

Approach to a microservices-based architecture bank application

A microservices-based architecture bank application that includes back-end and front-end applications, as well as a...

DeFi

End-to-end guide to creating an ERC-20 token-specific DApp

Decentralized application to operate with a mintable ERC-20 token

Traceability

Introducing Olive Oil Trust: front end

Next.js application that gives support to members and customers in Olive Oil Trust, and reduces...


Ready to #buidl?

Are you interested in Web3 or the synergies between blockchain technology, artificial intelligence and zero knowledge?. Then, do not hesitate to contact me by e-mail or on my LinkedIn profile. You can also find me on GitHub.