@navigators-exploration-team/mina-mastermind
Version:
[](https://github.com/navigators-exploration-team/recursive-mastermind-zkApp/actions/workflows/ci.yml)
483 lines • 23.7 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
import { Field, SmartContract, state, State, method, Poseidon, AccountUpdate, UInt64, PublicKey, UInt32, Permissions, Struct, Provable, Bool, UInt8, } from 'o1js';
import { Combination, Clue, GameState } from './utils.js';
import { StepProgramProof } from './stepProgram.js';
import { GAME_FEE, MAX_ATTEMPTS, PER_TURN_GAME_DURATION, REFEREE_PUBKEY, } from './constants.js';
export { NewGameEvent, GameAcceptEvent, RewardClaimEvent, ForfeitGameEvent, ProofSubmissionEvent, MastermindZkApp, };
class NewGameEvent extends Struct({
codeMasterPubKey: PublicKey,
rewardAmount: UInt64,
}) {
}
class GameAcceptEvent extends Struct({
codeBreakerPubKey: PublicKey,
finalizeSlot: UInt32,
}) {
}
class RewardClaimEvent extends Struct({
claimer: PublicKey,
}) {
}
class ForfeitGameEvent extends Struct({
playerPubKey: PublicKey,
}) {
}
class ProofSubmissionEvent extends Struct({
turnCount: UInt8,
isSolved: Bool,
}) {
}
class MastermindZkApp extends SmartContract {
constructor() {
super(...arguments);
/**
* `compressedState` is the compressed state variable that stores the following game states:
* - `rewardAmount`: The amount of tokens to be rewarded to the codeBreaker upon solving the game.
* - `finalizeSlot`: The slot at which the game will be finalized.
* - `turnCount`: The current turn count of the game.
* - `isSolved`: A boolean indicating whether the game has been solved or not.
* - `lastPlayedSlot`: The slot number at which the most recent move in the game was played.
*/
this.compressedState = State();
/**
* `codeMasterId` is the ID of the codeMaster `Hash(PubKey)` who created the game.
*/
this.codeMasterId = State();
/**
* `codeBreakerId` is the ID of the codeBreaker `Hash(PubKey)` who accepted the game.
*/
this.codeBreakerId = State();
/**
* `solutionHash` is the hash of the secret combination, salt and the zkApp address.
*/
this.solutionHash = State();
/**
* `packedGuessHistory` is the compressed state variable that stores the history of guesses made by the code breaker.
*/
this.packedGuessHistory = State();
/**
* `packedClueHistory` is the compressed state variable that stores the history of clues given by the code master.
*/
this.packedClueHistory = State();
this.events = {
newGame: NewGameEvent,
gameAccepted: GameAcceptEvent,
rewardClaimed: RewardClaimEvent,
gameForfeited: ForfeitGameEvent,
proofSubmitted: ProofSubmissionEvent,
};
}
/**
* Asserts that the game is still ongoing. For internal use only.
*/
async assertGameInProgress(rewardAmount, finalizeSlot, isSolved) {
const codeBreakerId = this.codeBreakerId.getAndRequireEquals();
// When reward claimed, finalizeSlot is set to 0, but codeBreakerId is not
finalizeSlot
.equals(UInt32.zero)
.and(codeBreakerId.equals(Field.from(0)).not())
.assertFalse('The game has already been finalized and the reward has been claimed!');
// When forfeit win is called, rewardAmount is set to 0, but finalizeSlot is not
finalizeSlot
.equals(UInt32.zero)
.not()
.and(rewardAmount.equals(UInt64.zero))
.assertFalse('Forfeit win has been called!');
// If the game has not been accepted by the codeBreaker yet, codeBreakerId is 0
codeBreakerId
.equals(Field.from(0))
.assertFalse('The game has not been accepted by the codeBreaker yet!');
const currentSlot = this.network.globalSlotSinceGenesis.get();
this.network.globalSlotSinceGenesis.requireBetween(currentSlot, currentSlot.add(UInt32.from(1)));
currentSlot.assertLessThan(finalizeSlot, 'The game has already been finalized!');
isSolved.assertFalse('The game secret has already been solved!');
return currentSlot;
}
async deploy() {
await super.deploy();
this.account.permissions.set({
...Permissions.default(),
setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(),
setPermissions: Permissions.impossible(),
send: Permissions.proof(),
});
}
/**
* Initializes the game, sets the secret combination and reward amount.
* @param secretCombination The secret combination to be solved by the codeBreaker.
* @param salt The salt to be used in the hash function to prevent pre-image attacks.
* @param rewardAmount The amount of tokens to be rewarded to the codeBreaker upon solving the game.
*/
async initGame(secretCombination, salt, rewardAmount) {
const isInitialized = this.account.provedState.getAndRequireEquals();
isInitialized.assertFalse('The game has already been initialized!');
super.init();
secretCombination.validate();
rewardAmount.assertGreaterThanOrEqual(UInt64.from(1e10), 'The reward amount must be greater than or equal to 10 MINA!');
const codeMasterPubKey = this.sender.getUnconstrained();
const codeMasterUpdate = AccountUpdate.createSigned(codeMasterPubKey);
// Deposit the reward amount to the contract.
codeMasterUpdate.send({ to: this.address, amount: rewardAmount });
// Pay the game fee to the referee.
// The fee is 1 MINA less than code breaker fee to balance the account creation fee paid by the code master.
codeMasterUpdate.send({
to: REFEREE_PUBKEY,
amount: GAME_FEE.sub(UInt64.from(1e9)),
});
const gameState = new GameState({
rewardAmount,
finalizeSlot: UInt32.from(0),
turnCount: UInt8.from(1),
lastPlayedSlot: UInt32.from(0),
isSolved: Bool(false),
});
this.solutionHash.set(Poseidon.hash([
...secretCombination.digits,
salt,
...this.address.toFields(),
]));
this.codeMasterId.set(Poseidon.hash(codeMasterPubKey.toFields()));
this.compressedState.set(gameState.pack());
this.emitEvent('newGame', new NewGameEvent({
codeMasterPubKey,
rewardAmount,
}));
}
/**
* Code breaker accepts the game and pays the reward to contract.
*
* @throws If the game has not been created yet, or if the game has already been accepted by the code breaker.
*/
async acceptGame() {
const { rewardAmount, turnCount } = GameState.unpack(this.compressedState.getAndRequireEquals());
turnCount.assertEquals(1, 'The game has not been created yet!');
this.codeBreakerId
.getAndRequireEquals()
.assertEquals(Field.from(0), 'The game has already been accepted by the codeBreaker!');
rewardAmount.assertGreaterThanOrEqual(UInt64.from(1e10), 'Code master reimbursement is already claimed!');
const sender = this.sender.getUnconstrained();
const codeBreakerUpdate = AccountUpdate.createSigned(sender);
// Deposit the reward amount to the contract.
codeBreakerUpdate.send({ to: this.address, amount: rewardAmount });
// Pay the game fee to the referee.
codeBreakerUpdate.send({
to: REFEREE_PUBKEY,
amount: GAME_FEE,
});
const currentSlot = this.network.globalSlotSinceGenesis.get();
this.network.globalSlotSinceGenesis.requireBetween(currentSlot, currentSlot.add(UInt32.from(1)));
const finalizeSlot = currentSlot.add(UInt32.from(MAX_ATTEMPTS * 2)
.mul(PER_TURN_GAME_DURATION)
.add(1));
const gameState = new GameState({
rewardAmount: rewardAmount.add(rewardAmount),
finalizeSlot,
lastPlayedSlot: currentSlot,
turnCount,
isSolved: Bool(false),
});
const codeBreakerId = Poseidon.hash(sender.toFields());
codeBreakerId.assertNotEquals(this.codeMasterId.getAndRequireEquals(), 'Code master cannot be the code breaker!');
this.codeBreakerId.set(codeBreakerId);
this.compressedState.set(gameState.pack());
this.emitEvent('gameAccepted', new GameAcceptEvent({
codeBreakerPubKey: sender,
finalizeSlot,
}));
}
/**
* Submits a proof to on-chain that includes the all game steps and the final solution if the game is solved.
*
* @param proof The proof generated by using `StepProgramProof` zkProgram.
* @param winnerPubKey The public key of the winner.
*
* @throws If the game has not been accepted yet, or if the game has already been finalized.
*/
async submitGameProof(proof, winnerPubKey) {
proof.verify();
const codeMasterId = this.codeMasterId.getAndRequireEquals();
const codeBreakerId = this.codeBreakerId.getAndRequireEquals();
let { rewardAmount, finalizeSlot, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals());
const lastPlayedSlot = await this.assertGameInProgress(rewardAmount, finalizeSlot, isSolved);
proof.publicOutput.codeBreakerId.assertEquals(codeBreakerId, 'The code breaker ID is not same as the one stored on-chain!');
proof.publicOutput.codeMasterId.assertEquals(codeMasterId, 'The code master ID is not same as the one stored on-chain!');
proof.publicOutput.solutionHash.assertEquals(this.solutionHash.getAndRequireEquals(), 'The solution hash is not same as the one stored on-chain!');
proof.publicOutput.turnCount.assertGreaterThan(turnCount, 'Cannot submit a proof for a previous turn!');
const clue = Clue.decompress(proof.publicOutput.lastcompressedClue);
// to classify game is solved:
// - clue must be true
// - turnCount should <= MAX_ATTEMPTS * 2 + 1
isSolved = clue
.isSolved()
.and(proof.publicOutput.turnCount.lessThan((MAX_ATTEMPTS + 1) * 2));
const winnerId = Poseidon.hash(winnerPubKey.toFields());
const isCodeMaster = codeMasterId.equals(winnerId);
const isCodeBreaker = codeBreakerId.equals(winnerId);
// to classify game is not solved within MAX_ATTEMPTS
// - clue must be false
// - turnCount should > MAX_ATTEMPTS * 2
const codeMasterWinByMaxAttempts = isSolved
.not()
.and(proof.publicOutput.turnCount.greaterThan(MAX_ATTEMPTS * 2));
const codeBreakerWin = isSolved;
const shouldSendReward = isCodeMaster
.and(codeMasterWinByMaxAttempts)
.or(isCodeBreaker.and(codeBreakerWin));
const recipient = AccountUpdate.createIf(shouldSendReward, winnerPubKey);
const amountToSend = Provable.if(shouldSendReward, rewardAmount, UInt64.zero);
this.send({ to: recipient, amount: amountToSend });
const gameState = new GameState({
rewardAmount: Provable.if(shouldSendReward, UInt64.zero, rewardAmount),
finalizeSlot: Provable.if(shouldSendReward, UInt32.zero, finalizeSlot),
turnCount: proof.publicOutput.turnCount,
lastPlayedSlot,
isSolved,
});
this.compressedState.set(gameState.pack());
this.packedGuessHistory.set(proof.publicOutput.packedGuessHistory);
this.packedClueHistory.set(proof.publicOutput.packedClueHistory);
this.emitEvent('proofSubmitted', new ProofSubmissionEvent({
turnCount: proof.publicOutput.turnCount,
isSolved,
}));
}
/**
* Allows the winner to claim the reward.
* @throws If the game has not been finalized yet, or if the caller is not the winner.
*/
async claimReward() {
let { rewardAmount, finalizeSlot, lastPlayedSlot, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals());
let isFinalized = this.network.globalSlotSinceGenesis
.getAndRequireEquals()
.greaterThanOrEqual(finalizeSlot);
const claimer = this.sender.getAndRequireSignature();
const claimerId = Poseidon.hash(claimer.toFields());
const codeMasterId = this.codeMasterId.getAndRequireEquals();
const codeBreakerId = this.codeBreakerId.getAndRequireEquals();
const isCodeMaster = codeMasterId.equals(claimerId);
const isCodeBreaker = codeBreakerId.equals(claimerId);
const isCodeMasterLeft = turnCount
.lessThanOrEqual(MAX_ATTEMPTS * 2)
.and(turnCount.value.isEven())
.and(isFinalized)
.and(isSolved.not()); // maybe redundant
const isCodeBreakerLeft = turnCount
.lessThan(MAX_ATTEMPTS * 2)
.and(turnCount.value.isOdd())
.and(isFinalized)
.and(isSolved.not());
// Code Master wins if the game is finalized and the codeBreaker has not solved the secret combination
// Also if game is not accepted by the codeBreaker yet, the finalize slot is remains 0
// So code master can use this method to reimburse the reward before the code breaker accepts the game
const codeMasterWinByFinalize = isSolved.not().and(isFinalized);
// Code Master wins if the codeBreaker has reached the maximum number of attempts without solving the secret combination
const codeMasterWinByMaxAttempts = isSolved
.not()
.and(turnCount.greaterThan(MAX_ATTEMPTS * 2));
const codeBreakerWin = isSolved;
isCodeMaster
.or(isCodeBreaker)
.assertTrue('You are not the codeMaster or codeBreaker of this game!');
const isWinner = isCodeMaster
.and(codeMasterWinByFinalize
.or(codeMasterWinByMaxAttempts)
.or(isCodeBreakerLeft))
.or(isCodeBreaker.and(codeBreakerWin.or(isCodeMasterLeft)));
isWinner.assertTrue('You are not the winner of this game!');
this.send({ to: claimer, amount: rewardAmount });
const gameState = new GameState({
rewardAmount: UInt64.zero,
finalizeSlot: UInt32.zero,
lastPlayedSlot,
turnCount,
isSolved,
});
this.compressedState.set(gameState.pack());
this.emitEvent('rewardClaimed', new RewardClaimEvent({
claimer,
}));
}
/**
* Allows the referee to forfeit the game and send the reward to the player.
* @param playerPubKey The public key of the player to be rewarded.
* @throws If the game has been finalized, if the caller is not the referee, or if the provided public key is not a player in the game.
*/
async forfeitWin(playerPubKey) {
const codeBreakerId = this.codeBreakerId.getAndRequireEquals();
const codeMasterId = this.codeMasterId.getAndRequireEquals();
let { rewardAmount, finalizeSlot, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals());
REFEREE_PUBKEY.equals(this.sender.getAndRequireSignature()).assertTrue('You are not the referee of this game!');
codeBreakerId.assertNotEquals(Field.from(0), 'The game has not been accepted by the codeBreaker yet!');
rewardAmount
.equals(UInt64.zero)
.assertFalse('There is no reward in the pool, the game is already finalized!');
const playerID = Poseidon.hash(playerPubKey.toFields());
const isCodeBreaker = codeBreakerId.equals(playerID);
const isCodeMaster = codeMasterId.equals(playerID);
isCodeBreaker
.or(isCodeMaster)
.assertTrue('The provided public key is not a player in this game!');
this.send({ to: playerPubKey, amount: rewardAmount });
const gameState = new GameState({
rewardAmount: UInt64.zero,
finalizeSlot,
lastPlayedSlot: UInt32.from(0),
turnCount,
isSolved,
});
this.compressedState.set(gameState.pack());
this.emitEvent('gameForfeited', new ForfeitGameEvent({
playerPubKey,
}));
}
/**
* Allows the codeBreaker to make a guess outside `stepProof` and then gives it to the codeMaster to provide a clue.
* @param guessCombination The guess combination made by the codeBreaker.
* @throws If the game has not been accepted yet, or if the game has already been finalized.
* @throws If the game has already been solved, or if the guess is not valid.
*/
async makeGuess(guessCombination) {
let { rewardAmount, finalizeSlot, lastPlayedSlot, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals());
const currentSlot = await this.assertGameInProgress(rewardAmount, finalizeSlot, isSolved);
turnCount.value
.isEven()
.not()
.assertTrue('Please wait for the codeMaster to give you a clue!');
this.codeBreakerId
.getAndRequireEquals()
.assertEquals(Poseidon.hash(this.sender.getAndRequireSignature().toFields()), 'You are not the codeBreaker of this game!');
turnCount.assertLessThan(MAX_ATTEMPTS * 2, 'You have reached the number limit of attempts to solve the secret combination!');
lastPlayedSlot
.add(PER_TURN_GAME_DURATION)
.assertGreaterThanOrEqual(currentSlot, 'You have passed the time limit to make a guess!');
guessCombination.validate();
const packedGuessHistory = Combination.updateHistory(guessCombination, this.packedGuessHistory.getAndRequireEquals(), turnCount.value.sub(1).div(2));
this.packedGuessHistory.set(packedGuessHistory);
const gameState = new GameState({
rewardAmount,
finalizeSlot,
turnCount: turnCount.add(1),
lastPlayedSlot: currentSlot,
isSolved,
});
this.compressedState.set(gameState.pack());
}
/**
* Allows the codeMaster to give a clue to the codeBreaker outside `stepProof`.
* @param secretCombination The secret combination to be solved by the codeBreaker.
* @param salt The salt to be used in the hash function to prevent pre-image attacks.
* @throws If the game has not been accepted yet, or if the game has already been finalized.
* @throws If the game has already been solved, or given secret combination and salt are not valid.
*/
async giveClue(secretCombination, salt) {
let { rewardAmount, finalizeSlot, lastPlayedSlot, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals());
const currentSlot = await this.assertGameInProgress(rewardAmount, finalizeSlot, isSolved);
this.codeMasterId
.getAndRequireEquals()
.assertEquals(Poseidon.hash(this.sender.getAndRequireSignature().toFields()), 'Only the codeMaster of this game is allowed to give clue!');
turnCount.assertLessThanOrEqual(MAX_ATTEMPTS * 2, 'The codeBreaker has finished the number of attempts without solving the secret combination!');
turnCount.value
.isEven()
.assertTrue('Please wait for the codeBreaker to make a guess!');
this.solutionHash
.getAndRequireEquals()
.assertEquals(Poseidon.hash([
...secretCombination.digits,
salt,
...this.address.toFields(),
]), 'The secret combination is not compliant with the stored hash on-chain!');
lastPlayedSlot
.add(PER_TURN_GAME_DURATION)
.assertGreaterThanOrEqual(currentSlot, 'You have passed the time limit to give clue!');
const lastGuess = Combination.getElementFromHistory(this.packedGuessHistory.getAndRequireEquals(), turnCount.div(2).sub(1).value);
const clue = Clue.generateClue(lastGuess.digits, secretCombination.digits);
const packedClueHistory = Clue.updateHistory(clue, this.packedClueHistory.getAndRequireEquals(), turnCount.div(2).sub(1).value);
this.packedClueHistory.set(packedClueHistory);
const gameState = new GameState({
rewardAmount,
finalizeSlot,
turnCount: turnCount.add(1),
lastPlayedSlot: currentSlot,
isSolved: clue.isSolved(),
});
this.compressedState.set(gameState.pack());
}
}
__decorate([
state(Field),
__metadata("design:type", Object)
], MastermindZkApp.prototype, "compressedState", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], MastermindZkApp.prototype, "codeMasterId", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], MastermindZkApp.prototype, "codeBreakerId", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], MastermindZkApp.prototype, "solutionHash", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], MastermindZkApp.prototype, "packedGuessHistory", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], MastermindZkApp.prototype, "packedClueHistory", void 0);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Combination,
Field,
UInt64]),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "initGame", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "acceptGame", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [StepProgramProof,
PublicKey]),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "submitGameProof", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "claimReward", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [PublicKey]),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "forfeitWin", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Combination]),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "makeGuess", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Combination, Field]),
__metadata("design:returntype", Promise)
], MastermindZkApp.prototype, "giveClue", null);
//# sourceMappingURL=Mastermind.js.map