Building a Privacy-Preserving Dice Game with Zama FHEVM Relayer SDK
A comprehensive tutorial on creating a decentralized dice game using FHEVM smart contracts with fully homomorphic encryption.
Overview
This tutorial will guide you through building a complete Dice Game DApp powered by Zama's FHEVM technology. You'll learn how to:
- Set up a repository with smart contracts and frontend
- Deploy FHEVM-enabled smart contracts
- Build a React frontend with encrypted interactions
- Integrate the Zama Relayer SDK for homomorphic encryption
- Create a privacy-preserving gaming experience
Prerequisites
- Node.js: Version 20 or higher
- MetaMask: Browser extension for wallet connection
- Basic understanding of React, Next.js, and blockchain development
- Familiarity with Hardhat & Ethers.js
- Knowledge of FHEVM and homomorphic encryption concepts
Project Structure
This tutorial uses a repository structure of official Zama hardhat template fhevm-react-template :
hello-fhevm-main/
├── LICENSE # Project license
├── package.json # Root package configuration
├── README.md # Project documentation
├── TUTORIAL.md # Tutorial documentation
├── tsconfig.json # TypeScript configuration
├── package-lock.json # NPM lock file
├── pnpm-lock.yaml # PNPM lock file
├── node_modules/ # Dependencies
├── packages/
│ ├── fhevm-hardhat-template/ # Smart contracts and deployment
│ │ ├── artifacts/ # Compiled contract artifacts
│ │ ├── cache/ # Hardhat cache
│ │ ├── contracts/ # Solidity smart contracts
│ │ ├── deploy/ # Deployment scripts
│ │ ├── deployments/ # Deployment artifacts
│ │ ├── fhevmTemp/ # FHEVM temporary files
│ │ ├── tasks/ # Hardhat task scripts
│ │ ├── test/ # Contract tests
│ │ ├── types/ # TypeScript type definitions
│ │ ├── hardhat.config.ts # Hardhat configuration
│ │ ├── package.json # Package configuration
│ │ ├── tsconfig.json # TypeScript configuration
│ │ ├── LICENSE # Package license
│ │ └── README.md # Package documentation
│ ├── fhevm-react/ # FHEVM React hooks and utilities
│ │ ├── internal/ # Internal implementation files
│ │ ├── node_modules/ # Package dependencies
│ │ ├── FhevmDecryptionSignature.ts # EIP-712 decryption signatures
│ │ ├── fhevmTypes.ts # TypeScript type definitions
│ │ ├── GenericStringStorage.ts # Storage abstraction
│ │ ├── index.ts # Package exports
│ │ ├── useFhevm.tsx # Main React hook
│ │ ├── userFhevm.test.tsx # Test files
│ │ └── package.json # Package configuration
│ ├── site/ # Next.js frontend application
│ │ ├── abi/ # Contract ABIs and addresses
│ │ ├── app/ # Next.js app directory
│ │ ├── components/ # React components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── public/ # Static assets
│ │ ├── node_modules/ # Dependencies
│ │ ├── components.json # Component configuration
│ │ ├── eslint.config.mjs # ESLint configuration
│ │ ├── next-env.d.ts # Next.js type definitions
│ │ ├── next.config.ts # Next.js configuration
│ │ ├── package.json # Package configuration
│ │ ├── postcss.config.mjs # PostCSS configuration
│ │ ├── README.md # Package documentation
│ │ ├── tailwind.config.ts # Tailwind CSS configuration
│ │ ├── tsconfig.json # TypeScript configuration
│ │ ├── vitest.config.ts # Vitest configuration
│ │ └── LICENSE # Package license
│ └── postdeploy/ # Post-deployment utilities
│ ├── index.ts # Main postdeploy script
│ └── package.json # Package configuration
└── scripts/ # Build and deployment scripts
├── deploy-hardhat-node.sh # Deploy to local hardhat node
├── generate-site-abi.mjs # Generate ABI files for frontend
├── is-hardhat-node-running.mjs # Check if hardhat node is running
└── utils.mjs # Utility functions
Step 1: Project Setup
1.1 Clone and Install Dependencies
git clone https://github.com/zama-ai/fhevm-react-template
cd hello-fhevm-main
npm install
1.2 Navigate to Hardhat Project
cd packages/fhevm-hardhat-template
npm install
1.3 Update Dice Game Contract
Under contracts/
directory, create or update the Dice game Solidity code:
// 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 {}
}
1.4 Deploy Script
Create the deploy script in deploy/deploy.ts
:
// deploy/deploy.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"];
1.5 Test Script
Create comprehensive tests in 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",
);
});
});
1.6 Task Scripts
Create task scripts in tasks/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());
});
1.7 Hardhat Configuration
Update hardhat.config.ts
:
import "@fhevm/hardhat-plugin";
import "@nomicfoundation/hardhat-chai-matchers";
import "@nomicfoundation/hardhat-ethers";
import "@nomicfoundation/hardhat-verify";
import "@typechain/hardhat";
import "hardhat-deploy";
import "hardhat-gas-reporter";
import type { HardhatUserConfig } from "hardhat/config";
import { vars } from "hardhat/config";
import "solidity-coverage";
import "./tasks/accounts";
import "./tasks/FHEDiceGame";
// Run 'npx hardhat vars setup' to see the list of variables that need to be set
const MNEMONIC: string = vars.get("MNEMONIC", "zzzzzz");
const INFURA_API_KEY: string = vars.get("INFURA_API_KEY", "");
const config: HardhatUserConfig = {
defaultNetwork: "hardhat",
namedAccounts: {
deployer: 0,
},
etherscan: {
apiKey: {
sepolia: process.env.ETHERSCAN_API_KEY || "",
},
customChains: [
{
network: "sepolia",
chainId: 11155111,
urls: {
apiURL: "https://api-sepolia.etherscan.io/api",
browserURL: "https://sepolia.etherscan.io",
},
},
],
},
gasReporter: {
currency: "USD",
enabled: process.env.REPORT_GAS ? true : false,
excludeContracts: [],
},
networks: {
hardhat: {
accounts: {
mnemonic: MNEMONIC,
},
chainId: 31337,
},
anvil: {
accounts: {
mnemonic: MNEMONIC,
path: "m/44'/60'/0'/0/",
count: 10,
},
chainId: 31337,
url: "http://localhost:8545",
},
sepolia: {
accounts: {
mnemonic: MNEMONIC,
path: "m/44'/60'/0'/0/",
count: 10,
},
chainId: 11155111,
url: `https://sepolia.infura.io/v3/${INFURA_API_KEY}`,
},
},
paths: {
artifacts: "./artifacts",
cache: "./cache",
sources: "./contracts",
tests: "./test",
},
solidity: {
version: "0.8.27",
settings: {
metadata: {
// Not including the metadata hash
// https://github.com/paulrberg/hardhat-template/issues/31
bytecodeHash: "none",
},
// Disable the optimizer when debugging
// https://hardhat.org/hardhat-network/#solidity-optimizer-support
optimizer: {
enabled: true,
runs: 800,
},
evmVersion: "cancun",
},
},
typechain: {
outDir: "types",
target: "ethers-v6",
},
};
export default config;
1.8 Compile and Test
Compile the contracts:
npx hardhat compile
Run the tests:
npx hardhat test
Step 2: Deployment and ABI Generation
2.1 Deploy to Local Hardhat Node
Start a local Hardhat node (new terminal):
npx hardhat node
Then deploy to localhost:
npx hardhat deploy --network localhost
2.2 Deploy to Sepolia
# Deploy smart contracts to Sepolia testnet
npx hardhat deploy --network sepolia
2.3 Generate ABI Files
After deployment, generate the ABI files for frontend integration. Create a run-postdeploy.ts
file in the hardhat template directory:
import { postDeploy } from '../postdeploy/index';
// Get command line arguments
const chainName = process.argv[2] || 'sepolia';
const contractName = process.argv[3] || 'FHEDiceGame';
console.log(`Running postdeploy for ${contractName} on ${chainName}...`);
try {
postDeploy(chainName, contractName);
console.log('Postdeploy completed successfully!');
} catch (error) {
console.error(' Postdeploy failed:', error);
process.exit(1);
}
Run this code:
npx ts-node run-postdeploy.ts sepolia FHEDiceGame
This creates the necessary TypeScript files in packages/site/abi/
:
FHEDiceGameABI.ts
- Contract ABIFHEDiceGameAddresses.ts
- Contract addresses
// FHEDiceGameABI.ts
export const FHEDiceGameABI = {
"abi": [
{
"inputs": [
{
"internalType": "externalEuint32",
"name": "inputSeed",
"type": "bytes32"
},
{
"internalType": "bytes",
"name": "seedProof",
"type": "bytes"
},
{
"internalType": "externalEuint32",
"name": "inputGuess",
"type": "bytes32"
},
{
"internalType": "bytes",
"name": "guessProof",
"type": "bytes"
}
],
"name": "playDice",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
// ... rest of the ABI
]
} as const;
// FHEDiceGameAddresses.ts
export const FHEDiceGameAddresses = {
"11155111": { address: "0x31630746e870D3D57e8B708ac479f269e1Da4c10", chainId: 11155111, chainName: "sepolia" },
"31337": { address: "0x31630746e870D3D57e8B708ac479f269e1Da4c10", chainId: 31337, chainName: "hardhat" },
};
- Type Safety: Provides TypeScript types for contract interactions
- Address Management: Automatically handles different network addresses
- ABI Synchronization: Ensures frontend ABI matches deployed contract
- Development Workflow: Enables seamless development across networks
Step 3: Frontend Integration with @fhevm/react
3.1 Project Structure
The frontend is built with Next.js 15 and React 19, located in packages/site/
:
packages/site/
├── app/ # Next.js app directory
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ └── providers.tsx # Context providers
├── components/ # React components
│ ├── DiceNarration.tsx # Main game component
│ ├── PlayDice.tsx # Dice rolling interface
│ ├── DiceResults.tsx # Results display
│ └── ConnectButton.tsx # Wallet connection
├── hooks/ # Custom React hooks
│ ├── useDiceGame.tsx # Game logic hook
│ └── metamask/ # MetaMask integration
└── abi/ # Contract ABIs and addresses
3.2 Install Dependencies
Install @fhevm/react and other dependencies in the packages/site/
directory:
cd packages/site
npm install @fhevm/react
3.3 Understanding the @fhevm/react Package
The @fhevm/react
package provides React hooks and utilities for FHEVM integration:
packages/fhevm-react/
├── internal/
│ ├── RelayerSDKLoader.ts # SDK loading logic
│ ├── PublicKeyStorage.ts # Key storage management
│ ├── fhevm.ts # Core FHEVM instance creation
│ └── mock/ # Mock implementations for testing
├── useFhevm.tsx # Main React hook
├── FhevmDecryptionSignature.ts # Decryption signature handling
└── GenericStringStorage.ts # Storage abstraction
3.4 Using the useFhevm Hook
The useFhevm
hook provides a clean interface for FHEVM integration:
import { useFhevm } from "@fhevm/react";
const {
instance: fhevmInstance,
status: fhevmStatus,
error: fhevmError,
} = useFhevm({
provider,
chainId,
initialMockChains: {
11155111: "https://sepolia.infura.io/v3/YOUR_INFURA_KEY"
},
enabled: true,
});
Step 4: Core Game Implementation
4.1 MetaMask Integration Hooks
The project includes comprehensive MetaMask integration hooks in packages/site/hooks/metamask/
:
useEip6963.ts
- EIP-6963 provider discoveryuseMetaMask.ts
- Core MetaMask connection managementuseMetaMaskEthersSigner.ts
- Ethers.js signer integration
export function useEip6963(): Eip6963State {
const [error, setError] = useState<Error | undefined>(undefined);
const [uuids, setUuids] = useState<
Record<string, Eip6963ProviderDetail> | undefined
>(undefined);
// ... provider discovery logic
}
export function useMetaMask(): UseMetaMaskState {
const { provider, chainId, accounts, isConnected, error, connect } =
useMetaMaskInternal();
// ... connection management logic
}
4.2 Core Game Hook: useDiceGame with @fhevm/react
The useDiceGame.tsx
hook integrates with the @fhevm/react package to handle all FHEVM interactions:
// In DiceNarration.tsx - Main component setup
import { useFhevm } from "@fhevm/react";
const {
instance: fhevmInstance,
status: fhevmStatus,
error: fhevmError,
} = useFhevm({
provider,
chainId,
initialMockChains: {
11155111: "https://sepolia.infura.io/v3/YOUR_INFURA_KEY"
},
enabled: true,
});
// Pass the FHEVM instance to the game hook
const diceGame = useDiceGame(
accounts?.[0] as `0x${string}` | undefined,
showToast,
setTxStatus,
contractAddress as `0x${string}` | undefined,
fhevmInstance
);
import { SetStateAction, useEffect, useState } from 'react';
import { BrowserProvider, Contract, parseEther, hexlify, BytesLike } from 'ethers';
export const useDiceGame = (
account: `0x${string}` | undefined,
showToast: ShowToastFunction,
setTxStatus: SetTxStatusFunction,
contractAddress: `0x${string}` | undefined,
fhevmInstance: any
) => {
const [contract, setContract] = useState<Contract | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [entryFeeWei, setEntryFeeWei] = useState<bigint | null>(null);
const [encryptedState, setEncryptedState] = useState<{
lastDiceRoll: string;
playerGuess: string;
winnerStatus: string;
}>({
lastDiceRoll: '0x',
playerGuess: '0x',
winnerStatus: '0x'
});
const [decryptedState, setDecryptedState] = useState<{
lastDiceRoll: number | null;
playerGuess: number | null;
winnerStatus: boolean | null;
}>({
lastDiceRoll: null,
playerGuess: null,
winnerStatus: null
});
const [fairness, setFairness] = useState<{
seed: number;
guess: number;
commitment: string;
txHash: string;
} | null>(null);
const abi = [
"function ENTRY_FEE() view returns (uint256)",
"function owner() view returns (address)",
"function getLastDiceRoll() view returns (bytes32)",
"function getPlayerGuess() view returns (bytes32)",
"function getWinnerStatus() view returns (bytes32)",
"function playDice(bytes32,bytes,bytes32,bytes) payable",
"function withdraw()"
];
const playDice = async (seedNumber: number, guessNumber: number) => {
if (!contract || !fhevmInstance) return;
if (
typeof seedNumber !== 'number' || Number.isNaN(seedNumber) ||
typeof guessNumber !== 'number' || Number.isNaN(guessNumber) ||
guessNumber < 1 || guessNumber > 6
) {
showToast('Enter a valid seed and a guess between 1-6', 'warning');
return;
}
setLoading(true);
setTxStatus('Encrypting seed and guess...');
try {
// Prepare encrypted inputs using FHEVM instance
const seedInput = await fhevmInstance.createEncryptedInput(contractAddress, account);
await seedInput.add32(seedNumber);
const seedEnc = await seedInput.encrypt();
const guessInput = await fhevmInstance.createEncryptedInput(contractAddress, account);
await guessInput.add32(guessNumber);
const guessEnc = await guessInput.encrypt();
// Extract handles and proofs
const firstSeed = (seedEnc.handles && seedEnc.handles[0]) || seedEnc.handle || seedEnc.inputHandle;
const firstGuess = (guessEnc.handles && guessEnc.handles[0]) || guessEnc.handle || guessEnc.inputHandle;
let seedHandle = typeof firstSeed === 'object' && firstSeed?.handle ? firstSeed.handle : firstSeed;
let guessHandle = typeof firstGuess === 'object' && firstGuess?.handle ? firstGuess.handle : firstGuess;
let seedProof = seedEnc.inputProof || seedEnc.proof;
let guessProof = guessEnc.inputProof || guessEnc.proof;
// Normalize to hex strings
const toHex = (v: BytesLike | undefined): string => {
if (!v) return '0x';
if (typeof v === 'string') return v.startsWith('0x') ? v : `0x${v}`;
try { return hexlify(v); } catch { return '0x'; }
};
const padToBytes32 = (hex: string): string => {
if (!hex) return '0x0000000000000000000000000000000000000000000000000000000000000000';
let h = hex.toLowerCase();
if (!h.startsWith('0x')) h = `0x${h}`;
h = h.replace(/^0x/, '');
if (h.length > 64) h = h.slice(h.length - 64);
return '0x' + h.padStart(64, '0');
};
seedHandle = padToBytes32(toHex(seedHandle));
guessHandle = padToBytes32(toHex(guessHandle));
seedProof = toHex(seedProof);
guessProof = toHex(guessProof);
if (!seedHandle || !seedProof || !guessHandle || !guessProof) {
showToast('Encryption output missing handle or proof', 'error');
setLoading(false);
setTxStatus('');
return;
}
// Compute fairness commitment
const buffer = new TextEncoder().encode(String(seedNumber));
const digest = await window.crypto.subtle.digest('SHA-256', buffer);
const bytes = Array.from(new Uint8Array(digest));
const commitment = '0x' + bytes.map(b => b.toString(16).padStart(2, '0')).join('');
setTxStatus('Submitting playDice transaction...');
const tx = await contract.playDice(
seedHandle,
seedProof,
guessHandle,
guessProof,
{ value: entryFeeWei ?? parseEther('0.0002') }
);
await tx.wait();
setFairness({ seed: seedNumber, guess: guessNumber, commitment, txHash: tx.hash });
showToast('Dice played successfully!', 'success');
await refreshEncryptedState();
} catch (err: any) {
console.error('playDice failed:', err);
showToast('playDice failed: ' + (err?.message || String(err)), 'error');
} finally {
setLoading(false);
setTxStatus('');
}
};
// ... rest of the hook implementation
};
- Automatic SDK Management: No need to manually load or initialize the SDK
- Instance Lifecycle: Handles FHEVM instance creation and cleanup
- Network Detection: Automatically switches between mock and production
- Error Handling: Provides comprehensive error states
- Key Storage: Manages public key storage in IndexedDB automatically
4.3 Decryption Process with @fhevm/react
The decryption process uses the FHEVM instance:
const userDecryptValue = async (ciphertextHandle: string, valueType: 'uint32' | 'bool'): Promise<number | boolean> => {
if (!fhevmInstance || !window.ethereum) throw new Error('FHEVM instance or Ethereum not available');
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const signerAddress = await signer.getAddress();
// Generate keypair and prepare decryption request
const keypair = fhevmInstance.generateKeypair();
const handleContractPairs = [
{ handle: ciphertextHandle, contractAddress }
];
const startTimeStamp = Math.floor(Date.now() / 1000).toString();
const durationDays = '10';
const contractAddresses = [contractAddress];
// Create EIP-712 signature
const eip712 = fhevmInstance.createEIP712(
keypair.publicKey,
contractAddresses,
startTimeStamp,
durationDays
);
const signature = await signer.signTypedData(
eip712.domain,
{ UserDecryptRequestVerification: eip712.types.UserDecryptRequestVerification },
eip712.message
);
// Decrypt the encrypted value
const result = await fhevmInstance.userDecrypt(
handleContractPairs,
keypair.privateKey,
keypair.publicKey,
signature.replace('0x', ''),
contractAddresses,
signerAddress,
startTimeStamp,
durationDays
);
const raw = result[ciphertextHandle];
if (valueType === 'bool') {
return raw === true || raw === 'true' || raw === 1 || raw === '1';
}
return Number(raw);
};
- User-Controlled: Only the user who encrypted the data can decrypt it
- EIP-712 Signatures: Secure cryptographic signatures
- Time-Limited: Decryption permissions have expiration times
- Contract-Specific: Decryption tied to specific contract addresses
4.4 Interactive Dice Component
The PlayDice.tsx
component provides the user interface:
const PlayDice: React.FC<PlayDiceProps> = ({ loading, onPlay }) => {
const [seed, setSeed] = useState('');
const [guess, setGuess] = useState(1);
const [entropyReady, setEntropyReady] = useState(false);
return (
<div className="card" style={{ maxWidth: '600px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<h3>Roll the Dice</h3>
<p>Choose your number or let fate decide</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', alignItems: 'center' }}>
{/* 3D Dice Visualization */}
<div className="dice-stage" onMouseEnter={() => { if (!entropyReady) startEntropyCapture(); }}>
<div className={`dice-cube ${rolling ? 'dice-rolling' : ''}`}>
<div className="dice-face face-1">⚀</div>
<div className="dice-face face-2">⚁</div>
<div className="dice-face face-3">⚂</div>
<div className="dice-face face-4">⚃</div>
<div className="dice-face face-5">⚄</div>
<div className="dice-face face-6">⚅</div>
</div>
</div>
{/* Guess Selection */}
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', justifyContent: 'center' }}>
{[1, 2, 3, 4, 5, 6].map((f) => (
<button
key={f}
onClick={() => { setGuess(f); setCurrentFace(f); setUserPicked(true); }}
className={`btn ${guess === f ? 'btn-primary' : 'btn-secondary'}`}
style={{ padding: '12px 16px', fontSize: '2rem', minWidth: '60px' }}
disabled={loading}
>
{['⚀','⚁','⚂','⚃','⚄','⚅'][f-1]}
</button>
))}
</div>
{/* Play Button */}
<button
onClick={async () => {
const chosen = userPicked ? guess : (Math.floor(Math.random() * 6) + 1);
setRolling(true);
setRolling(false);
onPlay(Number(seed || Date.now()), Number(chosen));
}}
disabled={loading || !entropyReady}
className={`btn ${loading ? 'btn-loading' : 'btn-primary'}`}
style={{ width: '100%', height: '50px', fontSize: '1rem', fontWeight: '600' }}
>
{loading ? 'Encrypting & Playing...' : 'Roll the Dice'}
</button>
</div>
</div>
);
};
4.5 Results Display Component
The DiceResults.tsx
component shows encrypted results:
const DiceResults: React.FC<DiceResultsProps> = ({
encrypted,
decrypted,
onDecryptRoll,
onDecryptGuess,
onDecryptWinner,
loading,
fairness
}) => {
return (
<div className="card" style={{ maxWidth: '90rem', margin: '6.6px auto 0' }}>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<h3>Encrypted Results</h3>
<p>Decrypt your results to see the outcome</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Last Dice Roll */}
<div style={{ padding: '20px', background: 'var(--bg-card)', border: '1px solid var(--border-light)', borderRadius: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '20px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', fontWeight: '600', marginBottom: '12px' }}>
Last Dice Roll (encrypted)
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', background: 'var(--bg-tertiary)', padding: '12px 16px', borderRadius: '8px', wordBreak: 'break-all' }}>
{encrypted.lastDiceRoll}
</div>
{decrypted.lastDiceRoll != null && (
<div style={{ marginTop: '10px', padding: '8px 12px', background: 'var(--success-green)', color: 'var(--text-white)', borderRadius: '8px', fontWeight: '600' }}>
{decrypted.lastDiceRoll}
</div>
)}
</div>
<button
onClick={onDecryptRoll}
disabled={loading}
className={`btn ${loading ? 'btn-loading' : 'btn-secondary'}`}
style={{ fontSize: '0.9rem', padding: '10px 20px', minWidth: '120px' }}>
Decrypt
</button>
</div>
</div>
</div>
{/* Fairness Proof */}
{fairness && (
<div style={{ padding: '16px', background: 'var(--bg-tertiary)', border: '1px solid var(--border-light)', borderRadius: '12px', marginTop: '20px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', color: 'var(--text-primary)' }}>Fairness Proof</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginBottom: '8px' }}>
Seed used: <span style={{ color: 'var(--text-primary)' }}>{fairness.seed}</span> • Guess: <span style={{ color: 'var(--text-primary)' }}>{fairness.guess}</span>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', background: 'var(--bg-card)', padding: '8px 12px', border: '1px solid var(--border-light)', borderRadius: '6px', wordBreak: 'break-all', marginBottom: '8px' }}>
commitment (sha256(seed)) = {fairness.commitment}
</div>
</div>
)}
</div>
);
};
Step 5: Complete Deployment Workflow
5.1 Deployment Steps
Considering you have created necessary files and scripts from the above.
# 1. Set up environment variables
npx hardhat vars set MNEMONIC
npx hardhat vars set INFURA_API_KEY
Configure the hardhat.config.ts
# 2. Navigate to hardhat template directory
cd packages/fhevm-hardhat-template
# 3. Install necessary dependencies
npm install
# 4. Compile the solidity code
npx hardhat compile
# 5. Run a test
npx hardhat test
# 6. Deploy smart contracts to Sepolia
npx hardhat deploy --network sepolia
# 7. Generate ABI files for frontend
npx ts-node run-postdeploy.ts sepolia FHEDiceGame
# 8. Navigate back to root and start frontend
cd ../..
npm run dev:mock
The application will be available at http://localhost:3000
5.2 Understanding the Postdeploy Script
The postdeploy script is critical for:
- Parsing deployment artifacts from deployment directories
- Generating TypeScript files for frontend integration
- Handling ABI synchronization across networks
- Creating contract address mappings for different chains
5.3 Verify Generated ABI Files
After running the postdeploy script, verify these files exist in packages/site/abi/
:
- FHEDiceGameABI.ts
- FHEDiceGameAddresses.ts
5.4 Connect MetaMask
- Install MetaMask browser extension
- For Sepolia testnet configuration:
- Network Name: Sepolia Test Network
- RPC URL:
https://sepolia.infura.io/v3/YOUR_INFURA_KEY
- Chain ID:
11155111
- Currency Symbol:
ETH
- Connect wallet to the application
- Ensure you have Sepolia ETH for gas fees
Step 6: Game Flow and User Experience
6.1 Complete Game Flow
- Connect Wallet: Users connect their MetaMask wallet
- Generate Entropy: Mouse movement generates random entropy
- Choose Guess: Players select a dice face (1-6) or random
- Encrypt & Submit: Seed and guess are encrypted and submitted
- Process Game: Contract generates encrypted dice roll and determines winner
- Decrypt Results: Players decrypt their results
6.2 Privacy Features
- Encrypted Inputs: Player guesses and seeds are encrypted before submission
- Encrypted Processing: All game logic runs on encrypted data
- User-Controlled Decryption: Only the player can decrypt their results
- Fairness Proofs: Cryptographic commitments ensure fairness
Step 7: Advanced Features
7.1 Fairness Proofs
The application implements cryptographic fairness proofs:
// Compute fairness commitment: sha256(seed)
const buffer = new TextEncoder().encode(String(seedNumber));
const digest = await window.crypto.subtle.digest('SHA-256', buffer);
const bytes = Array.from(new Uint8Array(digest));
const commitment = '0x' + bytes.map(b => b.toString(16).padStart(2, '0')).join('');
setFairness({
seed: seedNumber,
guess: guessNumber,
commitment,
txHash: tx.hash
});
7.2 Error Handling and User Feedback
Comprehensive error handling with user-friendly messages:
try {
// ... FHEVM operations
} catch (err: any) {
console.error('playDice failed:', err);
showToast('playDice failed: ' + (err?.message || String(err)), 'error');
} finally {
setLoading(false);
setTxStatus('');
}
Step 8: Key Learnings and Best Practices
8.1 FHEVM Integration Best Practices
- Always initialize the SDK: Ensure proper initialization before use
- Handle encryption errors: Implement robust error handling
- Manage permissions: Use
FHE.allow()
appropriately - Validate inputs: Always validate encrypted inputs before processing
Conclusion
You've successfully built a privacy-preserving dice game using Zama's FHEVM technology. This tutorial covered:
- Smart contract development with FHEVM
- Frontend integration with @fhevm/react
- Encryption and decryption workflows
- Privacy-preserving game mechanics
- Frontend deployment
Quiz
FHEVM Relayer SDK Quiz
Test your understanding of building privacy-preserving applications with Zama's FHEVM Relayer SDK