paint-brush
How To Build a Decentralized Betting Platform With Solidity and React.jsby@ernestnnamdi
289 reads

How To Build a Decentralized Betting Platform With Solidity and React.js

by Ernest NnamdiOctober 27th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In this tutorial you will learn how to create a decentralized betting platform using Solidity. The betting platform will allow users to participate in bets on custom questions with specific options, place their bets, and claim their winnings if they correctly guessed the outcome. We will be using foundry framework to build the decentralised betting platform.
featured image - How To Build a Decentralized Betting Platform With Solidity and React.js
Ernest Nnamdi HackerNoon profile picture

If you pay attention in the web3 space, you must’ve come across all the excitement around prediction and speculation markets. This informs the scope of today’s tutorial where you’ll learn how to create a decentralized betting platform using Solidity—the programming language used for smart contracts on Ethereum and other EVM-compatible blockchains. The betting platform will allow users to participate in bets on custom questions with specific options, place their bets, and claim their winnings if they correctly guessed the outcome.

Prerequisites

  • A basic understanding of Solidity.
  • A basic knowledge of React Js.
  • Nodejs and Foundry installed on your PC.


Project Setup and Installation For Solidity

We’ll be using the foundry framework to build the decentralized betting platform.

Run the command below to initiate a foundry project:

forge init betting

Open the betting folder on Vscode or your favorite code editor, and delete the scripts/counter.s.sol, src/counter.sol, and test/counter.t.sol.

Overview of the Betting Platform Features

The betting platform in this tutorial allows:

  1. The owner to set a bet question with a deadline.
  2. The owner to provide options to the question for users to bet on.
  3. The owner to set the correct answer after the bet has ended.
  4. Users to place bets on the options during the betting period.
  5. Users to withdraw their winnings after the outcome is revealed


Code Overview - refactor

For those who prefer to jump straight into the smart contracts, you can find the complete code for the smart contract here.

The contract has been deployed on sepolia - (0xa04F0bB994775bDe9f642F02A7A814cCDf5ee571).

Setting Up the Contract Structure

The owner of the contract is set at the point of deployment, this is important because the owner is vital to the contract, only the owner will be able to create questions, set options for the questions, set the answers for the questions and have access to other gated functions.


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

contract DecentralizedBetting {
    // Owner of the contract
    address public owner;

    // Constructor to set the contract owner
    constructor() {
        owner = msg.sender;
    }
}


Defining the Structures

The QuestionStatus is an enum that is used to track the state of a question, a question with a status of ongoing means that users are currently placing bet on the question, while a question with the Done status means the question has been resolved and users can withdraw their winnings.


The BetQuestion Struct stores details of the question, these details include the question, i.e who will be the president of the United States of America in November, the questionId, this is a unique identifier for each question and the deadline, every question has a deadline which is the time in which betting is closed.


The various mappings track the state of the bets:

  • IdToQuestion: Maps the question ID to the BetQuestion struct.
  • IdToQuestionStatus: Maps the question ID to the QuestionStatus.
  • IdToOptions: Tracks the betting options for each question.
  • IdToAnswer: Tracks the question ID to the answer.
  • IdToAnswerStatus: Tracks the question ID to the AnswerStatus.
  • IdToTotalBet: Tracks the question ID to the TotalBets for the question.
  • IdToPlayers: Tracks the players who bet on each question.
  • UserToId: Stores which option each player selected for a specific question.
  • UserToWinning: Tracks whether a user won a specific bet.
  • IdToAnswer: Tracks the question ID to the no of winners.
  • UserToPlayed: Tracks if the user has already placed a bet on a question.


 constract  DecentralizedBetting {
...........
enum QuestionStatus {
        Ongoing,
        Done
    }

    struct BetQuestion {
       string question;
       uint256 questionId;
       uint256 deadline;
    }

    mapping(uint256 => BetQuestion) IdToQuestion;
    mapping(uint256 => QuestionStatus) IdToQuestionStatus;
    mapping(uint256 => string[]) IdToOptions;
    mapping (uint256 id => uint256 answer) IdToAnswer;
    mapping (uint256 => bool) IdToAnswerStatus;
    mapping (uint256 => uint256) idToTotalBet;
    mapping(address => mapping(uint256 id => uint256 optionId)) public UserToId;
    mapping(address => mapping(uint256 id => bool)) public UserToWinning;
    mapping(uint256 => address[]) IdToPlayers;
    mapping(uint256 => uint256) IdToWinners;
    mapping(address => mapping(uint256 id => bool)) public UserToPlayed;

}


