Mina Developer Book
  • What is this book about?
  • Module 1
    • Introduction
    • Why ZK?
    • Mathematics of Zero-Knowledge Proofs
    • OPTIONAL: Advanced Resources
  • Module 2
    • zkFibonacci
    • Mina Protocol
    • o1js
      • SmartContract
      • ZkProgram
      • AccountUpdate
      • Merkle Tree
    • End-to-End zkApp development
      • Mastermind introduction
      • Building the zkApp
      • Testing environment
  • Next steps
  • New to blockchain pack
Powered by GitBook
On this page
  • Mastermind States
  • maxAttempts
  • turnCount
  • codemasterId & codebreakerId
  • solutionHash
  • unseparatedGuess
  • serializedClue
  • isSolved
  • zkApp Methods
  1. Module 2
  2. End-to-End zkApp development

Building the zkApp

Before starting everything else, you should create a project as before. Then, delete the Add.ts and Add.test.ts files to replace them with Mastermind.ts and Mastermind.test.ts.

Mastermind States

The Mastermind zkApp uses all 8 available states. Exceeding this limit would render the zkApp unusable, as it would surpass the maximum storage capacity.

import {state ,State,Field, UInt8, SmartContract,Bool} from "o1js";

export class MastermindZkApp extends SmartContract {
  @state(UInt8) maxAttempts = State<UInt8>();
  @state(UInt8) turnCount = State<UInt8>();
  @state(Field) codemasterId = State<Field>();
  @state(Field) codebreakerId = State<Field>();
  @state(Field) solutionHash = State<Field>();
  @state(Field) unseparatedGuess = State<Field>();
  @state(Field) serializedClue = State<Field>();
  @state(Bool) isSolved = State<Bool>();
}

Let's break down the purpose of each state and discuss the small workarounds used to minimize the number of states stored on-chain.

maxAttempts

  • This state is set during game initialization and is crucial for limiting the number of attempts in the game.

  • Without this state, the game would be biased in favor of the Code Breaker, allowing the game to continue indefinitely until the secret combination is solved.

turnCount

  • This state is essential for tracking game progress. It helps determine when the maximum number of attempts (maxAttempts) has been reached and also identifies whose turn it is to make a move. If the turnCount is even, it's the Code Master's turn to give a clue; if it's odd, it's the Code Breaker's turn to make a guess.

codemasterId & codebreakerId

  • These states represent the unique identifiers of the players, which are stored as the hash of their PublicKey.

  • We avoid storing the PublicKey directly because it occupies two fields. By hashing the PublicKey, we save two storage states, reducing the total required states from four to two.

  • Player identifiers are crucial for correctly associating each method call with the appropriate player, such as linking makeGuess to the Code Breaker and giveClue to the Code Master.

  • Restricting access to methods ensures that only the intended players can interact with the zkApp, preventing intruders from disrupting the 1 vs 1 interactive game.

solutionHash

  • The solution must remain private; otherwise, the game loses its purpose. Therefore, whenever the Code Master provides a clue, they should enter the secretCombination as a method parameter.

  • To maintain the integrity of the solution, the solution is hashed and stored on-chain when the game is first created.

  • Each time the Code Master calls the giveClue method, the entered private secret combination is salted, hashed, and compared against the solutionHash stored on-chain. This process ensures the integrity of the combination and helps prevent side-channel attacks.

  • Note: Unlike player IDs, where hashing is used for data compression, here it is used to preserve the privacy of the on-chain state and to ensure the integrity of the values entered privately with each method call.

unseparatedGuess

  • This state represents the Code Breaker's guess as a single field encoded in decimal.

    • For example, if the guess is 4 5 2 3, this state would be stored as a Field value of 4523.

  • The Code Master will later retrieve this value and separate it into the four individual digits to compare against the solution.

