A Price Prediction DApp with off-chain Oracles - Tutorial

A Price Prediction DApp with off-chain Oracles - Tutorial

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.

  1. Admin should be able to create new rounds of predictions.
  2. Prediction rounds should have windows in which predictors are allowed to make predictions.
  3. Predictors should pay ticket costs in prediction rounds. The more predictions a user makes, the more tickets they would have to pay for.
  4. An off-chain oracle should determine the price of the predicted pair at the closing of the round.
  5. The round should close when the prediction window closes and the right predictors should be winners in the round.
  6. Winners should be able to claim their wins.
  7. The platform should have a revenue generation system.
  8. Admin should be able to withdraw the revenue.


No alt text provided for this image

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.

Juliet Njemanze

Hospitality at Benvindo

1 年

????this is awesome. Weldone and more grace to u??

Chinwe Onyebuchi

Nutrition Consultant at Stefhenj therapeutic food options Nigeria

1 年

Great job ?? You're a genius. No doubt.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了