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 theturnCountis 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
PublicKeydirectly because it occupies two fields. By hashing thePublicKey, 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
makeGuessto the Code Breaker andgiveClueto 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
secretCombinationas 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
giveCluemethod, the entered private secret combination is salted, hashed, and compared against thesolutionHashstored 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 of4523.
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, or2, 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 likeField,UInt8to0, and theBooltype toBool(false), as it's a wrapper around a field with a value of0.Note that you cannot pass arguments to the
initmethod of aSmartContract.
There are three variations for initializing a zkApp:
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.
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 callingsuper.init()to set all state variables to0.Then, set the specific state variables to constant values, such as
Field(10)orBool(true).Example:
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 to0.Use the method's parameters to set specific state variables based on the caller's input.
Example:
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
1and2, theinit()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.Since the custom initialization method can be called by anyone at any time, refer to the Security Considerations to ensure it is implemented securely.
In the light of these, method initGame will be like this:
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:
unseparatedSecretCombinationand asalt.The
unseparatedSecretCombinationis split into an array of fields representing the four digits. An error is thrown if the number is not in the range of1000to9000.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
PublicKeyis hashed and stored on-chain ascodemasterIdonce the combination is validated.Finally, the
turnCountis incremented, signaling that the game is ready for the code breaker tomakeGuess.The first user to call this method with valid inputs will be designated as the code master.
Note: For simplicity, security checks in this method have been abstracted. For more details, please refer to the Security Considerations.
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:
makeGuess
Before all, a break for explanation of Provable.If API.
Provable.iffunctions similarly to the ternary operator in JavaScript, allowing you to choose between two values based on aBooleancondition.In the example below,
Provable.ifselectsField(10)if theBoolcondition istrue, andField(15)if it'sfalse:The arms of
Provable.ifcan be any value or function that returns a value.Unlike JavaScript's conditional statements,
Provable.ifdoes not bypass the execution of one branch based on the condition. Due to the deterministic nature of zk-SNARK circuits,Provable.ifevaluates both branches but only returns the value corresponding to theBoolcondition.Caution: Since both branches are always executed, if either branch contains code that throws an error,
Provable.ifwill trigger that error. This behavior contrasts with traditional JavaScript control flow, where only the executed branch of anif/elsestatement is evaluated.
For more details, refer to the Control Flow section in the o1js documentation.
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
turnCountstate isodd. 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
PublicKeyhash or fetch the registered code breaker ID.
Once the
makeGuessmethod 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
turnCountis incremented. This then awaits the code master to read the guess and provide a clue.
giveClue
Similar to the
makeGuessmethod, 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
turnCountis non-zero (to avoid colliding with thecreateGamemethod 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
unseparatedSecretCombinationinput is separated into 4 digits, hashed along with the salt, and asserted against thesolutionHashstate 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 theisSolvedstate is set toBool(true).The clue is then serialized into
42-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
turnCountis 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.
Don't forget to import Poseidon from o1js.
As you can see, there are two other util functions:
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.
Last updated