In the previous article “Building our first Mina zkApp game: Chapter 1”, we created a basic number-guessing game using Mina Protocol’s zkApps. While this was a great starting point, the game has a major security flaw: it’s relatively easy to guess the hidden number within the range of 0–100 by simply hashing all possible numbers. To address this issue, we’ll enhance our game by implementing a commit-reveal scheme, which will make it significantly harder for the guesser to deduce the hidden number.
What we are going to do
Our solution should have the following properties:
- The guesser should not be able to discover the correct number by any means, so the hider must reveal it after the guesser submits their guess.
- The hider should not be able to change their number after selecting it.
To achieve this, we will use a technique called commit-reveal.
A commit-reveal scheme is a cryptographic protocol that allows a user (the hider, in our case) to commit a value without revealing it, and then reveal the value later. This is useful in scenarios where we want to prevent other participants from knowing the committed value until a specific action (like a guess) has been made.
Thus, our user flow will change from hide -> guess to hide -> guess -> reveal.
In the hide phase, the hider will store not just the hash of the number but a hash of two numbers — the hidden number itself and a random number, called a salt. This will conceal our value, even though the hidden number is highly constrained to be within the range [0, 100). Previously, brute-forcing the hash required generating only 100 possible hashes; now, this effort increases to 100 * 2²⁵⁶.
The guess phase remains largely the same as before. The main change is that we can no longer determine whether the guesser guessed the correct value during this phase, so we will simply store the guess on-chain.
Reveal is a new phase, in which the hider will provide the hidden number and the salt. We will then check if the revealed value matches the one that was originally hidden. If it passes this check, we can verify whether the guesser was correct and update the result.
New hide method
We need to create a new struct that will contain both the value and the salt. Also we will define hash function that will return hash of value and salt:
export class HiddenValue extends Struct({
value: Field,
salt: Field,
}) {
hash(): Field {
return Poseidon.hash([this.value, this.salt]);
}
}
Next we will update hide method, so it will accept HiddenValue, and store hash of it:
@method async hideNumber(hiddenValue: HiddenValue) {
let curHiddenNumber = this.hiddenNumber.getAndRequireEquals();
curHiddenNumber.assertEquals(Field(0), 'Number is already hidden');
hiddenValue.value.assertLessThan(
Field(100),
'Value should be less then 100'
);
this.hiddenNumber.set(hiddenValue.hash());
}
New guess method
In guess method we will remove most of the previous logic. It will just store guessed number and hash of sender on-chain in the newly created state fields:
@state(Field) guessedNumber = State<Field>();
@state(Field) guesser = State<Field>();
....
@method async guessNumber(number: Field) {
let curGuessedNumber = this.guessedNumber.getAndRequireEquals();
curGuessedNumber.assertEquals(Field(0), "You have already guessed number");
const sender = this.sender.getAndRequireSignature();
const senderHash = Poseidon.hash(sender.toFields());
this.guessedNumber.set(number);
this.guesser.set(senderHash);
}
New reveal method
Reveal method will check hidden number, increase score for guesser, if he guessed correctly and reset the values of the hidden number and guessed number to zero so that another value can be hidden.
@method async revealNumber(
hiddenValue: HiddenValue,
score: Field,
scoreWitness: MerkleMapWitness
) {
// Check hidden value
let currentHiddenNumber = this.hiddenNumber.getAndRequireEquals();
currentHiddenNumber.assertEquals(
hiddenValue.hash(),
'It is not hidden number'
);
// Check score witness
const [prevScoreRoot, key] = scoreWitness.computeRootAndKeyV2(score);
this.scoreRoot
.getAndRequireEquals()
.assertEquals(prevScoreRoot, 'Wrong score witness');
const guesserHash = this.guesser.getAndRequireEquals();
key.assertEquals(guesserHash, 'Witness for wrong user');
// Check guess
const guessedNumber = this.guessedNumber.getAndRequireEquals();
const scoreDiff = Provable.if(
hiddenValue.value.equals(guessedNumber),
Field(1),
Field(0)
);
const [newScoreRoot] = scoreWitness.computeRootAndKeyV2(
score.add(scoreDiff)
);
this.scoreRoot.set(newScoreRoot);
this.hiddenNumber.set(Field(0));
this.guessedNumber.set(Field(0));
this.guesser.set(Field(0));
}
First, we will check if the provided value matches the one stored on the contract:
let currentHiddenNumber = this.hiddenNumber.getAndRequireEquals();
currentHiddenNumber.assertEquals(
hiddenValue.hash(),
'It is not hidden number'
);
Then we need to check if score witness for guesser is correct:
const [prevScoreRoot, key] = scoreWitness.computeRootAndKeyV2(score);
this.scoreRoot
.getAndRequireEquals()
.assertEquals(prevScoreRoot, 'Wrong score witness');
const guesserHash = this.guesser.getAndRequireEquals();
key.assertEquals(guesserHash, 'Witness for wrong user');
Next we are going to compare hidden number with guessed number. In case they are equal we will add 1 to the guesser score, otherwise we will keep score unchanged:
const guessedNumber = this.guessedNumber.getAndRequireEquals();
const scoreDiff = Provable.if(
hiddenValue.value.equals(guessedNumber),
Field(1),
Field(0)
);
const [newScoreRoot] = scoreWitness.computeRootAndKeyV2(
score.add(scoreDiff)
);
Finally we will update the on-chain values:
this.scoreRoot.set(newScoreRoot);
this.hiddenNumber.set(Field(0));
this.guessedNumber.set(Field(0));
this.guesser.set(Field(0));
Code for that can be found here — GitHub
Test
We will update previous test, to match current zkApp logic. First, we will send HiddenValue struct, instead of value itself:
const hiddenValue = new HiddenValue({
value: hiddenNumber,
salt: Field.random(),
});
let tx = Mina.transaction(senderAccount, async () => {
await zkApp.hideNumber(hiddenValue); // !
});
On guess phase we will send only value without score and its witness:
let tx3 = await Mina.transaction(senderAccount, async () => {
await zkApp.guessNumber(hiddenNumber);
});
And we will call new reveal method:
let tx4 = await Mina.transaction(senderAccount, async () => {
await zkApp.revealNumber(hiddenValue, score, scoreWitness);
});
await tx4.prove();
await tx4.sign([senderKey]).send();
Conclusion
With the commit-reveal scheme in place, our game is now much more secure. The guesser can no longer easily deduce the hidden number, and the hider cannot change their commitment once it’s made. In the next article, we’ll further enhance the game by introducing zk proofs, allowing the hider to give the guesser hints about the hidden number without revealing it directly.