Create a new folder named coin_pusher in the root directory to serve as the contract project folder. Then, navigate into this folder and install the required dependencies, Hardhat and Sight Oracle, by running the following commands:
cd coin_pusher
npm install --save-dev hardhat @sight-oracle/contracts dotenv
After successfully installing the dependencies, proceed to create a basic Hardhat project. In the previously created coin_pusher folder, run the following command:
npx hardhat init
Letβs choose create a JavaScript or TypeScript project. We recommend choosing TypeScript, but if youβre not familiar with it, simply select JavaScript.
Writing Contract
Copy the following code into /contracts/CoinPusher.sol.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@sight-oracle/contracts/Oracle/Types.sol";
import "@sight-oracle/contracts/Oracle/Oracle.sol";
import "@sight-oracle/contracts/Oracle/ResponseResolver.sol";
enum State {
Initial,
Launching, // is setting target
Launched, // target set done, user can play
Completed, //
Revealing,
Revealed
}
// The FHE Coin Pusher Contract
contract CoinPusher is Ownable2Step {
// Use Sight Oracle's RequestBuilder and ResponseResolver to interact with Sight Oracle
using RequestBuilder for Request;
using ResponseResolver for CapsulatedValue;
event TargetSet(uint64 min, uint64 max);
event Deposit(address indexed requester, uint64 indexed amount, uint64 sum);
event DepositConfirmed(
address indexed requester,
uint64 indexed amount,
uint64 sum
);
event TargetRevealed(uint64 target);
event GameComplete(address indexed winner, uint64 sum);
Oracle public oracle;
State private _state;
uint64 _min;
uint64 _max;
CapsulatedValue private _encrypted_target;
uint64 private _plaintext_target; // not revealed until game ends, in micro-ether unit
uint64 private _sum; // in micro-ether unit
address private _winner;
mapping(address => uint64) internal deposits; // in micro-ether unit
mapping(address => bool) internal users; // can only deposit once
mapping(bytes32 => bytes) requestExtraData;
struct GameStatus {
bool isComplete;
address winner;
uint64 target;
State state;
uint64 sum;
}
constructor(address oracle_, uint64 min, uint64 max) payable {
oracle = Oracle(payable(oracle_));
setTarget(min, max);
_state = State.Launching;
// initialize status
_winner = address(0);
_sum = 0;
}
function setTarget(uint64 min, uint64 max) private onlyOwner {
require(_state != State.Launching, "Game is not complete!");
require(max > min, "require max > min");
// clear up
_plaintext_target = 0;
_min = min;
_max = max;
// Initialize new FHE computation request of 3 steps.
Request memory r = RequestBuilder.newRequest(
msg.sender,
3,
address(this),
this.setTarget_cb.selector,
""
);
uint64 range = max - min + 1;
uint64 shards = (type(uint64).max / range + 1);
// Step 1: generate random value
op encryptedValueA = r.rand();
// Step 2 - 3: limit the random value into range min - max
op scaled_random_value = r.div(encryptedValueA, shards);
r.add(scaled_random_value, min);
// Send the request via Sight FHE Oracle
oracle.send(r);
}
// only Oracle can call this
function setTarget_cb(
bytes32 /* requestId */,
CapsulatedValue[] memory EVs
) public onlyOracle {
// Decode value from Oracle callback
_encrypted_target = EVs[EVs.length - 1];
_state = State.Launched;
emit TargetSet(_min, _max);
}
// user make a deposit
function deposit(uint8 flag) public payable {
require(
_state == State.Launched,
"Game not launched or Game already Completed."
);
require(users[msg.sender] == false, "You have already deposited");
uint64 amount = 0;
uint256 value;
if (flag == 0) {
amount = 1000; // This could represent 1000 microether (0.001 ETH)
value = 0.001 ether;
} else if (flag == 1) {
amount = 5000; // This could represent 5000 microether (0.005 ETH)
value = 0.005 ether;
} else if (flag == 2) {
amount = 10000; // This could represent 10000 microether (0.01 ETH)
value = 0.01 ether;
} else {
revert("only flag 1 - small, 2 - medium, 3 - large allowed");
}
// Check if the sent value matches the required amount
require(msg.value == value, "Incorrect payment amount.");
users[msg.sender] = true;
deposits[msg.sender] = amount;
_sum = _sum + amount;
// Initialize new FHE computation request of 3 steps.
Request memory r = RequestBuilder.newRequest(
msg.sender,
3,
address(this),
this.deposit_cb.selector,
""
);
// Step 1: load local stored encrypted target into request processing context
op e_target = r.getEuint64(_encrypted_target.asEuint64());
// Step 2: compare balance and encrypted_target
op e_greater = r.ge(_sum, e_target);
// Step 3: decrypt the comparison result, it is safe to reveal
r.decryptEbool(e_greater);
// requestExtraData[r.id] = abi.encode(msg.sender, amount, _sum);
// send request to Sight FHE Oracle
oracle.send(r);
emit Deposit(msg.sender, amount, _sum);
}
// only Oracle can call this
function deposit_cb(
bytes32 requestId,
CapsulatedValue[] memory EVs
) public onlyOracle {
if (_state != State.Launched) {
return;
}
bytes memory extraData = requestExtraData[requestId];
(address requester, uint64 amount, uint64 sum) = abi.decode(
extraData,
(address, uint64, uint64)
);
emit DepositConfirmed(requester, amount, sum);
// Check winning condition
// CapsulatedValue 0: the encrypted target
// CapsulatedValue 1: the encrypted compare result
// CapsulatedValue 2: the decrypted compare result, as used here
bool isWinner = EVs[EVs.length - 1].asBool();
if (isWinner) {
_winner = requester;
_state = State.Completed;
emit GameComplete(_winner, _sum);
revealTarget();
}
}
// Reveal the target
function revealTarget() private {
require(_state == State.Completed, "Game is not complete!");
_state = State.Revealing;
// Initialize new FHE computation request of 2 steps.
Request memory r = RequestBuilder.newRequest(
msg.sender,
2,
address(this),
this.revealTarget_cb.selector,
""
);
// Step 1: load encrypted target into processing context
op e_target = r.getEuint64(_encrypted_target.asEuint64());
// Step 2: decrypt the target
r.decryptEuint64(e_target);
oracle.send(r);
}
// only Oracle can call this
function revealTarget_cb(
bytes32 /* requestId */,
CapsulatedValue[] memory EVs
) public onlyOracle {
CapsulatedValue memory wrapped_plaintext_target = EVs[EVs.length - 1];
// unwrap the plaintext value
_plaintext_target = wrapped_plaintext_target.asUint64();
_state = State.Revealed;
emit TargetRevealed(_plaintext_target);
}
modifier onlyOracle() {
require(msg.sender == address(oracle), "Only Oracle Can Do This");
_;
}
function depositOf(address addr) public view returns (uint64) {
return deposits[addr];
}
function getGameStatus() public view returns (GameStatus memory) {
return
GameStatus({
isComplete: _state == State.Completed,
winner: _winner,
target: _plaintext_target,
state: _state,
sum: _sum
});
}
function withdraw() external onlyOwner {
require(
_state == State.Completed ||
_state == State.Revealing ||
_state == State.Revealed,
"Invalid state"
);
require(address(this).balance > 0, "No funds to withdraw");
payable(owner()).transfer(address(this).balance);
}
fallback() external payable {}
receive() external payable {}
}
This is the core contract code for the entire game, ensuring that the game is fair, secure, and transparent. It may seem complex, but donβt worryβweβll break it down and provide a clear, detailed explanation of each section.
enum State {
Initial,
Launching, // is setting target
Launched, // target set done, user can play
Completed, //
Revealing,
Revealed
}
First, we defined an enum type called State, which will be used to track the gameβs status.
contract CoinPusher is Ownable2Step {
// Use Sight Oracle's RequestBuilder and ResponseResolver to interact with Sight Oracle
using RequestBuilder for Request;
using ResponseResolver for CapsulatedValue;
Ownable2Step is a contract for ownership control that inherits from Ownable and offers enhanced security compared to Ownable.
The contract uses the RequestBuilder library to construct requests and the ResponseResolver library to interpret the responses.
Here are some Event definitions. An Event is a mechanism for recording changes in the contractβs state, allowing external applications to listen to and query these changes. You can use emit to trigger an Event.
Oracle public oracle;
State private _state;
uint64 _min;
uint64 _max;
CapsulatedValue private _encrypted_target;
uint64 private _plaintext_target; // not revealed until game ends, in micro-ether unit
uint64 private _sum; // in micro-ether unit
address private _winner;
mapping(address => uint64) internal deposits; // in micro-ether unit
mapping(address => bool) internal users; // can only deposit once
mapping(bytes32 => bytes) requestExtraData;
struct GameStatus {
bool isComplete;
address winner;
uint64 target;
State state;
uint64 sum;
}
This code defines several state variables:
oracle is an instance of Oracle used to handle cryptographic operations and send requests.
_state tracks the current state of the game.
_min and _max represent the minimum and maximum range of the gameβs target value.
_encrypted_target and _plaintext_target store the encrypted and decrypted target values.
_sum records the current total deposit amount.
_winner holds the winnerβs address.
deposits keeps track of each playerβs deposit amount.
users indicates whether a user has participated in the game.
requestExtraData contains additional data for requests sent to the Oracle.
GameStatus is a data structure that simplifies checking the status of various game variables.
The constructor function takes three parameters, which are used to initialize the basic settings and state of the game. These parameters must be provided when deploying the contract.
SetTarget Function
function setTarget(uint64 min, uint64 max) private onlyOwner {
require(_state != State.Launching, "Game is not complete!");
require(max > min, "require max > min");
// clear up
_plaintext_target = 0;
_min = min;
_max = max;
// Initialize new FHE computation request of 3 steps.
Request memory r = RequestBuilder.newRequest(
msg.sender,
3,
address(this),
this.setTarget_cb.selector,
""
);
uint64 range = max - min + 1;
uint64 shards = (type(uint64).max / range + 1);
// Step 1: generate random value
op encryptedValueA = r.rand();
// Step 2 - 3: limit the random value into range min - max
op scaled_random_value = r.div(encryptedValueA, shards);
r.add(scaled_random_value, min);
// Send the request via Sight FHE Oracle
oracle.send(r);
}
The setTarget function is called once within the constructor to initialize the target range.
The onlyOwner modifier from the Ownable2Step contract, restricts access to the setTarget function, allowing only the contract owner to call it. A new request is created using RequestBuilder.newRequest, which specifies the sender, the number of computation steps, the callback address, and the callback function. The rand function is then called to generate a random encrypted value, which is stored in the Sight Network. To ensure the generated random value falls within the specified min and max range, the encrypted div and add functions are used for secure division and addition operations. Finally, the send function submits the request to the Sight Oracle.
setTarget_cb function
function setTarget_cb(
bytes32 /* requestId */,
CapsulatedValue[] memory EVs
) public onlyOracle {
// Decode value from Oracle callback
_encrypted_target = EVs[EVs.length - 1];
_state = State.Launched;
emit TargetSet(_min, _max);
}
setTarget_cb is a callback function (with βcbβ standing for callback) that has an onlyOracle modifier, meaning it can only be called by the Oracle contract. EVs[EVs.length - 1] will retrieve the last element from the EVs array and assign it to _encrypted_target, which means storing the encrypted value returned from the Oracle in _encrypted_target. Then, the _state is updated to Launched, indicating that the game has started. Finally, the TargetSet event is triggered to notify listeners that the target range has been set.
deposit function
function deposit(uint8 flag) public payable {
require(
_state == State.Launched,
"Game not launched or Game already Completed."
);
require(users[msg.sender] == false, "You have already deposited");
uint64 amount = 0;
uint256 value;
if (flag == 0) {
amount = 1000; // This could represent 1000 microether (0.001 ETH)
value = 0.001 ether;
} else if (flag == 1) {
amount = 5000; // This could represent 5000 microether (0.005 ETH)
value = 0.005 ether;
} else if (flag == 2) {
amount = 10000; // This could represent 10000 microether (0.01 ETH)
value = 0.01 ether;
} else {
revert("only flag 1 - small, 2 - medium, 3 - large allowed");
}
// Check if the sent value matches the required amount
require(msg.value == value, "Incorrect payment amount.");
users[msg.sender] = true;
deposits[msg.sender] = amount;
_sum = _sum + amount;
// Initialize new FHE computation request of 3 steps.
Request memory r = RequestBuilder.newRequest(
msg.sender,
3,
address(this),
this.deposit_cb.selector,
""
);
// Step 1: load local stored encrypted target into request processing context
op e_target = r.getEuint64(_encrypted_target.asEuint64());
// Step 2: compare balance and encrypted_target
op e_greater = r.ge(_sum, e_target);
// Step 3: decrypt the comparison result, it is safe to reveal
r.decryptEbool(e_greater);
// send request to Sight FHE Oracle
bytes32 lastReqId = oracle.send(r);
requestExtraData[lastReqId] = abi.encode(msg.sender, amount, _sum);
emit Deposit(msg.sender, amount, _sum);
}
The deposit function is used to handle user deposits behavior. When calling deposit, you need to pass in a parameter of 0, 1, or 2, and send the corresponding amount of Ether. These parameters correspond to three different deposit amounts. Once the player's deposit is confirmed successful, the user is marked as having deposited, and the user's deposit amount and the total deposit amount _sum are updated. Then, a request is initialized through RequestBuilder.newRequest. The getEuint64 function will load the locally stored encrypted target value into the request's processing context, and the ge function will compare _sum with e_target. ge stands for greater than or equal to. If _sum is greater than or equal to e_target, the result will be true; otherwise, it will be false. decryptEbool is used to decrypt the encrypted boolean value. Finally, record some information about the request in requestExtraData.
deposit_cb function
function deposit_cb(
bytes32 requestId,
CapsulatedValue[] memory EVs
) public onlyOracle {
if (_state != State.Launched) {
return;
}
bytes memory extraData = requestExtraData[requestId];
(address requester, uint64 amount, uint64 sum) = abi.decode(
extraData,
(address, uint64, uint64)
);
emit DepositConfirmed(requester, amount, sum);
// Check winning condition
// CapsulatedValue 0: the encrypted target
// CapsulatedValue 1: the encrypted compare result
// CapsulatedValue 2: the decrypted compare result, as used here
bool isWinner = EVs[EVs.length - 1].asBool();
if (isWinner) {
_winner = requester;
_state = State.Completed;
emit GameComplete(_winner, _sum);
revealTarget();
}
}
First, retrieve the extraData corresponding to the requestId from requestExtraData. Then, decode the extraData to obtain information about the deposit request, and trigger the DepositConfirmed event to indicate that the deposit transaction has been successfully completed. The line of code EVs[EVs.length - 1].asBool() retrieves the last CapsulatedValue from the EVs array and assigns its boolean value to isWinner. If isWinner is true, it means the sender of this request has won the competition. Therefore, update _winner to requester and change the game's status to Completed. Finally, call revealTarget to convert the encrypted target value into plaintext.
revealTarget function
function revealTarget() private {
require(_state == State.Completed, "Game is not complete!");
_state = State.Revealing;
// Initialize new FHE computation request of 2 steps.
Request memory r = RequestBuilder.newRequest(
msg.sender,
2,
address(this),
this.revealTarget_cb.selector,
""
);
// Step 1: load encrypted target into processing context
op e_target = r.getEuint64(_encrypted_target.asEuint64());
// Step 2: decrypt the target
r.decryptEuint64(e_target);
oracle.send(r);
}
The revealTarget function loads the locally stored encrypted target value _encrypted_target into the request's processing context by using getEuint64 and assigns it to e_target. It then decrypts e_target by performing a decryption operation with decryptEuint64.
revealTarget_cb function
function revealTarget_cb(
bytes32 /* requestId */,
CapsulatedValue[] memory EVs
) public onlyOracle {
CapsulatedValue memory wrapped_plaintext_target = EVs[EVs.length - 1];
// unwrap the plaintext value
_plaintext_target = wrapped_plaintext_target.asUint64();
_state = State.Revealed;
emit TargetRevealed(_plaintext_target);
}
In the revealTarget_cb function, the plaintext of the target returned by the Oracle will be stored in _plaintext_target, then the game status will be updated to Revealed and the TargetRevealed event will be emitted.
The above describes the core functionalities of the Coin Pusher game. There are also some functions for reading contract states, but I won't go into detail about those. Next, let's proceed to deploy the contract to the Sepolia test network.
Deploy Contract
To deploy the contract on the Sepolia test network, you first need to obtain some Sepolia test tokens and the Sepolia RPC URL.
If you donβt have a Sepolia RPC yet, you can click here to require one.
Note: You can get the private key through MetaMask by going to Account Details => Export Private Key (Please use a test wallet, as leaking the private key poses a risk of account theft!).
buildModule: The first aspect to note is that modules are created by calling the buildModule function, which requires a module ID and a callback function. Our module will be identified as "Coin_PusherModule".
m: The m parameter being passed into the callback is an instance of a ModuleBuilder, which is an object with methods to define and configure your smart contract instances.
oracleAddr:oracleAddr is the address of a Sight Oracle, used for requesting and responding to data. The address is chosen based on the network.
m.contract("CoinPusher", [oracleAddr, 10000, 50000]): Deploys the CoinPusher contract using Ignition and passes the Oracle address as a constructor parameter. 10000 and 50000 are the minimum and maximum values for initializing the target.
After a successful deployment, you will see some deployment information printed to the console, including the Example contract address.
After deploying the contract, you will find a new file named deployments/chain-11155111 under the ignition directory. This file contains the contract's ABI and contract address, which we will need to use when building the frontend to interact with the contract.
Congratulations on completing the smart contract part of the dapp! Take a break, and then click to join us in building the frontend pages of the dapp.