serializedClue

  • This state is a single field representing a clue, which is packed as a serialized value. A clue consists of four digits, each of which can be either 0, 1, or 2, meaning the clue digits fall within the range of a 2-bit number. These digits are combined and stored on-chain as an 8-bit field in decimal.

    • This state demonstrates a bit-serialization technique to compact multiple small field elements into one.

Note: To interpret the clue, the Code Breaker must deserialize and separate the clue digits to meaningfully understand the outcome of their previous guess.

isSolved

This state is a Bool that indicates whether the Code Breaker has successfully uncovered the solution.

It is crucial for determining the end of the game, signaling completion once the Code Breaker achieves 4 hits within the allowed maxAttempts.

Now that we have the states, we need to build methods for game functionality.

zkApp Methods

initGame

Note: The init() method is predefined in the base SmartContract class, similar to a constructor.

  • It is automatically called when you deploy your zkApp with the zkApp CLI for the first time.

  • It is not called during contract upgrades or subsequent deployments.

  • The base init() method initializes provable types like Field, UInt8 to 0, and the Bool type to Bool(false), as it's a wrapper around a field with a value of 0.

  • Note that you cannot pass arguments to the init method of a SmartContract.


There are three variations for initializing a zkApp:

  1. All state initialized as 0 (no state with non-zero value):

    • If you don't need to set any state to a non-zero value, there's no need to override init() or create a custom initialization method.

    • The base init() method will be automatically invoked when the zkApp is first deployed using the zkApp CLI.

  2. Initialize at least one state with a constant value:

    • Override the init() method to initialize your on-chain state with constant values.

    • Include the base init() method's logic by calling super.init() to set all state variables to 0.

    • Then, set the specific state variables to constant values, such as Field(10) or Bool(true).

    • Example:

      class HelloWorld extends SmartContract {
        @state(Field) x = State<Field>();
      
        init() {
          super.init();
          this.x.set(Field(10)); // Set initial state to a constant value
        }
      }

Initialize at least one state with a value dependent on an argument:

  • Create a separate zkApp method with the adequate name

  • Within this method, call super.init() to initialize all state variables to 0.

  • Use the method's parameters to set specific state variables based on the caller's input.

  • Example:

    class HelloWorld extends SmartContract {
      @state(Field) x = State<Field>();
    
      @method async initWorld(myValue: Field) {
        super.init();
        this.x.set(myValue); // Set initial state based on caller's input
      }
    }

Notes:

  • In the Mastermind zkApp, we used the third variation to initialize the game, as it allows the caller to set the value of maxAttempts.

  • In variations 1 and 2, the init() method, whether default or overridden, is automatically executed when the zkApp is deployed. In contrast, the custom init method in the third variation must be called manually to initialize the states.

In the light of these, method initGame will be like this:

  @method async initGame(maxAttempts: UInt8) {
    const isInitialized = this.account.provedState.getAndRequireEquals();
    isInitialized.assertFalse('The game has already been initialized!');

    // Sets your entire state to 0.
    super.init();

    maxAttempts.assertGreaterThanOrEqual(
      UInt8.from(5),
      'The minimum number of attempts allowed is 5!'
    );

    maxAttempts.assertLessThanOrEqual(
      UInt8.from(15),
      'The maximum number of attempts allowed is 15!'
    );

    this.maxAttempts.set(maxAttempts);
  }

Don't forget to import method from o1js.


