UNPKG

@navigators-exploration-team/mina-mastermind

Version:

[![Integration tests](https://github.com/navigators-exploration-team/recursive-mastermind-zkApp/actions/workflows/ci.yml/badge.svg)](https://github.com/navigators-exploration-team/recursive-mastermind-zkApp/actions/workflows/ci.yml)

435 lines 21.7 kB
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 { MAX_ATTEMPTS, PER_ATTEMPT_GAME_DURATION } 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, maxAttemptsExceeded: 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. */ 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(); /** * `refereeId` is the ID of the referee `Hash(PubKey)` who penalizes misbehaving players. */ this.refereeId = State(); /** * `solutionHash` is the hash of the secret combination and salt. */ 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 assertNotFinalized(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!'); finalizeSlot .equals(UInt32.zero) .or(codeBreakerId.equals(Field.from(0))) .assertFalse('The game has not been accepted by the codeBreaker yet!'); const currentSlot = this.network.globalSlotSinceGenesis.get(); // extend network precondition in case of skipped slots this.network.globalSlotSinceGenesis.requireBetween(currentSlot, finalizeSlot.sub(UInt32.from(1))); currentSlot.assertLessThan(finalizeSlot, 'The game has already been finalized!'); isSolved.assertFalse('The game secret has already been solved!'); return finalizeSlot; } 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, maximum attempts, referee, 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 refereePubKey The public key of the referee who will penalize misbehaving players. * @param rewardAmount The amount of tokens to be rewarded to the codeBreaker upon solving the game. */ async initGame(secretCombination, salt, refereePubKey, 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); codeMasterUpdate.send({ to: this.address, amount: rewardAmount }); const gameState = new GameState({ rewardAmount, finalizeSlot: UInt32.zero, turnCount: UInt8.from(1), isSolved: Bool(false), }); this.solutionHash.set(Poseidon.hash([...secretCombination.digits, salt])); this.codeMasterId.set(Poseidon.hash(codemasterPubKey.toFields())); this.refereeId.set(Poseidon.hash(refereePubKey.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); codeBreakerUpdate.send({ to: this.address, amount: rewardAmount }); const currentSlot = this.network.globalSlotSinceGenesis.get(); this.network.globalSlotSinceGenesis.requireBetween(currentSlot, currentSlot.add(UInt32.from(1))); const finalizeSlot = currentSlot.add(UInt32.from(MAX_ATTEMPTS).mul(PER_ATTEMPT_GAME_DURATION)); const gameState = new GameState({ rewardAmount: rewardAmount.add(rewardAmount), finalizeSlot, turnCount, isSolved: Bool(false), }); this.codeBreakerId.set(Poseidon.hash(sender.toFields())); 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 { finalizeSlot, rewardAmount, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals()); await this.assertNotFinalized(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 maxAttemptsExceeded = proof.publicOutput.turnCount.greaterThanOrEqual(MAX_ATTEMPTS * 2); const clue = Clue.decompress(proof.publicOutput.lastcompressedClue); isSolved = clue.isSolved().and(maxAttemptsExceeded.not()); const winnerId = Poseidon.hash(winnerPubKey.toFields()); const isCodeMaster = codeMasterId.equals(winnerId); const isCodeBreaker = codeBreakerId.equals(winnerId); const codeMasterWinByMaxAttempts = isSolved .not() .and(proof.publicOutput.turnCount.greaterThanOrEqual(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, 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, maxAttemptsExceeded, })); } /** * 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, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals()); const currentSlot = this.network.globalSlotSinceGenesis.getAndRequireEquals(); let isFinalized = currentSlot.greaterThanOrEqual(finalizeSlot); const claimer = this.sender.getAndRequireSignature(); const codeMasterId = this.codeMasterId.getAndRequireEquals(); const computedCodeMasterId = Poseidon.hash(claimer.toFields()); const codeBreakerId = this.codeBreakerId.getAndRequireEquals(); const computedCodebreakerId = Poseidon.hash(claimer.toFields()); const isCodeMaster = codeMasterId.equals(computedCodeMasterId); const isCodeBreaker = codeBreakerId.equals(computedCodebreakerId); // Code Master wins if the game is finalized and the codeBreaker has not solved the secret combination yet // 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.greaterThanOrEqual(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(isCodeBreaker.and(codeBreakerWin)); 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, 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 refereeId = this.refereeId.getAndRequireEquals(); const codeBreakerId = this.codeBreakerId.getAndRequireEquals(); const codeMasterId = this.codeMasterId.getAndRequireEquals(); let { rewardAmount, finalizeSlot, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals()); refereeId.assertEquals(Poseidon.hash(this.sender.getAndRequireSignature().toFields()), '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, 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, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals()); await this.assertNotFinalized(finalizeSlot, isSolved); turnCount.value .isEven() .not() .assertTrue('Please wait for the codeMaster to give you a clue!'); turnCount.assertLessThan(MAX_ATTEMPTS * 2, 'You have reached the number limit of attempts to solve the secret combination!'); this.codeBreakerId .getAndRequireEquals() .assertEquals(Poseidon.hash(this.sender.getAndRequireSignature().toFields()), 'You are not the codeBreaker of this game!'); 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), 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, turnCount, isSolved } = GameState.unpack(this.compressedState.getAndRequireEquals()); await this.assertNotFinalized(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]), 'The secret combination is not compliant with the stored hash on-chain!'); const lastGuess = Combination.getElementFromHistory(this.packedGuessHistory.getAndRequireEquals(), turnCount.div(2).sub(1).value); const clue = Clue.giveClue(lastGuess.digits, secretCombination.digits); const packedClueHistory = Clue.updateHistory(clue, this.packedClueHistory.getAndRequireEquals(), turnCount.div(2).sub(1).value); this.packedClueHistory.set(packedClueHistory); isSolved = isSolved.or(clue.isSolved()); const gameState = new GameState({ rewardAmount, finalizeSlot, turnCount: turnCount.add(1), 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, "refereeId", 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, PublicKey, 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