A Price Prediction DApp with off-chain Oracles - Tutorial
Henry Onyebuchi
Founder @ Juggervault Finance | Smart Contract Engineer, Security Researcher & Educator | Backend Developement | DeFi & NFT Specialist | dApp & Web3 Consultant | Tokenomics Expert
In this article, I will show you how to build a price prediction smart contract using data from off-chain oracles to fulfill prediction rounds and winnings.
The concept we are going to adopt should be able to provide the following features.
For the network, I will be using Mantle Network testnet for the deployment, and Supra off-chain data for price feeds.
About Mantle Network
The Mantle network is a high-performance Ethereum layer-2 network built with modular architecture delivering low fees and high security. We will be leveraging its testnet to host our smart contract.
About Supra Oracles
Supra Oracles provide off-chain price data and verifiable random figures (VRF) across multiple networks.
We would be using Supra Oracles because they provide services on the Mantle network where we are deploying our smart contract.
Development
We are going to use the Hardhat framework for this project. So we need to do the following steps to install it.
Make sure you have Node installed on your device. If you do not have this set up, you may follow this guide to get started.
Now, create a folder for the project, navigate into the folder, and install the necessary hardhat dependencies.
mkdir MyProject
cd MyProject
npm init -y
npm install --save-dev hardhat ethers @nomiclabs/hardhat-ethers
Now initialize a hardhat project and create an empty hardhat config file.
npx hardhat
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888
?? Welcome to Hardhat v2.9.9 ??
? What do you want to do? …
Create a JavaScript project
Create a TypeScript project
? Create an empty hardhat.config.js
Quittt
Configure the hardhat config file to look like this:
require('@nomiclabs/hardhat-ethers');
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.17",
};
In the root of the project, create a contracts folder and add a MyContract.sol file in the folder to hold the smart contract logic.
mkdir contracts && cd contracts && touch MyContract.sol
Now start writing your smart contract logic. First, we need to define the version of Solidity to use for this project. I will be using 0.8.17 for this project as is in the hardhat config file.
Then we need to import some dependencies for admin functionality and other utilities like counters to perform incrementation in the most optimized way. For this, we will use Openzeppelin, but first, we need to install it.
npm install @openzeppelin/contracts
Now we begin our code with the necessary importations
领英推荐
pragma solidity 0.8.17;
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/utils/Counters.sol';
MyContract is Ownable {
}
Let us define a struct type Batch which should have data associated with prediction rounds round. It would look like this.
struct Batch {
uint256 id; // the id of the round
uint256 ticketCost; // the cost for purchasing one ticket
uint256 totalTickets; // the total number of tickets that have been purchased in this round
uint256 totalBalanceAfterFee; // the balance left for winners to share after platform fee deduction
uint256 startTimestamp; // the unix timestamp to start accepting predictions
uint256 closeTimestamp; // the unix timestamp to stop accepting predictions. This should be the closing time of the round
int256 winPrice; // the win price returned by the price oracle
address[] winners; // list of all the winners in this round
string pair; // assets pair to check on the price oracle
bool isClosed; // true/false if the round is closed
}
Now, we need to integrate the interface of SupraOracles, activate the Counters library, and define some state variables.
pragma solidity 0.8.17;
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/utils/Counters.sol';
interface ISupraSValueFeed {
function checkPrice(string memory marketPair)
external
view
returns (int256 price, uint256 timestamp);
}
MyContract is Ownable {
using Counters for Counters.Counter;
struct Batch {
uint256 id; // the id of the round
uint256 ticketCost; // the cost for purchasing one ticket
uint256 totalTickets; // the total number of tickets that have been purchased in this round
uint256 totalBalanceAfterFee; // the balance left for winners to share after platform fee deduction
uint256 startTimestamp; // the unix timestamp to start accepting predictions
uint256 closeTimestamp; // the unix timestamp to stop accepting predictions. This should be the closing time of the round
int256 winPrice; // the win price returned by the price oracle
address[] winners; // list of all the winners in this round
string pair; // assets pair to check on the price oracle
bool isClosed; // true/false if the round is closed
}
ISupraSValueFeed private sValueFeed; // pointer to supra router
Counters.Counter private _id; // incrementing round ids
uint256 public fee; // platform fee for revenue. 1% = 1000
uint256 public available; // to show available funds from fees
address public routerAddress = 0x700a89Ba8F908af38834B9Aba238b362CFfB665F; // router address for Mantle testnet on Supra
}
Now, we set the value of our state variable fee, and integrate the method to retrieve prices from SupraOracles. But first, we must point the contract to the router address. We will do this in the constructor. We may as well whitelist some pairs we wish to unlock in our contract, so we will go ahead to create an authorized whitelisting method to enable this.
Since we intend to only use whitelisted pairs on the prediction market, we would need a mapping to store valid pairs. We will also enable batch whitelisting to allow the admin to add as many pairs as needed in one transaction. We will include an event for this method as well.
// mapping of pair to boo
mapping(string => bool) isValidPair;
event Whitelist(address indexed caller, string[] pairs);
constructor(uint256 _fee, string[] memory pairs) Ownable() {
fee = _fee;
whitelist(pairs);
sValueFeed = ISupraSValueFeed(routerAddress);
}
function whitelist(string[] memory pairs) public onlyOwner {
uint256 len = pairs.length;
for (uint256 i = 0; i < len; i++) {
isValidPair[pairs[i]] = true;
}
emit Whitelist(msg.sender, pairs);
}
function getPrice(string memory pair)
public
view
returns (int256 price, uint256 timestamp)
{
return sValueFeed.checkPrice(pair);
}
Now that we have created a route to retrieve price feeds, we will go ahead to create a method for the admin to release a new round of predictions. Admin needs to be able to determine the cost per ticket for the newly created batch, as well as the supported pair and prediction window.
For this, we need access to the Batch struct we defined, hence, we will include a public mapping to access it. For every newly created batch, we will increment our private state variable, _id. Let's also not forget to include an event for this, NewPrediction.
// mapping ids to Batch
mapping(uint256 => Batch) public batches;
event NewPrediction(
uint256 id,
uint256 indexed ticketPrice,
string indexed pair,
uint256 startTimestamp,
uint256 endTimestamp
);
function newPrediction(
uint256 ticketCost, // cost per ticket
uint256 startsIn, // how many seconds before prediction starts
uint256 lastsFor, // prediction window duration
string calldata pair // whitelisted pair supported by SupraOracles
) external onlyOwner returns (uint256 id) {
bool valid = isValidPair[pair];
require(valid, "INVALID_PAIR");
_id.increment();
id = _id.current();
uint256 start = block.timestamp + startsIn;
uint256 end = start + lastsFor;
Batch storage b = batches[id];
b.id = id;
b.ticketCost = ticketCost;
b.startTimestamp = start;
b.closeTimestamp = end;
b.pair = pair;
emit NewPrediction(id, ticketCost, pair, start, end);
};
We now create the methods we need for predictors to predict on rounds, and also one to resolve/close the rounds at the closing time. We need a mapping to store batch predictions and their predictors.
For the prediction method, we should ensure that predictions are only possible during the prediction window. We need to allow predictors to pick multiple tickets in one transaction. They also need to pay for the ticket cost when making the transaction.
For the resolve method, we need to restrict it to only admins and make sure it only works once for each round. Ideally, we want this method to be called exactly at the closing time. We have the option of building a native backend or adopting existing relay services like OpenZeppelin Defender, Chainlink Keeper, Gelato Network, or any other of choice.
We also should collect fees when resolving a batch. For this project, we will first deduct the fees for the platform revenue, then share what is left with all the winners. Also, enable admin to get all the raised funds if there is no winner.
We need to make sure that a valid batch is being predicted and resolved when the above methods are executed, hence, we will create a modifier realId to ensure this on these methods. Do not forget to include events for these methods.
// mapping ids to prediction to predictors
mapping(uint256 => mapping(int256 => address[])) private predictions;
event Predict(
address indexed user,
uint256 indexed id,
int256[] predictions
);
event Resolve(
address indexed caller,
uint256 id,
int256 price,
uint256 timestamp,
address[] winners
);
modifier realId(uint256 id) {
Batch memory b = batches[id];
require(b.startTimestamp != 0, "INVALID_ID");
_;
}
function predict(uint256 id, int256[] calldata _predictions)
external
payable
realId(id)
{
Batch memory b = batches[id];
require(
b.closeTimestamp > block.timestamp &&
b.startTimestamp < block.timestamp,
"PREDICTION_NOT_OPEN"
);
uint256 len = _predictions.length;
uint256 cost = b.ticketCost * len;
require(cost == msg.value, "WRONG_PAYMENT_VALUE");
for (uint256 i = 0; i < len; i++) {
activity[msg.sender][id].push(_predictions[i]);
predictions[id][_predictions[i]].push(msg.sender);
}
Batch storage _b = batches[id];
unchecked {
_b.totalTickets += len;
}
emit Predict(msg.sender, id, _predictions);
}
function resolve(uint256 id) external onlyOwner realId(id) {
Batch memory b = batches[id];
require(block.timestamp > b.closeTimestamp, "NOT_YET");
require(!b.isClosed, "ALREADY_RESOLVED");
Batch storage _b = batches[id];
_b.isClosed = true;
// Get the win price from Supra Oracles
(int256 price, uint256 timestamp) = getPrice(b.pair);
_b.winPrice = price;
uint256 feeAmt;
uint256 raised = b.ticketCost * b.totalTickets;
uint256 len = predictions[id][price].length;
if (len == 0) {
feeAmt = raised;
} else {
uint256 _fee = fee;
feeAmt = (raised * _fee) / (1000 * 100);
uint256 calc = raised - feeAmt;
_b.totalBalanceAfterFee = calc;
_b.winners = predictions[id][price];
}
unchecked {
available += feeAmt;
}
b = batches[id];
emit Resolve(msg.sender, id, price, timestamp, b.winners);
}
We are almost done with our contract logic. What is left to add are methods to allow winners and admins to withdraw wins and revenue respectively. So we will create methods for this: withdrawWin() and collectFee().
First, we shall provide a public method result() to tell if a user is a winner in a prediction round, as well as a mapping that tells when a user has withdrawn wins from a given round
// mapping to show which user claimed wins from batch id
mapping(address => mapping(uint256 => bool)) public claimed;
event CollectFee(address indexed wallet, uint256 available);
event WithdrawWin(
address indexed caller,
uint256 indexed id,
uint256 amount
);
function result(address addr, uint256 id)
public
view
realId(id)
returns (bool isWinner, uint256 winTickets)
{
Batch memory b = batches[id];
uint256 len = b.winners.length;
if (len > 0) {
uint256 num;
for (uint256 i = 0; i < len; i++) {
if (b.winners[i] == addr) num++;
}
if (num > 0) {
(isWinner, winTickets) = (true, num);
}
}
}
function withdrawWin(uint256 id) external {
bool clm = claimed[msg.sender][id];
require(!clm, "ALREADY_CLAIMED");
(, uint256 count) = result(msg.sender, id);
require(count > 0, "NOT_WINNER");
Batch memory b = batches[id];
uint256 amt = (b.totalBalanceAfterFee / b.winners.length) * count;
claimed[msg.sender][id] = true;
_transfer(payable(msg.sender), amt);
emit WithdrawWin(msg.sender, id, amt);
}
function collectFee(address payable wallet) external onlyOwner {
uint256 avail = available;
require(avail > 0, "NOTHING_TO_WITHDRAW");
available = 0;
_transfer(wallet, avail);
emit CollectFee(wallet, avail);
}
function _transfer(address payable wallet, uint256 amt) private {
(bool success, ) = wallet.call{value: amt}("");
require(success, "WITHDRAWAL_ERROR");
}
And we are done. We have now successfully created a price prediction market using an off-chain price oracle. There are of course multiple ways of improving the code. One of them would be aggregating predictors' wins across multiple prediction rounds. This way, they won't have to withdraw their wins on different rounds in many transactions.
Here's the full code:
//SPDX-License-Identifier: UNLICENSE
pragma solidity 0.8.17;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
interface ISupraSValueFeed {
function checkPrice(string memory marketPair)
external
view
returns (int256 price, uint256 timestamp);
}
contract MyContract is Ownable {
using Counters for Counters.Counter;
struct Batch {
uint256 id; // the id of the round
uint256 ticketCost; // the cost for purchasing one ticket
uint256 totalTickets; // the total number of tickets that have been purchased in this round
uint256 totalBalanceAfterFee; // the balance left for winners to share after platform fee deduction
uint256 startTimestamp; // the unix timestamp to start accepting predictions
uint256 closeTimestamp; // the unix timestamp to stop accepting predictions. This should be the closing time of the round
int256 winPrice; // the win price returned by the price oracle
address[] winners; // list of all the winners in this round
string pair; // assets pair to check on the price oracle
bool isClosed; // true/false if the round is closed
}
ISupraSValueFeed private sValueFeed; // pointer to supra router
Counters.Counter private _id; // incrementing round ids
uint256 public fee; // platform fee for revenue. 1% = 1000
uint256 public available; // to show available funds from fees
address public routerAddress = 0x700a89Ba8F908af38834B9Aba238b362CFfB665F; // router address for Mantle testnet on Supra
// mapping of pair to boo
mapping(string => bool) isValidPair;
// mapping ids to Batch
mapping(uint256 => Batch) public batches;
// mapping ids to prediction to predictors
mapping(uint256 => mapping(int256 => address[])) private predictions;
// mapping to show which user claimed wins from batch id
mapping(address => mapping(uint256 => bool)) public claimed;
event CollectFee(address indexed wallet, uint256 available);
event WithdrawWin(
address indexed caller,
uint256 indexed id,
uint256 amount
);
event NewPrediction(
uint256 id,
uint256 indexed ticketPrice,
string indexed pair,
uint256 startTimestamp,
uint256 endTimestamp
);
event Predict(
address indexed user,
uint256 indexed id,
int256[] predictions
);
event Resolve(
address indexed caller,
uint256 id,
int256 price,
uint256 timestamp,
address[] winners
);
event Whitelist(address indexed caller, string[] pairs);
constructor(uint256 _fee, string[] memory pairs) Ownable() {
fee = _fee;
whitelist(pairs);
sValueFeed = ISupraSValueFeed(routerAddress);
}
modifier realId(uint256 id) {
Batch memory b = batches[id];
require(b.startTimestamp != 0, "INVALID_ID");
_;
}
function whitelist(string[] memory pairs) public onlyOwner {
uint256 len = pairs.length;
for (uint256 i = 0; i < len; i++) {
isValidPair[pairs[i]] = true;
}
emit Whitelist(msg.sender, pairs);
}
function newPrediction(
uint256 ticketCost, // cost per ticket
uint256 startsIn, // how many seconds before prediction starts
uint256 lastsFor, // prediction window duration
string calldata pair // whitelisted pair supported by SupraOracles
) external onlyOwner returns (uint256 id) {
bool valid = isValidPair[pair];
require(valid, "INVALID_PAIR");
_id.increment();
id = _id.current();
uint256 start = block.timestamp + startsIn;
uint256 end = start + lastsFor;
Batch storage b = batches[id];
b.id = id;
b.ticketCost = ticketCost;
b.startTimestamp = start;
b.closeTimestamp = end;
b.pair = pair;
emit NewPrediction(id, ticketCost, pair, start, end);
}
function predict(uint256 id, int256[] calldata _predictions)
external
payable
realId(id)
{
Batch memory b = batches[id];
require(
b.closeTimestamp > block.timestamp &&
b.startTimestamp < block.timestamp,
"PREDICTION_NOT_OPEN"
);
uint256 len = _predictions.length;
uint256 cost = b.ticketCost * len;
require(cost == msg.value, "WRONG_PAYMENT_VALUE");
for (uint256 i = 0; i < len; i++) {
activity[msg.sender][id].push(_predictions[i]);
predictions[id][_predictions[i]].push(msg.sender);
}
Batch storage _b = batches[id];
unchecked {
_b.totalTickets += len;
}
emit Predict(msg.sender, id, _predictions);
}
function resolve(uint256 id) external onlyOwner realId(id) {
Batch memory b = batches[id];
require(block.timestamp > b.closeTimestamp, "NOT_YET");
require(!b.isClosed, "ALREADY_RESOLVED");
Batch storage _b = batches[id];
_b.isClosed = true;
// Get the win price from Supra Oracles
(int256 price, uint256 timestamp) = getPrice(b.pair);
_b.winPrice = price;
uint256 feeAmt;
uint256 raised = b.ticketCost * b.totalTickets;
uint256 len = predictions[id][price].length;
if (len == 0) {
feeAmt = raised;
} else {
uint256 _fee = fee;
feeAmt = (raised * _fee) / (1000 * 100);
uint256 calc = raised - feeAmt;
_b.totalBalanceAfterFee = calc;
_b.winners = predictions[id][price];
}
unchecked {
available += feeAmt;
}
b = batches[id];
emit Resolve(msg.sender, id, price, timestamp, b.winners);
}
function result(address addr, uint256 id)
public
view
realId(id)
returns (bool isWinner, uint256 winTickets)
{
Batch memory b = batches[id];
uint256 len = b.winners.length;
if (len > 0) {
uint256 num;
for (uint256 i = 0; i < len; i++) {
if (b.winners[i] == addr) num++;
}
if (num > 0) {
(isWinner, winTickets) = (true, num);
}
}
}
function withdrawWin(uint256 id) external {
bool clm = claimed[msg.sender][id];
require(!clm, "ALREADY_CLAIMED");
(, uint256 count) = result(msg.sender, id);
require(count > 0, "NOT_WINNER");
Batch memory b = batches[id];
uint256 amt = (b.totalBalanceAfterFee / b.winners.length) * count;
claimed[msg.sender][id] = true;
_transfer(payable(msg.sender), amt);
emit WithdrawWin(msg.sender, id, amt);
}
function collectFee(address payable wallet) external onlyOwner {
uint256 avail = available;
require(avail > 0, "NOTHING_TO_WITHDRAW");
available = 0;
_transfer(wallet, avail);
emit CollectFee(wallet, avail);
}
function getPrice(string memory pair)
public
view
returns (int256 price, uint256 timestamp)
{
return sValueFeed.checkPrice(pair);
}
function _transfer(address payable wallet, uint256 amt) private {
(bool success, ) = wallet.call{value: amt}("");
require(success, "WITHDRAWAL_ERROR");
}
}
I hope you enjoyed this tutorial. Let me know what you want to see next in the comment section. Watch this DEMO VIDEO to see how our dApp works when deployed to the Mantle testnet.
Hospitality at Benvindo
1 年????this is awesome. Weldone and more grace to u??
Nutrition Consultant at Stefhenj therapeutic food options Nigeria
1 年Great job ?? You're a genius. No doubt.