A Zama's FHEVM RPS Solidity implementation that allows players to play Rock-Paper-Scissors games privately.
The following guidelines were followed when designing the smart contract:
-
Players' addresses and moves are kept encrypted and are only accessible to themselves.
-
For HCU optimization, plays are encoded as an
euint8as follows:- 🪨 : 1
- 🧻 : 2
- ✂️ : 3
It is the dApp developer's responsibility to ensure that the values passed to the smart contract are within this range. Instead of performing a validity check on the provided
euint8(which would be expensive in terms of HCU), the smart contract will automatically adjust any value outside the range to match the expected values. -
When the game is marked as solved, the game result can be decrypted by anyone on the client side. The game result is encoded as an
euint8value following this format:- 0: game not solved yet
- 1: host wins
- 2: guest wins
- 3: draw
Any user on the chain can create a new Rock-Paper-Scissors game. While creating the game, the user must include their
play in the transaction. The play and the host address will be stored encrypted in a Game struct in the contract. When
a game is created, a GameCreated event is emitted, including the gameId of the newly created game.
Any user can also join an already created game by passing its gameId to the smart contract. The guest player must
submit their play in the joining transaction as well. Then, the game outcome will be calculated and stored in the Game
struct, encrypted, making the result publicly decryptable.
Finally, anyone can query the encryptedResult for a certain gameId that has been marked as gameSolved and decrypt
it on the client side.
To avoid unnecessary HCU costs, the algorithm for game resolution has been implemented using bitwise operations,
allowing for cheaper computations. When both plays are received by the smart contract, it first checks if they are the
same, resulting in a draw. Then, it packs the binary representation of both moves into a single euint8 variable. For
instance, if the host played 🧻 (0010) and the guest played ✂️ (0011), the packed moves will equal 1011, obtained
by shifting 🧻 two positions to the right and adding ✂️ in the last two bits. This results in a matrix of 9 possible
game outcomes, with their decimal values being (7, 9, 14) for the host winning and (6, 11, 13) for the guest
winning. To resolve the game, a boolean AND operation is performed on an euint16 variable with its 7th, 9th, and
14th bits set to one (equivalent to the decimal number 17024) and another euint16 variable with only the bit
corresponding to its packed move set to 1. If the result of this operation is 0, the host won; otherwise, the guest
won the game.
| Host Move | Guest Move | Packed Value (Binary) | Packed Value (Decimal) | Outcome |
|---|---|---|---|---|
| Rock (1) | Rock (1) | 0101 | 5 | Draw |
| Rock (1) | Paper (2) | 0110 | 6 | Guest Wins |
| Rock (1) | Scissors (3) | 0111 | 7 | Host Wins |
| Paper (2) | Rock (1) | 1001 | 9 | Host Wins |
| Paper (2) | Paper (2) | 1010 | 10 | Draw |
| Paper (2) | Scissors (3) | 1011 | 11 | Guest Wins |
| Scissors (3) | Rock (1) | 1101 | 13 | Guest Wins |
| Scissors (3) | Paper (2) | 1110 | 14 | Host Wins |
| Scissors (3) | Scissors (3) | 1111 | 15 | Draw |
Developers building decentralized applications (dApps) on top of the FHERPS smart contract should consider the
following:
{% hint style="danger" %} Input Validation is Crucial: The smart contract intentionally skips move validation (checking if the input is 1, 2, or 3) to save on HCU costs. This means your dApp must validate user input before encrypting it and sending it to the contract. If an invalid value is submitted, the contract will not revert, but the game logic will produce an incorrect result. {% endhint %}
{% hint style="info" %} Asynchronous and Anonymous Game Flow: The contract is designed for anonymous gameplay. A user can create a game without revealing their identity, and any other user can join an existing game. To manage this, your dApp should:
- Track
GameCreatedEvents: Listen forGameCreatedevents to build a list of available games for users to join. - Track
GameSolvedEvents: Listen forGameSolvedevents to remove games from the available list. - Check Game Status: Before a user attempts to join a game, use the
solved(gameId)function to ensure it hasn't already been taken by another player. {% endhint %}
{% hint style="info" %}
Decrypting Game Results: The game result is stored as an encrypted euint8. Your dApp will need to call the encryptedResult(gameId) view function and then use the fhevm.publicDecryptEuint method to decrypt the value on the client side, as shown in the usage examples.
{% endhint %}
{% hint style="success" %} User Experience for Anonymous Play: Since the gameplay is anonymous, your dApp should focus on providing a seamless experience for discovering and joining open games. The core user interaction should be browsing a list of available games and joining one. {% endhint %}
This section provides examples of how to interact with the FHERPS smart contract using the provided Hardhat tasks.
First, you need to deploy the contract to your network of choice (e.g., localhost for local testing or sepolia for the testnet).
npx hardhat deploy --network localhostOnce deployed, you can retrieve the contract address using the task:address task:
npx hardhat task:address --network localhost
# Output: FHERPS address is 0x...To create a new game, a user (the "host") must submit their move (1 for Rock, 2 for Paper, 3 for Scissors). The task task:create-game handles the encryption of the move and sends it to the contract.
Command:
# Replace --move 1 with 2 (Paper) or 3 (Scissors) as desired.
npx hardhat task:create-game --move 1 --network localhostThe task will output the gameId of the newly created game, which is needed for other players to join.
Code Snippet from tasks/FHERPS.ts:
This is how the move is encrypted and the createGameAndSubmitMove function is called.
import { FhevmType } from "@fhevm/hardhat-plugin";
import { ethers, deployments, fhevm } from "hardhat";
// Assume contract, signers, and move are already initialized
const FHERPSDeployement = await deployments.get("FHERPS");
const signers = await ethers.getSigners();
const fheRpsContract = await ethers.getContractAt("FHERPS", FHERPSDeployement.address);
const move = 1; // Example move: Rock
// Encrypt the move for the host (signer[0])
const encryptedValue = await fhevm
.createEncryptedInput(FHERPSDeployement.address, signers[0].address)
.add8(move)
.encrypt();
// Call the contract to create the game
const tx = await fheRpsContract
.connect(signers[0])
.createGameAndSubmitMove(encryptedValue.handles[0], encryptedValue.inputProof);
// The contract will emit a 'GameCreated' event with the gameIdAnother user (the "guest") can join an existing game using its gameId. They also submit their encrypted move.
Command:
# Replace --gameid 0 with the actual gameId.
# Replace --move 2 with the guest's move.
npx hardhat task:join-game --gameid 0 --move 2 --network localhostCode Snippet from tasks/FHERPS.ts:
The guest's move is encrypted and sent to the joinGameAndSubmitMove function.
import { FhevmType } from "@fhevm/hardhat-plugin";
import { ethers, deployments, fhevm } from "hardhat";
// Assume contract, signers, gameId, and move are initialized
const FHERPSDeployement = await deployments.get("FHERPS");
const signers = await ethers.getSigners();
const guestSigner = signers[1]; // Assuming the guest is the second account
const fheRpsContract = await ethers.getContractAt("FHERPS", FHERPSDeployement.address);
const gameId = 0;
const move = 2; // Example move: Paper
// Encrypt the move for the guest
const encryptedValue = await fhevm
.createEncryptedInput(FHERPSDeployement.address, guestSigner.address)
.add8(move)
.encrypt();
// Call the contract to join the game
const tx = await fheRpsContract
.connect(guestSigner)
.joinGameAndSubmitMove(gameId, encryptedValue.handles[0], encryptedValue.inputProof);Once a guest joins a game, the result is calculated and stored encrypted in the contract. Anyone can query this encrypted result and decrypt it on the client-side.
Command:
npx hardhat task:get-result-and-decrypt --gameid 0 --network localhostThe task will output the encrypted result and the decrypted "clear" result (1 for host wins, 2 for guest wins, 3 for a draw).
Code Snippet from tasks/FHERPS.ts:
This shows how to fetch the encrypted result and decrypt it.
import { FhevmType } from "@fhevm/hardhat-plugin";
import { ethers, deployments, fhevm } from "hardhat";
// Assume contract, gameId are initialized
const FHERPSDeployement = await deployments.get("FHERPS");
const fheRpsContract = await ethers.getContractAt("FHERPS", FHERPSDeployement.address);
const gameId = 0;
// Get the encrypted result from the contract
const encryptedResult = await fheRpsContract.encryptedResult(gameId);
// Decrypt the result publicly
const clearResult = await fhevm.publicDecryptEuint(
FhevmType.euint8,
encryptedResult
);
console.log(`Game ${gameId} result: ${clearResult}`);