Variables

We have some important storage variables in the betting contract, we have the questionId, which we are using as a unique identifier for each question and is incremented after the creation of a question.


The betAMount is the amount that is expected for every user to spend on betting on one question and the owner is used to store the owner of the contract.



 contract  DecentralizedBetting {
...........


uint256 private questionId = 1;
uint256 public betAmount = 0.1 ether;
 // Owner of the contract
 address public owner;

...........

}


Events

Events are used to track, store, and notify users of important information, events are emitted in this contract when questions are created, options are created, the owner sets an answer for a question, and users place their bets.


 contract  DecentralizedBetting {
...........

   // Event for Creating a Question
    event QuestionCreated(uint256 indexed questionId, string question);
    // Event for Adding Options
    event OptionCreated(uint256 indexed questionId, string[] options);
    // Event for Setting Answer
    event Answer(uint256 indexed questionId, uint256 optionId);
    // Event for placing a bet
    event BetPlaced(address indexed user, uint questionId, uint optionId);
...........

}


Functions

The constructor runs at the point of deployment and it can only run once, the owner of the contract is set at deployment.


 constructor() {
        owner = msg.sender; // Set the contract deployer as the owner
    }


The setQuestion can only be called by the owner of the contract and it is used to create a new question that users can bet on. The function takes in two parameters, the question and the deadline. The deadline is the end time for users to place a bet on a question.

The function checks if the caller is the owner of the contract and reverts if the caller is not the owner. It also checks if the deadline set has not already passed. It also updates the IdToQuestion, and IdToQuestionStatus mappings.


// Function to set a Bet question
    function setQuestion(string calldata question, uint256 deadline) external {
        if (msg.sender != owner){
            revert("Not Owner");
        }
        if (block.timestamp > deadline){
            revert();
        }
        IdToQuestion[questionId] = BetQuestion(question,questionId, deadline);
        IdToQuestionStatus[questionId] = QuestionStatus.Ongoing;
        emit QuestionCreated(questionId, question);
        questionId++;
    }



The setOptions can only be called by the owner of the contract. This function takes in two parameters, the questionId and the options. it is used to set the options available for users to bet on for a question. To simplify the contract, every questions can only accept two options. This function updates the IdToOptions mapping.


// Function to set Options for Bet question
    function setOptions (uint256 id, string[] memory options) external {
         if (msg.sender != owner){
            revert("Not Owner");
        }
        if (options.length > 2){
            revert();
        }
        string[] memory arr = new string[](2);
        for (uint256 i; i < options.length; i++){
            arr[i] = options[i];
        }
        IdToOptions[id] = arr;
        emit OptionCreated(questionId, options);
    }



The setAnswer can only be called by the owner. This function accepts 2 parameters, the questionId and OptionId. This function is used to set the answer to a question. Without the answer being set, users will not know if they won or lost their bet. This function updates the IdToAnswer mapping.


function setAnswer (uint256 id, uint256 optionId) external {
         if (msg.sender != owner){
            revert("Not Owner");
        }
        IdToAnswer[id] = optionId;
        IdToAnswerStatus[id] = true;

        emit Answer(questionId, optionId);
    }



