Building a Prediction Market DApp with Pyth Oracle & Morph starter kit

Building a Prediction Market DApp with Pyth Oracle & Morph starter kit

Ah, prediction markets. The current rave. From predicting election outcomes to forecasting crypto prices, these decentralized prediction platforms have captured the imagination of both traders and developers. Today, I will walk you through building your own prediction market dApp using Pyth Oracle.

Oracles serve as bridges between the blockchain and external data sources, fetching and delivering crucial information to your smart contracts. They allow your dApp to react to real-world events and make decisions based on accurate, timely data.

Pyth Oracle

The Pyth Network is a first-party financial oracle network designed to publish continuous real-world data on-chain in a tamper-resistant, decentralized, and self-sustainable environment. It connects market data owners to applications across multiple blockchains, with data contributed by over 100 first-party publishers, including major exchanges and market-making firms. 

Pyth supports over 200 price feeds, including crypto, equities, FX, and metals, updating twice per second without access restrictions. The network has secured over $2.0B in total value and supports significant trading volumes, making it a trusted oracle for over 350 protocols on 55+ blockchains. For more details, visit Pyth Network.

What are we building?

If you are following so far you must have already gleaned it. We are building a prediction market dApp that allows us to predict the prices of any crypto token,  using solidity(foundry), Pyth oracle for fetching prices, Typescript and Nextjs for our frontend.

  

Features

Our prediction market dApp will allow us to do the following,

  • Create markets: This could be any crypto, equity, fx or even commodity 
  • Buy shares: Users will be able to buy shares for whatever their prediction is(Yes or No).
  • Resolve markets: Once the epoch elapses, the dApp resolves the market by using pyth oracle to determine the outcome and winners.
  • Claim : winners will be able to claim their winnings from making correct predictions.

Let's get to building!

Setting up our environment

We are going to start by using the Morph starter kit. This starter kit was designed to abstract away any complexity involved in setting up your environment to build and deploy on morph. 

So in your terminal, run the command below and choose the name of your dapp or hit Enter to select the default.

npx @morphl2/create-morph-app@latest

After its successfully installed, you should see some setup options.

Great! Now we have our kit installed, we can finally start building.

cd my-morph-app

We are going to be using foundry to write and deploy our smart contracts. So navigate to the contracts/foundry directory and run the following commands to rename the env.example file to env and also, to compile our boilerplate contracts.

  cp .env.example .env
  forge build

.env

Before we start building, we have to set our environment variables. Paste in your private key in the .env file in the root of your project.

PRIVATE_KEY=your-private-key
RPC_URL=https://rpc-quicknode-holesky.morphl2.io

Token.sol

Now, we can finally start writing our smart contracts. In your src folder, delete the boilerplate file (MyToken.sol) and create a new file called Token.sol. This will serve as the currency for our prediction dApp. Paste in the code below

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract OkidoToken is ERC20 {
    constructor(uint256 initialSupply) ERC20("Okido Token", "OKD") {
        _mint(msg.sender, initialSupply);
    }
}

Market.sol

The market contract is the implementation of the prediction market dApp. Still in your src folder, create another file called Market.sol and paste in the code in this gist. Now, you might see some errors regarding the Pyth imports. This is because we have not installed Pyth yet in our project. 

In your terminal(enure you are in the foundry directory) , run the command below to install Pyth to our foundry project

forge install pyth-network/pyth-sdk-solidity@v2.2.0 --no-git --no-commit

Next, we add Pyth to our remappings in the foundry.toml file. Paste this line in the remappings array in your foundry.toml file.

"@pythnetwork/=lib/pyth-sdk-solidity/"

Your foundry.toml file should look like this

Also, the warnings in the market contract should have disappeared and your Market.sol should look like below.

Contract Walkthrough

Key variables

IERC20 public paymentToken;      // The token used for betting
IPyth public pythOracle;         // Pyth price feed oracle
uint256 public marketCount;      // Total number of markets created
uint256 public constant FEE_PERCENTAGE = 1;  // 1% fee on transactions

createMarket function

function createMarket(
    string memory _cryptoPair,
    uint256 _strikePrice,
    uint256 _endTime,
    uint256 _resolutionTime,
    bytes32 _pythPriceId
)

  • Creates a new prediction market
  • Validates that timestamps are correct (end time > current time, resolution time > end time)
  • Emits a MarketCreated event
  • Increments the market counter

buyShares function