createGame

  • This method should be called after initializing the game and only once.

  • The method executes successfully when the following conditions are met:

    • The code master provides two arguments: unseparatedSecretCombination and a salt.

    • The unseparatedSecretCombination is split into an array of fields representing the four digits. An error is thrown if the number is not in the range of 1000 to 9000.

    • The separated digits are validated to ensure they are unique and non-zero, with errors thrown if they do not meet these criteria.

    • The secret combination is then hashed with the salt and stored on-chain as solutionHash.

    • The caller's PublicKey is hashed and stored on-chain as codemasterId once the combination is validated.

    • Finally, the turnCount is incremented, signaling that the game is ready for the code breaker to makeGuess.

    • The first user to call this method with valid inputs will be designated as the code master.

  @method async createGame(unseparatedSecretCombination: Field, salt: Field) {
    const isInitialized = this.account.provedState.getAndRequireEquals();
    isInitialized.assertTrue('The game has not been initialized yet!');

    const turnCount = this.turnCount.getAndRequireEquals();

    //! Restrict this method to be only called once at the beginnig of a game
    turnCount.assertEquals(0, 'A mastermind game is already created!');

    //! Separate combination digits and validate
    const secretCombination = separateCombinationDigits(
      unseparatedSecretCombination
    );

    validateCombination(secretCombination);

    // Generate solution hash & store on-chain
    const solutionHash = Poseidon.hash([...secretCombination, salt]);
    this.solutionHash.set(solutionHash);

    // Generate codemaster ID
    const codemasterId = Poseidon.hash(
      this.sender.getAndRequireSignature().toFields()
    );

    // Store codemaster ID on-chain
    this.codemasterId.set(codemasterId);

    // Increment on-chain turnCount
    this.turnCount.set(turnCount.add(1));
  }

There are some undefined functions in the method as you see. They are reusable utility functions. For these functions, create a seperate file called util.ts and put functions in it:

import { Field, Bool, Provable } from 'o1js';

export {
  separateCombinationDigits,
  compressCombinationDigits,
  validateCombination,
};

/**
 * Separates a four-digit Field value into its individual digits.
 *
 * @param combination - The four-digit Field to be separated.
 * @returns An array of four Field digits representing the separated digits.
 *
 * @throws Will throw an error if the combination is not a four-digit number.
 *
 * @note The function first asserts that the input is a valid four-digit Field.
 *       The digits are then witnessed, and their correctness is asserted by re-compressing
 *       them back into the original combination and ensuring equality.
 */
function separateCombinationDigits(combination: Field) {
  // Assert that the combination is a four-digit Field
  const isFourDigit = combination
    .greaterThanOrEqual(1000)
    .and(combination.lessThanOrEqual(9999));
  isFourDigit.assertTrue('The combination must be a four-digit Field!');

  // Witness single digits of the combination
  const digits = Provable.witness(Provable.Array(Field, 4), () => {
    const num = combination.toBigInt();
    return [num / 1000n, (num / 100n) % 10n, (num / 10n) % 10n, num % 10n];
  });

  // Assert the correctness of the witnessed digit separation
  compressCombinationDigits(digits).assertEquals(combination);

  return digits;
}

/**
 * Combines an array of four digits into a single Field value.
 *
 * @note An additional check to ensure that the input has exactly four digits would typically be necessary.
 * However, since this function is primarily used within {@link separateCombinationDigits}, the input is
 * already validated as a four-digit Field array by `Provable.Array(Field, 4)`, which inherently ensures the array has a length of 4.
 *
 * @param combinationDigits - An array of four Field digits.
 * @returns The combined Field element representing the original four-digit number.
 */
function compressCombinationDigits(combinationDigits: Field[]) {
  return combinationDigits[0]
    .mul(1000)
    .add(combinationDigits[1].mul(100))
    .add(combinationDigits[2].mul(10))
    .add(combinationDigits[3]);
}

/**
 * Validates the combination digits to ensure they meet the game rules.
 *
 * @param combinationDigits - An array of four Field digits representing the combination.
 *
 * @throws Will throw an error if any digit (except the first) is 0 or if any digits are not unique.
 *
 * @note The first digit is not checked for 0 because it would reduce the combination to a 3-digit value.
 *       The combination digits are provided by {@link separateCombinationDigits}, which ensures they form
 *       a valid four-digit number.
 */
