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.
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
.
The betting platform in this tutorial allows:
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).
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;
}
}
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:
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;
}
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 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);
...........
}
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];
}
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
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.
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
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.