function buyShares(uint256 _marketId, bool _isYes, uint256 _amount)

  • Allows users to buy position shares in a market
  • _isYes: true for "above strike price", false for "below strike price"
  • Takes 1% fee from the input amount
  • Updates the user's position and market's total shares
  • Transfers payment tokens from user to contract
  • Protected against reentrancy

sellShares function

function sellShares(uint256 _marketId, bool _isYes, uint256 _amount)

  • Allows users to sell their position shares
  • Verifies user has sufficient shares to sell
  • Takes 1% fee from the sale amount
  • Updates positions and transfers tokens back to user

resolveMarket function

function resolveMarket(uint256 _marketId, bytes[] calldata _pythUpdateData)

  • Called after market end time to determine the outcome
  • Uses Pyth oracle to get the final price
  • Converts price to 18 decimals for consistent comparison
  • Sets market outcome (true if price >= strike price)
  • Marks market as resolved
  • Requires payment for Pyth oracle update

claimRewards function

function claimRewards(uint256 _marketId)

  • Allows winners to claim their rewards after market resolution
  • Calculates reward based on share of winning side
  • Transfers rewards to user
  • Resets user's position to prevent double claims

Deploying our contract

Deploying our contract is super easy with our development kit. Update your run function in your script/Deployer.s.sol file.

 function run() public returns (CryptoPredictionsMarket)  {
        vm.startBroadcast();
        OkidoToken token = new OkidoToken(1000000);
        CryptoPredictionsMarket market = new CryptoPredictionsMarket(address(token), 0x2880aB155794e7179c9eE2e38200202908C17B43);

        vm.stopBroadcast();

        return market;

    }

This function deploys the token( ERC20 token which we are using as our currency)  and passes the address, alongside the address of the Pyth oracle deployment on Morph, as arguments to the constructor in our prediction market contract.Pyth morph address:

0x2880aB155794e7179c9eE2e38200202908C17B43

Your deployment script should be similar to what's below

Next, we run the commands to deploy our contract on morph

source .env
forge script script/Deployer.s.sol -rpc-url $RPC_URL -broadcast -legacy -private-key $PRIVATE_KEY

Setting up the frontend

.env

The first thing we are going to do is to create a walletconnect id and add it to our env file (rename .env.example to .env). 

Next, run yarn install to install dependencies and yarn dev to start our dev server.

page.tsx

This is our home page. Replace the default code with the one below

import Markets from "@/components/Markets";

export default function Home() {
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8 text-center">
        Crypto Prediction Markets
      </h1>
      <Markets />
    </main>
  );
}

Also, this is the best time to install the Pyth oracle dependency

yarn add @pythnetwork/pyth-evm-js

Markets.tsx

This component renders all markets created. In your components folder, create a Markets.tsx file and paste in the code from this gist. We are going to go over some selected functions.

Market creation:

const handleCreateMarket = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
        await writeContractAsync({
            address: marketAddress,
            abi: marketAbi,
            functionName: "createMarket",
            args: [
                newMarket.cryptoPair,
                parseUnits(newMarket.strikePrice, 18),
                BigInt(Math.floor(new Date(newMarket.endTime).getTime() / 1000)),
                BigInt(Math.floor(new Date(newMarket.resolutionTime).getTime() / 1000)),
                newMarket.pythPriceId as `0x${string}`,
            ],
        });
        // Reset form and refetch markets
        setIsCreatingMarket(false);
        setNewMarket({ /* ... */ });
        refetchMarkets();
    } catch (error) {
        console.error("Error creating market:", error);
    }
};

In this function, we use the writeContractAsync hook from wagmi to create a new market by calling the “createMarket” function on our contract and passing in the the crypto pair, strike price, end time, resolution time and price id (gotten from pyth. Each crypto pair has a unique price id).

Getting approvals

const { data: tokenAllowance, refetch: refetchAllowance } = useReadContract({
    address: tokenAddress,
    abi: tokenAbi,
    functionName: "allowance",
    args: [address, marketAddress],
  }) as any;

  const handleApproveTokens = async () => {
    if (!isConnected) {
      open();
      return;
    }

    try {
      await writeContractAsync({
        address: tokenAddress,
        abi: tokenAbi,
        functionName: "approve",
        args: [marketAddress, parseUnits("1000000", 18)], // Approve a large amount
      });
      console.log("Token approval successful");
      refetchAllowance();
    } catch (error) {
      console.error("Error approving tokens:", error);
    }
  };

In this function, we useReadContract hook from wagmi to call the allowance function on the token contract to check if the market contract has sufficient allowance. Next, we call the approve function to approve the market contract to spend money from our wallet.