The runBet function can only be called by the owner, and it can only be called if the setAnswer function has been called and the deadline has passed. This function has one parameter, which is questionId and it predetermines all the users that won their bets for the question and also tracks the number of users that won the bet. This function also updates the IdToQuestionStatus mapping and users can withdraw their winnings once this function has been called.


 function runBet (uint256 id) external {
         if (msg.sender != owner){
            revert("Not Owner");
        }
        if (block.timestamp < IdToQuestion[id].deadline){
            revert("Bet is still Ongoing");
        }
        for (uint256 i; i < IdToPlayers[id].length; i++){
            if (IdToAnswer[id] == UserToId[IdToPlayers[id][i]][id]){
                 UserToWinning[IdToPlayers[id][i]][id] = true;
                 IdToWinners[id] += 1;
            }    
        }
        IdToQuestionStatus[questionId] = QuestionStatus.Done;
    }



The placeBet function can be called by anyone who is willing to place a bet, it takes in two parameters, the questionId and the optionId. To call this function, users need at least 0.1 ether to place a bet. Users are unable to place a bet on the same question twice. This function updates the UserToId, IdToPlayers, idToTotalBet and UserToPlayed mappings.


  // Function to place a bet
    function placeBet(uint256 id, uint256 optionId) external payable {
        require(msg.value == betAmount, "You must bet 0.1 ETH");
        if (block.timestamp > IdToQuestion[id].deadline){
            revert("Bet deadline has passed");
        }
        if (UserToPlayed[msg.sender][id]){
            revert("You have already placed this bet");
        }
        UserToId[msg.sender][id] = optionId;
        IdToPlayers[id].push(msg.sender);
        idToTotalBet[id] += msg.value; 
        UserToPlayed[msg.sender][id] = true;
        emit BetPlaced(msg.sender, id, optionId);
    }



The withdawWin function can be called by anyone, any user that chooses a correct option in their bet can call this function and get their winnings.


function withdrawWin (uint256 id) external {
       uint256 winnings = idToTotalBet[id] / IdToWinners[id];
        if (UserToWinning[msg.sender][id] == true){
            (bool sent,) = payable(msg.sender).call{value: winnings}("");
            require(sent, "Failed to send Ether");
        }
    }



The getQuestions is used to get all available questions for bet on the frontend, this function can be improved to only get questions that are available for bets.


 function getQuestions() public view returns (BetQuestion[] memory) {
        BetQuestion[] memory items = new BetQuestion[](questionId - 1);
        for (uint256 i = 0; i < questionId - 1; i++) {
            items[i] = IdToQuestion[i+1];
        }
        return items;
    }



The getOptions function is used to get Options for any question, the options are necessary for users to be able to bet on for a question.


function getOptions(uint256 id) public view returns (string[] memory) {
        return IdToOptions[id];
    }



Project Setup and Installation For React.js

Run the command below and follow the prompt to initiate a React project, we will be using react.js to build the frontend for the decentralized betting platform.


npm create vite@latest


Install the necessary dependencies.


npm install npm install ethers react-toastify


App.js Overview

We will be using hooks like useState, useRef, and useEffect to manage the component's state and to trigger smart contract interactions. Below is a breakdown of key parts of the code.


Setting Up the Ethereum Provider and Smart Contract Interaction

In the React app, we use ethers.js to connect to the Ethereum network through MetaMask. The functions createWriteContract and createGetContract initialize a contract instance for writing and reading data, respectively. These functions are essential for interacting with the blockchain.


function App() {
......................
const createWriteContract = async () => {
    const { ethereum } = window;
    const provider = new ethers.providers.Web3Provider(ethereum)
    const signer = await provider.getSigner();
    const betContract = new ethers.Contract(betAddress, betABI.abi, signer);
    return betContract;
  };

  const createGetContract = async () => {
    const { ethereum } = window;
    const provider = new ethers.providers.Web3Provider(ethereum)
    const betContract = new ethers.Contract(betAddress, betABI.abi, provider);
    return betContract;
  };
................
}



To create a new question, the createQuestion function is called when the form is submitted. The form data includes the question and a deadline for betting.


The setOptions function is used to add options to the question created, both functions can only be called by the owner of the contract.