function validateCombination(combinationDigits: Field[]) {
  for (let i = 1; i < 4; i++) {
    // Ensure the digit is not zero (only for digits 2, 3, and 4)
    combinationDigits[i]
      .equals(0)
      .assertFalse(`Combination digit ${i + 1} should not be zero!`);

    // Ensure the digits are unique
    for (let j = i; j < 4; j++) {
      combinationDigits[i - 1].assertNotEquals(
        combinationDigits[j],
        `Combination digit ${j + 1} is not unique!`
      );
    }
  }
  }

makeGuess

Before all, a break for explanation of Provable.If API.


  • Provable.if functions similarly to the ternary operator in JavaScript, allowing you to choose between two values based on a Boolean condition.

  • In the example below, Provable.if selects Field(10) if the Bool condition is true, and Field(15) if it's false:

    const selector = Bool(true);
    const selected = Provable.if(selector, Field(10), Field(15));
  • The arms of Provable.if can be any value or function that returns a value.

  • Unlike JavaScript's conditional statements, Provable.if does not bypass the execution of one branch based on the condition. Due to the deterministic nature of zk-SNARK circuits, Provable.if evaluates both branches but only returns the value corresponding to the Bool condition.

  • Caution: Since both branches are always executed, if either branch contains code that throws an error, Provable.if will trigger that error. This behavior contrasts with traditional JavaScript control flow, where only the executed branch of an if/else statement is evaluated.


  • This method should be called directly after a game is created or when a clue is given for the previous guess.

  • There are a few restrictions on calling this method to maintain a consistent progression of the game:

    • If the game isSolved, the method can be called, but it will throw an error.

    • If the code breaker exceeds the maxAttempts, the method can be called, but it will throw an error.

    • This method also enforces the correct sequence of player interactions by only allowing the code breaker to make a guess if the turnCount state is odd. If any of these conditions are not met, the method can be called, but it will throw an error.

  • Special handling is required when the method is called for the first time:

    • The first player to call the method and make a guess will be registered as the code breaker for the remainder of the game.

    • The Provable.If API is used to either set the current caller's PublicKey hash or fetch the registered code breaker ID.

  • Once the makeGuess method is called successfully for the first time and a code breaker ID is registered, the method will restrict any caller except the registered one.

  • After all the preceding checks pass, the code breaker's guess combination is validated, stored on-chain, and the turnCount is incremented. This then awaits the code master to read the guess and provide a clue.

  //! Before calling this method the codebreaker should interpret
  //! the codemaster clue beforehand and make a guess
  @method async makeGuess(unseparatedGuess: Field) {
    const isInitialized = this.account.provedState.getAndRequireEquals();
    isInitialized.assertTrue('The game has not been initialized yet!');

    const turnCount = this.turnCount.getAndRequireEquals();

    //! Assert that the secret combination is not solved yet
    this.isSolved
      .getAndRequireEquals()
      .assertFalse('You have already solved the secret combination!');

    //! Only allow codebreaker to call this method following the correct turn sequence
    const isCodebreakerTurn = turnCount.value.isEven().not();
    isCodebreakerTurn.assertTrue(
      'Please wait for the codemaster to give you a clue!'
    );

    //! Assert that the codebreaker has not reached the limit number of attempts
    const maxAttempts = this.maxAttempts.getAndRequireEquals();
    turnCount.assertLessThan(
      maxAttempts.mul(2),
      'You have reached the number limit of attempts to solve the secret combination!'
    );

    // Generate an ID for the caller
    const computedCodebreakerId = Poseidon.hash(
      this.sender.getAndRequireSignature().toFields()
    );

    const setCodeBreakerId = () => {
      this.codebreakerId.set(computedCodebreakerId);
      return computedCodebreakerId;
    };

    //? If first guess ==> set the codebreaker ID
    //? Else           ==> fetch the codebreaker ID
    const isFirstGuess = turnCount.value.equals(1);
    const codebreakerId = Provable.if(
      isFirstGuess,
      setCodeBreakerId(),
      this.codebreakerId.getAndRequireEquals()
    );

    //! Restrict method access solely to the correct codebreaker
    computedCodebreakerId.assertEquals(
      codebreakerId,
      'You are not the codebreaker of this game!'
    );

    //! Separate and validate the guess combination
    const guessDigits = separateCombinationDigits(unseparatedGuess);
    validateCombination(guessDigits);

    // Update the on-chain unseparated guess
    this.unseparatedGuess.set(unseparatedGuess);

    // Increment turnCount and wait for the codemaster to give a clue
    this.turnCount.set(turnCount.add(1));
  }

