FHEVM Dice Guessing Game Complete Implementation
Dice Guessing Game Smart Contract
Create/replace contracts/FHEDiceGame.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {FHE, euint32, externalEuint32, ebool} from "@fhevm/solidity/lib/FHE.sol";
import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol";
/// @title A privacy-preserving FHE dice game contract
/// @author fhevm-hardhat-template
contract FHEDiceGame is SepoliaConfig {
euint32 private _lastDiceRoll;
euint32 private _playerGuess;
ebool private _isWinner; // Encrypted winner status
address public owner;
uint256 public constant ENTRY_FEE = 0.0002 ether;
// Custom errors for gas optimization
error IncorrectEntryFee();
error OnlyOwnerCanWithdraw();
constructor() {
owner = msg.sender;
}
/// @notice Returns the last encrypted dice roll
function getLastDiceRoll() external view returns (euint32) {
return _lastDiceRoll;
}
/// @notice Returns the player's encrypted guess
function getPlayerGuess() external view returns (euint32) {
return _playerGuess;
}
/// @notice Returns the encrypted winner status
function getWinnerStatus() external view returns (ebool) {
return _isWinner;
}
/// @notice Play the dice game with encrypted inputs
/// @param inputSeed the encrypted seed
/// @param seedProof the seed proof
/// @param inputGuess the encrypted guess (1-6)
/// @param guessProof the guess proof
function playDice(
externalEuint32 inputSeed,
bytes calldata seedProof,
externalEuint32 inputGuess,
bytes calldata guessProof
) external payable {
if (msg.value != ENTRY_FEE) revert IncorrectEntryFee();
// Convert external encrypted inputs to internal euint32
euint32 encryptedSeed = FHE.fromExternal(inputSeed, seedProof);
euint32 encryptedGuess = FHE.fromExternal(inputGuess, guessProof);
// Store encrypted values
_playerGuess = encryptedGuess;
_lastDiceRoll = _generateDiceRoll(encryptedSeed);
// Core FHE Logic: Determine if the player is a winner on-chain
_isWinner = FHE.eq(_lastDiceRoll, _playerGuess);
// Allow the player to access their
// own encrypted values for off-chain decryption
FHE.allowThis(_lastDiceRoll);
FHE.allow(_lastDiceRoll, msg.sender);
FHE.allowThis(_playerGuess);
FHE.allow(_playerGuess, msg.sender);
FHE.allowThis(_isWinner);
FHE.allow(_isWinner, msg.sender);
}
/// @notice Generate a dice roll from encrypted seed
function _generateDiceRoll(euint32 seed) internal returns (euint32) {
euint32 salt = FHE.asEuint32(42);
euint32 combined = FHE.add(seed, salt);
euint32 mask = FHE.asEuint32(7);
euint32 masked = FHE.and(combined, mask);
euint32 one = FHE.asEuint32(1);
euint32 result = FHE.add(masked, one);
euint32 six = FHE.asEuint32(6);
return FHE.min(result, six);
}
/// @notice Withdraw contract balance (owner only)
function withdraw() external {
if (msg.sender != owner) revert OnlyOwnerCanWithdraw();
payable(owner).transfer(address(this).balance);
}
/// @notice Fund the contract
receive() external payable {}
}
Create a test script to check for smart contract efficiency
Create/replace contracts/test/FHEDiceGame.ts
:
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { ethers, fhevm } from "hardhat";
import { FHEDiceGame } from "../types";
import { expect } from "chai";
import { FhevmType } from "@fhevm/hardhat-plugin";
type Signers = {
deployer: HardhatEthersSigner;
alice: HardhatEthersSigner;
bob: HardhatEthersSigner;
};
async function deployFixture() {
const factory = await ethers.getContractFactory("FHEDiceGame");
const fheDiceGameContract = (await factory.deploy()) as FHEDiceGame;
const fheDiceGameContractAddress = await fheDiceGameContract.getAddress();
return { fheDiceGameContract, fheDiceGameContractAddress };
}
describe("FHEDiceGame", function () {
let signers: Signers;
let fheDiceGameContract: FHEDiceGame;
let fheDiceGameContractAddress: string;
before(async function () {
const ethSigners: HardhatEthersSigner[] = await ethers.getSigners();
signers = { deployer: ethSigners[0], alice: ethSigners[1], bob: ethSigners[2] };
});
beforeEach(async function () {
// Check whether the tests are running
// against an FHEVM mock environment
if (!fhevm.isMock) {
console.warn(`This hardhat test suite cannot run on Sepolia Testnet`);
this.skip();
}
({ fheDiceGameContract, fheDiceGameContractAddress } = await deployFixture());
});
it("encrypted dice roll should be uninitialized after deployment", async function () {
const encryptedDiceRoll = await fheDiceGameContract.getLastDiceRoll();
// Expect initial dice roll to be bytes32(0) after deployment,
// (meaning the encrypted dice roll value is uninitialized)
expect(encryptedDiceRoll).to.eq(ethers.ZeroHash);
});
it("encrypted player guess should be uninitialized after deployment", async function () {
const encryptedPlayerGuess = await fheDiceGameContract.getPlayerGuess();
// Expect initial player guess to be bytes32(0) after deployment,
// (meaning the encrypted player guess value is uninitialized)
expect(encryptedPlayerGuess).to.eq(ethers.ZeroHash);
});
it("should play dice game and generate valid dice roll", async function () {
const encryptedDiceRollBefore = await fheDiceGameContract.getLastDiceRoll();
expect(encryptedDiceRollBefore).to.eq(ethers.ZeroHash);
const encryptedPlayerGuessBefore = await fheDiceGameContract.getPlayerGuess();
expect(encryptedPlayerGuessBefore).to.eq(ethers.ZeroHash);
// Encrypt seed value
const seedValue = 12345;
const encryptedSeed = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(seedValue)
.encrypt();
// Encrypt guess value (1-6)
const guessValue = 4;
const encryptedGuess = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(guessValue)
.encrypt();
// Play the dice game with correct entry fee
const entryFee = ethers.parseEther("0.0002");
const tx = await fheDiceGameContract
.connect(signers.alice)
.playDice(
encryptedSeed.handles[0],
encryptedSeed.inputProof,
encryptedGuess.handles[0],
encryptedGuess.inputProof,
{ value: entryFee },
);
await tx.wait();
// Get and decrypt the dice roll
const encryptedDiceRollAfter = await fheDiceGameContract.getLastDiceRoll();
const clearDiceRoll = await fhevm.userDecryptEuint(
FhevmType.euint32,
encryptedDiceRollAfter,
fheDiceGameContractAddress,
signers.alice,
);
// Get and decrypt the player guess
const encryptedPlayerGuessAfter = await fheDiceGameContract.getPlayerGuess();
const clearPlayerGuess = await fhevm.userDecryptEuint(
FhevmType.euint32,
encryptedPlayerGuessAfter,
fheDiceGameContractAddress,
signers.alice,
);
// Verify dice roll is between 1 and 6
expect(clearDiceRoll).to.be.greaterThanOrEqual(1);
expect(clearDiceRoll).to.be.lessThanOrEqual(6);
// Verify player guess matches what we sent
expect(clearPlayerGuess).to.eq(guessValue);
});
it("should play multiple games and track last values correctly", async function () {
const entryFee = ethers.parseEther("0.0002");
// First game
const seedValue1 = 11111;
const guessValue1 = 2;
const encryptedSeed1 = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(seedValue1)
.encrypt();
const encryptedGuess1 = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(guessValue1)
.encrypt();
let tx = await fheDiceGameContract
.connect(signers.alice)
.playDice(
encryptedSeed1.handles[0],
encryptedSeed1.inputProof,
encryptedGuess1.handles[0],
encryptedGuess1.inputProof,
{ value: entryFee },
);
await tx.wait();
// Second game with different values
const seedValue2 = 22222;
const guessValue2 = 5;
const encryptedSeed2 = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(seedValue2)
.encrypt();
const encryptedGuess2 = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(guessValue2)
.encrypt();
tx = await fheDiceGameContract
.connect(signers.alice)
.playDice(
encryptedSeed2.handles[0],
encryptedSeed2.inputProof,
encryptedGuess2.handles[0],
encryptedGuess2.inputProof,
{ value: entryFee },
);
await tx.wait();
// Verify the contract tracks the LAST game's values
const encryptedDiceRoll = await fheDiceGameContract.getLastDiceRoll();
const clearDiceRoll = await fhevm.userDecryptEuint(
FhevmType.euint32,
encryptedDiceRoll,
fheDiceGameContractAddress,
signers.alice,
);
const encryptedPlayerGuess = await fheDiceGameContract.getPlayerGuess();
const clearPlayerGuess = await fhevm.userDecryptEuint(
FhevmType.euint32,
encryptedPlayerGuess,
fheDiceGameContractAddress,
signers.alice,
);
// Should have the values from the second (last) game
expect(clearDiceRoll).to.be.greaterThanOrEqual(1);
expect(clearDiceRoll).to.be.lessThanOrEqual(6);
expect(clearPlayerGuess).to.eq(guessValue2);
});
it("should revert with incorrect entry fee", async function () {
// Encrypt seed and guess values
const seedValue = 98765;
const guessValue = 3;
const encryptedSeed = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(seedValue)
.encrypt();
const encryptedGuess = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(guessValue)
.encrypt();
// Try with incorrect entry fee (too low)
const incorrectFee = ethers.parseEther("0.0001");
await expect(
fheDiceGameContract
.connect(signers.alice)
.playDice(
encryptedSeed.handles[0],
encryptedSeed.inputProof,
encryptedGuess.handles[0],
encryptedGuess.inputProof,
{ value: incorrectFee },
),
).to.be.revertedWithCustomError(fheDiceGameContract, "IncorrectEntryFee");
});
it("should allow owner to withdraw funds", async function () {
// First, add some funds by playing the game
const seedValue = 54321;
const guessValue = 6;
const encryptedSeed = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(seedValue)
.encrypt();
const encryptedGuess = await fhevm
.createEncryptedInput(fheDiceGameContractAddress, signers.alice.address)
.add32(guessValue)
.encrypt();
const entryFee = ethers.parseEther("0.0002");
await fheDiceGameContract
.connect(signers.alice)
.playDice(
encryptedSeed.handles[0],
encryptedSeed.inputProof,
encryptedGuess.handles[0],
encryptedGuess.inputProof,
{ value: entryFee },
);
// Get contract balance before withdrawal
const contractBalanceBefore = await ethers.provider.getBalance(fheDiceGameContractAddress);
expect(contractBalanceBefore).to.be.greaterThan(0);
// Owner withdraws funds
const tx = await fheDiceGameContract.connect(signers.deployer).withdraw();
await tx.wait();
// Check contract balance is now 0
const contractBalanceAfter = await ethers.provider.getBalance(fheDiceGameContractAddress);
expect(contractBalanceAfter).to.eq(0);
});
it("should prevent non-owner from withdrawing", async function () {
await expect(fheDiceGameContract.connect(signers.alice).withdraw()).to.be.revertedWithCustomError(
fheDiceGameContract,
"OnlyOwnerCanWithdraw",
);
});
});
Create a deploy script to deploy the smart contract on Sepolia
Create/replace deploy/deploy.ts
:
// deploy/01_deploy_fhe_dice_game.ts
import { DeployFunction } from "hardhat-deploy/types";
import { HardhatRuntimeEnvironment } from "hardhat/types";
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { deployer } = await hre.getNamedAccounts();
const { deploy } = hre.deployments;
const deployedFHEDiceGame = await deploy("FHEDiceGame", {
from: deployer,
log: true,
});
console.log(`FHEDiceGame contract deployed to: ${deployedFHEDiceGame.address}`);
};
export default func;
func.id = "deploy_fhe_dice_game";
func.tags = ["FHEDiceGame"];
Now let's compile → test → deploy
To compile
npx hardhat compile
To test
npx hardhat test
To deploy on localhost hardhat
Start a local Hardhat node (new terminal):
npx run hardhat-node
Then deploy:
npx hardhat deploy --network localhost
To deploy on Sepolia
npx hardhat deploy --network sepolia
Hurray 🎉🎉🥳🥳 you have successfully deployed a FHEVM powered smart contract
Example output: FHEDiceGame contract deployed to: 0xA6915c97f44f1708e37dD871CCE991fD6D45E943
Now Copy / Keep the smart contract we will have to integrate this on the frontend
Create a task script to test the smart contract
Create/replace contracts/task/FHEDiceGame.ts
:
import { task } from "hardhat/config";
import { FhevmType } from "@fhevm/hardhat-plugin";
/**
* Example:
* - npx hardhat --network localhost task:dice-address
* - npx hardhat --network sepolia task:dice-address
*/
task("task:dice-address", "Prints the FHEDiceGame address").setAction(async function (_taskArguments, hre) {
const { deployments } = hre;
const fheDice = await deployments.get("FHEDiceGame");
console.log("FHEDiceGame address is " + fheDice.address);
});
/**
* Example:
* - npx hardhat --network localhost task:play-dice --guess 4 --seed 12345
* - npx hardhat --network sepolia task:play-dice --guess 2 --seed 54321
*/
task("task:play-dice", "Play FHEDiceGame by providing guess and seed")
.addParam("guess", "The dice guess between 1 and 6")
.addParam("seed", "Random seed for dice roll")
.addOptionalParam("address", "Optionally specify the FHEDiceGame contract address")
.setAction(async function (taskArguments, hre) {
const { ethers, deployments, fhevm } = hre;
const guess = parseInt(taskArguments.guess);
const seed = parseInt(taskArguments.seed);
if (!Number.isInteger(guess) || guess < 1 || guess > 6) {
throw new Error(`--guess must be integer between 1 and 6`);
}
if (!Number.isInteger(seed)) {
throw new Error(`--seed must be an integer`);
}
await fhevm.initializeCLIApi();
const FHEDiceDeployment = taskArguments.address
? { address: taskArguments.address }
: await deployments.get("FHEDiceGame");
console.log(`FHEDiceGame: ${FHEDiceDeployment.address}`);
const [signer] = await ethers.getSigners();
const fheDiceContract = await ethers.getContractAt("FHEDiceGame", FHEDiceDeployment.address);
// Encrypt seed and guess
const encryptedSeed = await fhevm
.createEncryptedInput(FHEDiceDeployment.address, signer.address)
.add32(seed)
.encrypt();
const encryptedGuess = await fhevm
.createEncryptedInput(FHEDiceDeployment.address, signer.address)
.add32(guess)
.encrypt();
// Get entry fee
const entryFee = await fheDiceContract.ENTRY_FEE();
console.log(`💰 Entry fee: ${ethers.formatEther(entryFee)} ETH`);
// Send tx
const tx = await fheDiceContract
.connect(signer)
.playDice(
encryptedSeed.handles[0],
encryptedSeed.inputProof,
encryptedGuess.handles[0],
encryptedGuess.inputProof,
{ value: entryFee },
);
console.log(`Wait for tx:${tx.hash}...`);
const receipt = await tx.wait();
console.log(`tx:${tx.hash} status=${receipt?.status}`);
console.log(`FHEDiceGame playDice(guess=${guess}, seed=${seed}) succeeded!`);
});
/**
* Example:
* - npx hardhat --network localhost task:dice-result
* - npx hardhat --network sepolia task:dice-result
*/
task("task:dice-result", "Fetch the last dice roll and winner status")
.addOptionalParam("address", "Optionally specify the FHEDiceGame contract address")
.setAction(async function (taskArguments, hre) {
const { ethers, deployments, fhevm } = hre;
await fhevm.initializeCLIApi();
const FHEDiceDeployment = taskArguments.address
? { address: taskArguments.address }
: await deployments.get("FHEDiceGame");
console.log(`FHEDiceGame: ${FHEDiceDeployment.address}`);
const [signer] = await ethers.getSigners();
const fheDiceContract = await ethers.getContractAt("FHEDiceGame", FHEDiceDeployment.address);
const encryptedDiceRoll = await fheDiceContract.getLastDiceRoll();
const encryptedWinnerStatus = await fheDiceContract.getWinnerStatus();
if (encryptedDiceRoll === ethers.ZeroHash) {
console.log("No game played yet.");
return;
}
const clearDiceRoll = await fhevm.userDecryptEuint(
FhevmType.euint32,
encryptedDiceRoll,
FHEDiceDeployment.address,
signer,
);
const clearWinnerStatus = await fhevm.userDecryptEbool(
encryptedWinnerStatus,
FHEDiceDeployment.address,
signer,
);
console.log("Encrypted Dice Roll :", encryptedDiceRoll);
console.log("Clear Dice Roll :", clearDiceRoll.toString());
console.log("Encrypted Winner Stat :", encryptedWinnerStatus);
console.log("Clear Winner Stat :", clearWinnerStatus.toString());
});
Play game / Run task test of the FHEVM contract:
Let us now test the smart contract by guessing a number; Note: Here the seed provided is to randomize the computation
npx hardhat --network sepolia task:play-dice --guess 6 --seed 65463
Expected Output:
FHEDiceGame: 0xA6915c97f44f1708e37dD871CCE991fD6D45E943
💰 Entry fee: 0.0002 ETH
Wait for tx:0xd69aa9095b34e8832451c8359af64f477f5c820063f59fb21e21c9062fd89b7e...
tx:0xd69aa9095b34e8832451c8359af64f477f5c820063f59fb21e21c9062fd89b7e status=1
FHEDiceGame playDice(guess=6, seed=65463) succeeded!
Now let's us check the result too!
npx hardhat --network sepolia task:dice-result --address {contract address}
Expected output:
FHEDiceGame: 0xA6915c97f44f1708e37dD871CCE991fD6D45E943
Encrypted Dice Roll : 0x
Clear Dice Roll : 4
Encrypted Winner Stat : 0x
Clear Winner Stat : false/true
Understanding the Dice Generation Algorithm
The _generateDiceRoll
function (lines 73-83) performs the following encrypted operations:
We used seed (random numbers) and salt to randomize the outcome
Refer: Line of Code 73-83 on FHEDiceGame.sol
/// @notice Generate a dice roll from encrypted seed
function _generateDiceRoll(euint32 seed) internal returns (euint32) {
euint32 salt = FHE.asEuint32(42);
euint32 combined = FHE.add(seed, salt);
euint32 mask = FHE.asEuint32(7);
euint32 masked = FHE.and(combined, mask);
euint32 one = FHE.asEuint32(1);
euint32 result = FHE.add(masked, one);
euint32 six = FHE.asEuint32(6);
return FHE.min(result, six);
}
- Salt addition: Prevents predictable patterns by adding 42 to the seed
- Bit masking:
& 7
creates modulo 8 operation (0-7 range) - Range adjustment:
+ 1
shifts to 1-8, thenmin(result, 6)
caps at dice range 1-6
Next Steps is Decrypting Game Results and Determining Winners.
Using FHevm.js now you can decrypt the result on frontend and check if you win or lose.
Smart Contract Implementation Quiz
Test your understanding of FHEVM smart contract development and testing!