The setAnswer function is used to set the correct answer to a question, this will help determine the winners of the bet. The runBet function is used to predetermine the winners of the bet for a question.


 function App() {
......................

const createQuestion = async (evt) => {
    evt.preventDefault();
    const contract = await createWriteContract();
    const id = toast.loading("Transaction in progress..");

    try {
      const dateInSecs = Math.floor(new Date(deadlineRef.current.value).getTime() / 1000);
      const tx = await contract.setQuestion(questionRef.current.value, dateInSecs);
      await tx.wait();
      setTimeout(() => {
        window.location.href = "/";
      }, 10000);
      toast.update(id, {
        render: "Transaction successfull",
        type: "success",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    } catch (error) {
      console.log(error);
      toast.update(id, {
        render: `${error.reason}`,
        type: "error",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    }
  };

const setOptions = async (evt) => {
    evt.preventDefault();
    const contract = await createWriteContract();
    const id = toast.loading("Transaction in progress..");
    try {
      const tx = await contract.setOptions(questionIdRef.current.value, [option1Ref.current.value, option2Ref.current.value])
      await tx.wait();
      setTimeout(() => {
        window.location.href = "/";
      }, 10000);
      toast.update(id, {
        render: "Transaction successfull",
        type: "success",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    } catch (error) {
      console.log(error);
      toast.update(id, {
        render: `${error.reason}`,
        type: "error",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    }
  };

 const setAnswer = async (evt) => {
    evt.preventDefault();
    const contract = await createWriteContract();
    const id = toast.loading("Transaction in progress..");
    try {
      const tx = await contract.setAnswer(questionIdRef2.current.value, answerRef.current.value)
      await tx.wait();
      setTimeout(() => {
        window.location.href = "/";
      }, 10000);
      toast.update(id, {
        render: "Transaction successfull",
        type: "success",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    } catch (error) {
      console.log(error);
      toast.update(id, {
        render: `${error.reason}`,
        type: "error",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    }
  };

const runBet = async (evt) => {
    evt.preventDefault();
    const contract = await createWriteContract();
    const id = toast.loading("Transaction in progress..");
    try {
      const tx = await contract.runBet(questionIdRef3.current.id);
      await tx.wait();
      setTimeout(() => {
        window.location.href = "/";
      }, 10000);
      toast.update(id, {
        render: "Transaction successfull",
        type: "success",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    } catch (error) {
      console.log(error);
      toast.update(id, {
        render: `${error.reason}`,
        type: "error",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    }
  };

................
}


The user can place a bet by calling the placeBet function. It reads the selected option and interacts with the contract to register the bet. The users can use the withdrawWin function to withdraw their winnings if they placed a correct bet.


 const placeBet = async (evt) => {
    evt.preventDefault();
    const contract = await createWriteContract();
    const id = toast.loading("Transaction in progress..");
    try {
      const tx = await contract.placeBet(id, answerRef1.current.id);
      await tx.wait();
      setTimeout(() => {
        window.location.href = "/";
      }, 10000);
      toast.update(id, {
        render: "Transaction successfull",
        type: "success",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    } catch (error) {
      console.log(error);
      toast.update(id, {
        render: `${error.reason}`,
        type: "error",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    }
  };
 
  const withdrawWin = async (evt) => {
    evt.preventDefault();
    const contract = await createWriteContract();
    const id = toast.loading("Transaction in progress..");
    try {
      const tx = await contract.runBet(questionIdRef4.current.id);
      await tx.wait();
      setTimeout(() => {
        window.location.href = "/";
      }, 10000);
      toast.update(id, {
        render: "Transaction successfull",
        type: "success",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    } catch (error) {
      console.log(error);
      toast.update(id, {
        render: `${error.reason}`,
        type: "error",
        isLoading: false,
        autoClose: 10000,
        closeButton: true,
      });
    }
  };



Throughout the app, React hooks (useState, useEffect, and useRef) are used to manage state, such as the current questions and options. The UI is dynamically updated based on changes in the state, and MetaMask prompts for confirmation whenever the user triggers a transaction.


import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import betABI from "../abi/bet.json";
import { ethers } from "ethers";
import { toast } from "react-toastify";
import { useState, useRef, useEffect } from "react";
import "react-toastify/dist/ReactToastify.css";
function App() {
  const questionRef = useRef();
  const deadlineRef = useRef();
  const option1Ref = useRef();
  const option2Ref = useRef();
  const questionIdRef = useRef();
  const questionIdRef2 = useRef();
  const questionIdRef3 = useRef();
  const questionIdRef4 = useRef();
  const answerRef = useRef();
  const answerRef1 = useRef();
  const selectRef = useRef();

  const [questions, setQuestions] = useState([]);
  const [options, setOption] = useState([]);
  const [id, setId] = useState(1);
  const [betid, setBId] = useState(0);
  const [address, setAddress] = useState("");
  const [balance, setBalance] = useState(0);
  const betAddress = "0xa04F0bB994775bDe9f642F02A7A814cCDf5ee571";

  ..........................
  const getBalance = async () => {
    const { ethereum } = window;
    const provider = new ethers.providers.Web3Provider(ethereum);
    const signer = await provider.getSigner();
    const address = await signer.getAddress()
    const balance = await provider.getBalance(address);
    setBalance(Number(balance));
    setAddress(address);
  };

  useEffect(() => {
    getQuestions();
    getOptions();
    getBalance();
  }, [id]);

  return (
    <>
      <h1>DecentralizedBetting</h1>

      <div className='options2'>
        <div>User - {address}</div>
        <div>Balance - {balance / 10 ** 18} ether</div>
      </div>

      <div>
        <div>
          <div className='text1'>Create New Question (Admin)</div>
          <textarea ref={questionRef} className='textarea'>
          </textarea>
          <input type='datetime-local' ref={deadlineRef} className='input1' placeholder='Enter Deadline' />
          <button onClick={createQuestion} className='but1'>Create Question</button>
        </div>
        <div className='options'>
          <div className='text1'>Add Options to Question (Admin)</div>
          <input ref={questionIdRef} className='input1' placeholder='Question Id' />
          <input ref={option1Ref} className='input1' placeholder='Option 1' />
          <input ref={option2Ref} className='input1' placeholder='Option 2' />
          <button onClick={setOptions} className='but1'>Add Options</button>
        </div>
        <div className='options'>
          <div className='text1'>Update Answer to Question (Admin)</div>
          <input ref={questionIdRef2} className='input1' placeholder='Question Id' />
          <input ref={answerRef} className='input1' placeholder='Option Id' />
          <button onClick={setAnswer} className='but1'>Update Answer</button>
        </div>

        <div className='options'>
          <div className='text1'>Get Winners (Admin)</div>
          <input ref={questionIdRef3} className='input1' placeholder='Question Id' />
          <button onClick={runBet} className='but1'>Get winners</button>
        </div>

        <div className='options'>
          <div className='text1'>Place Bet</div>
          <select ref={selectRef} onChange={() => setId(selectRef.current.value)} className='input1' name="cars" id="cars">
            {
              questions.map((item, index) => {
                return <option key={index} value={item.questionId}>{String(item.question)}</option>
              })
            }
          </select>
          <input ref={answerRef1} className='input1' value={betid} placeholder='Option Id' />
          <div className='placebet'>
            <button onClick={() => setBId(0)} className='but1'>{options[0]}</button>
            <button onClick={() => setBId(1)} className='but1'>{options[1]}</button>
          </div>
          <button onClick={placeBet} className='but1'>Place Bet</button>
        </div>

        <div className='options'>
          <div className='text1'>Withdraw Winnings</div>
          <input ref={questionIdRef4} className='input1' placeholder='Question Id' />
          <button onClick={withdrawWin} className='but1'>Withdraw</button>
        </div>

      </div>
    </>
  )
}
export default App



Image of the decentralized betting app's frontend.


Image of the decentralized betting app's frontend


Conclusion

In this tutorial, we covered how to build a decentralized betting platform with Solidity and React. We explored setting up questions, and options, placing bets, and settling results using smart contract methods. You now have a working decentralized application that runs on the Ethereum blockchain. You can check out the live version of the project here.


Next Steps

  • Deploy your smart contract to a live network (testnets like Sepolia).
  • Improve the UI for better user experience.
  • Consider adding more complex betting mechanisms or other Web3 functionalities.