giveClue

  • Similar to the makeGuess method, there are a few restrictions on calling this method to maintain a consistent progression of the game:

    • The caller is restricted to be only the registered code master ID.

    • The correct sequence is enforced by checking that turnCount is non-zero (to avoid colliding with the createGame method call) and even.

    • If the game isSolved, this method is blocked and cannot be executed.

    • If the code breaker exceeds the maxAttempts, this method is blocked and cannot be executed.

  • After the preceding checks pass, the plain unseparatedSecretCombination input is separated into 4 digits, hashed along with the salt, and asserted against the solutionHash state to ensure the integrity of the secret.

  • Next, the guess from the previous turn is fetched, separated, and compared against the secret combination digits to provide a clue:

    • If the clue results in 4 hits (e.g., 2 2 2 2), the game is marked as solved, and the isSolved state is set to Bool(true).

    • The clue is then serialized into 4 2-bit Fields, packed as an 8-bit field in decimal, and stored on-chain.

    • Note that this technique requires the adversary to deserialize and correctly interpret the digits before making the next guess.

  • Finally, the turnCount is incremented, making it odd and awaiting the code breaker to deserialize and read the clue before making a meaningful guess—assuming the game is not already solved or has not reached the maximum number of attempts.

  @method async giveClue(unseparatedSecretCombination: Field, salt: Field) {
    const isInitialized = this.account.provedState.getAndRequireEquals();
    isInitialized.assertTrue('The game has not been initialized yet!');

    const turnCount = this.turnCount.getAndRequireEquals();

    // Generate codemaster ID
    const computedCodemasterId = Poseidon.hash(
      this.sender.getAndRequireSignature().toFields()
    );

    //! Restrict method access solely to the correct codemaster
    this.codemasterId
      .getAndRequireEquals()
      .assertEquals(
        computedCodemasterId,
        'Only the codemaster of this game is allowed to give clue!'
      );

    //! Assert that the codebreaker has not reached the limit number of attempts
    const maxAttempts = this.maxAttempts.getAndRequireEquals();
    turnCount.assertLessThanOrEqual(
      maxAttempts.mul(2),
      'The codebreaker has finished the number of attempts without solving the secret combination!'
    );

    //! Assert that the secret combination is not solved yet
    this.isSolved
      .getAndRequireEquals()
      .assertFalse(
        'The codebreaker has already solved the secret combination!'
      );

    //! Assert that the turnCount is pair & not zero for the codemaster to call this method
    const isNotFirstTurn = turnCount.value.equals(0).not();
    const isCodemasterTurn = turnCount.value.isEven().and(isNotFirstTurn);
    isCodemasterTurn.assertTrue(
      'Please wait for the codebreaker to make a guess!'
    );

    // Separate the secret combination digits
    const solution = separateCombinationDigits(unseparatedSecretCombination);

    //! Compute solution hash and assert integrity to state on-chain
    const computedSolutionHash = Poseidon.hash([...solution, salt]);
    this.solutionHash
      .getAndRequireEquals()
      .assertEquals(
        computedSolutionHash,
        'The secret combination is not compliant with the stored hash on-chain!'
      );

    // Fetch & separate the on-chain guess
    const unseparatedGuess = this.unseparatedGuess.getAndRequireEquals();
    const guessDigits = separateCombinationDigits(unseparatedGuess);

    // Scan the guess through the solution and return clue result(hit or blow)
    let clue = getClueFromGuess(guessDigits, solution);

    // Check if the guess is correct & update the on-chain state
    let isSolved = checkIfSolved(clue);
    this.isSolved.set(isSolved);

    // Serialize & update the on-chain clue
    const serializedClue = serializeClue(clue);
    this.serializedClue.set(serializedClue);

    // Increment the on-chain turnCount
    this.turnCount.set(turnCount.add(1));
  }
  