Market.sol

The next component we are going to work on is the Market component. In your components folder, create another file called Market.sol and paste in the code from this gist.

Buying shares

const handleBuy = () => {
    try {
        const buyShareTx = writeContractAsync({
            address: marketAddress,
            abi: marketAbi,
            functionName: "buyShares",
            args: [market.id, isYes, amount],
        });
    } catch (err: any) {
        console.log("Transaction Failed: " + err.message);
    }
};

The handleBuy function calls the “buyShares” function in the contract and takes in the id of the market, user position (yes/no) and amount.

Resolving markets

const handleResolveMarket = async () => {
    try {
        const connection = new EvmPriceServiceConnection(
            "https://hermes.pyth.network"
        );
        const priceFeedUpdateData = await connection.getPriceFeedsUpdateData([
            market.pythPriceId.toString(),
        ]);

        const resolveMarketTx = writeContractAsync({
            address: marketAddress,
            abi: marketAbi,
            functionName: "resolveMarket",
            args: [market.id, priceFeedUpdateData as any],
            value: parseEther("0.001"),
        });
    } catch (err: any) {
        console.log("Transaction Failed: " + err.message);
    }
};

This function first creates a connection to Pyth’s price service. hermes.pyth.network  is Pyth’s endpoint for getting price updates. It provides real-time price data. 

After making the connection, it gets the price feed update data. This is the latest price data which includes the latest price, confidence interval, timestamp and publisher signature. 

Finally, it calls the “resolveMarket” function in the contract passing in the id of the market and the update data while also paying for the data. Also worthy to note that we are passing an arbitrary amount(0.001 eth) only for demonstration purposes. Ideally, you can call the getUpdateFee method to get the exact fee for each price update.

So to put the flow of data more explicitly, 

Pyth Network -> Price Service -> Smart Contract -> Market Resolution

Connecting to the contract

After we are done with the UI (and i know its a load of errors in your browser but here comes the magic!), it's time to connect to the smart contract. First, we navigate to the constants folder and create two files marketAbi.ts and tokenAbi.ts. 

This is where we are going to paste in the ABIs from our contracts. To get the ABI, go to contracts/foundry and locate the out folder. In the folder, look for the name of our contracts (market and token respectively) and  copy the json file.

Back in our constants folder, paste the json file into the respective files (marketAbi.ts and tokenAbi.ts). Also ensure you paste only the ABI array and not include the metadata. For ease and as an example, here are my marketAbi.ts and tokenAbi.ts files.

Finally, in the index file in our constants folder, we modify it to export our ABIs and also the addresses of our market and token contracts. 

Testing it all out

Time for the fun part. Fiest lets run “yarn dev” to see that we got nothing wrong along the way. Your screen should look like mine below

Next, we connect our wallet (sometimes this could happen automatically) and get this beautiful modal courtesy of web3modal which comes pre-configured with the kit (isnt that cool? Modals, wagmi, viem etc all come already setup with the kit).

After, connecting our wallet, we should get this default screen.

Finally, we create a market. This market will be focused on the ETH/USD crypto pair and our strike price will be $2700. Our duration will be really short for demonstration purposes.

After creating a market, users will be able to take either yes or no positions. But in order to do so, they have to acquire shares(stake). So first, we call the approve function to allow the Market contract transfer our stake from our wallet. Notice how after granting approval, the approve button disappears.

After granting approvals, next is to buy shares. Note, we take a one percent fee on whatever position the user takes (refer to the buyShares function in our contract).

Once the epoch of our market elapses, a new button pops up. The button allows us to “resolve market”. This means it calls the “handleResolveMarket” function which connects and queries Pyth oracle for the exact price of our crypto pair as at the resolution time. This function determines the winners and losers in the market.

After resolving the market, go ahead and claim your rewards. Watch your token balance to see the rewards come in.

Conclusion

In this guide, we have walked through building a full stack prediction market dApp. From setting up our environment using the morph starter kit to deploying our smart contract and then interacting with it from the frontend.

I have also left a small challenge (as I would be doing so from now on) called fixed the bug. There is an intentional bug in our dApp. After claiming your rewards, even though the balance updates and the funds is sent to the winner, it doesnt reset the yes/no shares amount, to zero.Two ways to fix this. Either from the resolveMarket function in the contract, or modify the UI to indicate the amount has been claimed and market closed.

Send links to your solutions to discord and tag me (ernestnnamdi) and i’ll definitely respond. See you again soon.