Don't forget to import Poseidon from o1js.

As you can see, there are two other util functions:

/**
 * Serializes an array of Field elements representing a clue into a single Field
 * Each clue element is converted to 2 bits and then combined into a single dField.
 *
 * @param clue - An array of 4 Field elements, each representing a part of the clue.
 * @returns - A single Field representing the serialized clue.
 */
function serializeClue(clue: Field[]): Field {
  const clueBits = clue.map((f) => f.toBits(2)).flat();
  const serializedClue = Field.fromBits(clueBits);

  return serializedClue;
}

/**
 * Deserializes a Field into an array of Field elements, each representing a part of the clue.
 * The serialized clue is split into 2-bit segments to retrieve the original clue elements.
 *
 * @note This function is not used within a zkApp itself but is utilized for reading and deserializing
 * on-chain stored data, as well as verifying integrity during integration tests.
 *
 * @param serializedClue - A Field representing the serialized clue.
 * @returns - An array of 4 Field elements representing the deserialized clue.
 */
function deserializeClue(serializedClue: Field): Field[] {
  const bits = serializedClue.toBits(8);
  const clueA = Field.fromBits(bits.slice(0, 2));
  const clueB = Field.fromBits(bits.slice(2, 4));
  const clueC = Field.fromBits(bits.slice(4, 6));
  const clueD = Field.fromBits(bits.slice(6, 8));

  return [clueA, clueB, clueC, clueD];
}

/**
 * Compares the guess with the solution and returns a clue indicating hits and blows.
 * A "hit" is when a guess digit matches a solution digit in both value and position.
 * A "blow" is when a guess digit matches a solution digit in value but not position.
 *
 * @param guess - The array representing the guessed combination.
 * @param solution - The array representing the correct solution.
 * @returns - An array where each element represents the clue for a corresponding guess digit.
 *                           2 indicates a "hit" and 1 indicates a "blow".
 */
function getClueFromGuess(guess: Field[], solution: Field[]) {
  let clue = Array.from({ length: 4 }, () => Field(0));

  for (let i = 0; i < 4; i++) {
    for (let j = 0; j < 4; j++) {
      const isEqual = guess[i].equals(solution[j]).toField();
      if (i === j) {
        clue[i] = clue[i].add(isEqual.mul(2)); // 2 for a hit (correct digit and position)
      } else {
        clue[i] = clue[i].add(isEqual); // 1 for a blow (correct digit, wrong position)
      }
    }
  }

  return clue;
}

/**
 * Determines if the secret combination is solved based on the given clue.
 *
 * @param clue - An array representing the clues for each guess.
 * @returns Returns true if all clues indicate a "hit" (2), meaning the secret is solved.
 */
function checkIfSolved(clue: Field[]) {
  let isSolved = Bool(true);

  for (let i = 0; i < 4; i++) {
    let isHit = clue[i].equals(2);
    isSolved = isSolved.and(isHit);
  }

  return isSolved;
}

Don't forget to export these functions in utils.ts

As said before, deserialization will be needed. deserializeClue method will be used in that manner.

PreviousMastermind introductionNextTesting environment

Last updated 8 months ago

Since the custom initialization method can be called by anyone at any time, refer to the to ensure it is implemented securely.

Note: For simplicity, security checks in this method have been abstracted. For more details, please refer to the .

For more details, refer to the section in the o1js documentation.

Security Considerations
Security Considerations
Control Flow