Building a decentralized prediction market platform in 200 lines of Solidity
October 5, 2021
Hey! If you'd like to see my (not particularly pretty) frontend implementation for this contract, along with an initial bet that you can interact with, check out Gambeth.
2022 is almost upon us, and so last month I decided to start working on my first personal project with the intent to actually make some money. I had what I believe we can all agree on is a pretty realistic goal: becoming a multimillionaire before the end of the year and enjoying the rest of my days in some exotic beach. No better place than the Caribbean to spend your golden years in front of the PC! I am, after all, almost 26 years old now, which as we all know is basically 78 in Javascript years.
So I thought to myself: What are the kids into these days? Well, if the news industry and my Reddit history are anything to go by, I'd say the answer has to be betting (aka "investing") and cryptocurrencies. They call it "diversification", which I guess refers to the diverse names they have for "gambling".
And so I tried to think of a way to unify the two concepts and came up with what I think is a pretty neat idea. A website, depending only on the blockchain with no server interaction of any kind, in which anyone can participate in and create their own bets. A smart oracle queries and extracts an arbitrary node from any website, and posts the result to the blockchain. The smart contract would then let people claim their reward if they bet on the correct result. Bet owners earn a commission for each participant in the betting pool, which incentivizes them to create the bet in the first place. People make money, people lose money, gambling addicts get their daily fix and I become obscenely rich, everyone wins!
Believe it or not, things did not work out as expected. Shocking, I know.
The post went pretty much unnoticed and my initial bet created to promote the site only had one participant: myself.
Sad violin noises.
Bonus point: I ended up losing the keys to the address which published the smart contract. More sad violin noises.
Bright side! I managed to get most of my funds back... by betting on a bunch of results and getting lucky. Slightly less sad violin noises.
So I'm back for round 2 with a newly deployed contract, and I've decided to settle for the 2nd best thing after money: Imaginary Internet points. Hopefully this tutorial/walkthrough of my code will give you a solid grasp of the core concepts of Solidity and how smart contracts work, and at least get me some strangers' validation in return. Of course, if you'd also like to contribute to my early retirement, feel free to participate in the new version of the bet where I almost lost 1000 dollars.
But enough talk about my financial and marketing failures, let's begin!
pragma solidity > 0.6.1 < 0.7.0;
pragma experimental ABIEncoderV2;
import "github.com/provable-things/ethereum-api/blob/master/provableAPI_0.6.sol";
contract WeiStakesByDecentralizedDegenerates is usingProvable {
address payable contractCreator;
constructor() public payable {
contractCreator = msg.sender;
}
Nothing too fancy here, but I'll explain for those of you who new to Solidity.
There are a multitude of versions for the Solidity compiler and smart contracts are not always compatible with all of them, so we need to specify which compiler to actually use when building our dApp. It works similar to npm, so you can "pin" the compiler version for your contract. Since our smart oracle dependency requires a compiler version higher than 0.6.1 but lower than 0.7.0, that's what we'll be using here.
The experimental pragma on the 2nd line allows us to pass an array of strings as a function parameter, which we'll need to do later on.
The import system allows us to use libraries by referring directly to their URL, much like you would in Go and modern Javascript. We're using the latest version of Provable's oracle, which our dApp will use to query websites and parse the intended result for any bet.
Then we have our contract's declaration along with the is usingProvable modifier which makes it inherit all the methods from the Provable API contract.
Finally we have the contract's constructor, which sets the contractCreator variable to whoemever deployed the contract in the first place. Don't worry about the address payable part, it's covered just below.
/* There are two different dates associated with each created bet:
one for the deadline when a user can no longer place new bets,
and another one telling the smart oracle contract when to actually
query the specified website to parse the bet's final result. */
mapping(string => uint64) public betDeadlines;
mapping(string => uint64) public betSchedules;
// There's a 0.0001 ETH fixed commission transferred to the contract's creator for every placed bet
uint256 constant fixedCommission = 1e14;
// Minimum entry for all bets, bet owners cannot set it lower than this
uint256 constant minimumBet = fixedCommission * 2;
// Custom minimum entry for each bet, set by their owner
mapping(string => uint256) public betMinimums;
// Keep track of all createdBets to prevent duplicates
mapping(string => bool) public createdBets;
// Once a query is executed by the oracle, associate its ID with the bet's ID to handle updating the bet's state in __callback
mapping(bytes32 => string) public queryBets;
// Keep track of all owners to handle commission fees
mapping(string => address payable) public betOwners;
mapping(string => uint256) public betCommissions;
// For each bet, how much each has each user put into that bet's pool?
mapping(string => mapping(address => uint256)) public userPools;
// What is the total pooled per bet?
mapping(string => uint256) public betPools;
/* To help people avoid overpaying for the oracle contract querying service,
its last price is saved and then suggested in the frontend */
uint256 public lastQueryPrice;
// Queries can't be scheduled more than 60 days in the future
uint64 constant scheduleThreshold = 60 * 24 * 60 * 60;
/* Provable's API requires some initial funds to cover the cost of the query.
If they are not enough to pay for it, the user should be informed and their funds returned. */
event LackingFunds(address indexed sender, uint256 funds);
Most of the state for our contract can be expressed as simple mappings from a unique bet ID to each of its properties. Some Solidity quirks in the code above are the address type and the payable modifier. An address is just a number which represents a, you guessed it, address in the blockchain. It could be anything from a user (in our case) to another smart contract. A payable address in the betOwners mapping is required because we'll be transferring funds (the commission) to these addresses whenever a user claims their reward from the betting pool.
/* Contains all the information that does not need to be saved as a state variable,
but which can prove useful to people taking a look at the bet in the frontend. */
event CreatedBet(string indexed _id, uint256 initialPool, string description, string query);
You can think of events as external messages that don't form part of the blockchain. They can be queried against to gain more information about a contract without incurring in unnecessary storage costs.
Notice the _id parameter is marked with the indexed modifier, so something's going on here. It indicates that you're able to query the specified parameter to find all emitted events where it matches against a particular value. One problem occurs for indexed parameters of type string though.
Indexed string parameters are hashed before being emitted, so you are not able to access the original value for these parameters when looking at past events. Non indexed string parameters work the opposite way: you can't use them to filter past events, but you'll be able to access them in their unhashed form when viewing past events.
So how do we get the best of both worlds? Let's keep going and find out!
function createBet(string calldata betId, string calldata query, uint64 deadline, uint64 schedule, uint256 commission, uint256 minimum, uint256 initialPool, string calldata description) public payable {
require(
bytes(betId).length > 0
&& deadline > block.timestamp // Bet can't be set in the past
&& deadline <= schedule // Users should only be able to place bets before it is actually executed
&& schedule < block.timestamp + scheduleThreshold
&& msg.value >= initialPool
&& commission > 1 // Commission can't be higher than 50%
&& minimum >= minimumBet
&& !createdBets[betId], // Can't have duplicate bets
"Unable to create bet, check arguments.");
// The remaining balance should be enough to cover the cost of the smart oracle query
uint256 balance = msg.value - initialPool;
lastQueryPrice = provable_getPrice("URL");
if (lastQueryPrice > balance) {
emit LackingFunds(msg.sender, lastQueryPrice);
(bool success, ) = msg.sender.call.value(msg.value)("");
require(success, "Error when returning funds to bet owner.");
return;
}
// Bet creation should succeed from this point onward
createdBets[betId] = true;
/* Even though the oracle query is scheduled to run in the future,
it immediately returns a query ID which we associate with the newly created bet. */
bytes32 queryId = provable_query(schedule, "URL", query);
queryBets[queryId] = betId;
// Nothing fancy going on here, just boring old state updates
betOwners[betId] = msg.sender;
betCommissions[betId] = commission;
betDeadlines[betId] = deadline;
betSchedules[betId] = schedule;
betMinimums[betId] = minimum;
/* By adding the initial pool to the bet creator's,
but not associating it with any results, we allow the creator to incentivize
people to participate without needing to place a bet themselves. */
userPools[betId][msg.sender] += initialPool;
betPools[betId] = initialPool;
emit CreatedBet(betId, initialPool, description, query);
}
The payable modifier strikes again, but it's now showing up in the function's signature. A payable function can never return a value, and it gives us access to that msg.value variable, which equals the funds transferred from the bet's owner. msg.sender, on the other hand, is the address of whomever is creating the bet.
The require() check at the start of our function makes sure that no invalid bets have been created. Wouldn't want people to accidentally create a bet in the past which no one can enter, for example. The error message at the end of the function call is also helpful for debugging purposes.
block.timestamp is a pretty self-explanatory variable which lets us access the current timestamp for the transaction. While not as accurate as possible, it should be good enough for the purposes of our contract.
Anyways, we're more than halfway there already! We can create new bets but it's gonna be hard getting people to participate if we don't actually implement that into our contract.
// The table in the frontend representing each bet's pool is populated according to these events.
event PlacedBets(address indexed user, string indexed _id, string id, string[] results);
Remember what we discussed about indexed vs non indexed string parameters in events? Take a look at the _id/id here, which in both cases refers to the ID where a new bet has been placed. By declaring an indexed and non-indexed version we can query all events involving a particular ID, but we can also listen to new events coming in and immediately tell what bet they belong to.
// For each bet, how much is the total pooled per result?
mapping(string => mapping(string => uint256)) public resultPools;
// For each bet, track how much each user has put into each result
mapping(string => mapping(address => mapping(string => uint256))) public userBets;
function placeBets(string calldata betId, string[] calldata results, uint256[] calldata amounts) public payable {
require(
results.length > 0
&& results.length == amounts.length
&& createdBets[betId]
&& !finishedBets[betId]
&& betDeadlines[betId] >= block.timestamp,
"Unable to place bets, check arguments.");
uint256 total = msg.value;
for (uint i = 0; i < results.length; i++) {
/* More than one bet can be placed at the same time,
need to be careful the transaction funds are never less than all combined bets.
When the oracle fails an empty string is returned,
so by not allowing anyone to bet on an empty string bets can be refunded if an error happens. */
uint256 bet = amounts[i];
require(
bytes(results[i]).length > 0
&& total >= bet
&& bet >= betMinimums[betId],
"Attempted to place invalid bet, check amounts and results");
total -= bet;
bet -= fixedCommission;
// Update all required state
resultPools[betId][results[i]] += bet;
userPools[betId][msg.sender] += bet;
betPools[betId] += bet;
userBets[betId][msg.sender][results[i]] += bet;
}
// Fixed commission transfer
(bool success, ) = contractCreator.call.value(fixedCommission * results.length)("");
require(success, "Failed to transfer fixed commission to contract creator.");
emit PlacedBets(msg.sender, betId, betId, results);
}
At this point you might start to notice that some of the mappings look a bit redundant. The gist of the problem is that Solidity does not have an easy way to access a mapping's keys. Because of this, we need to keep track of all different ways a placed bet changes all its related pools.
Important note about require(): if any of its invariants fail to hold at any point the contract's state will be rolled back, the transaction reverted and the user refunded whatever gas is leftover. When used at the start of a function software like MetaMask is smart enough to figure out if the transaction will succeed before actually executing it (like in the createBet() function from before), but that's not the case here.
OK, only 2 things missing: letting users claim the rewards from their bets, and the actual bet execution.
// For each bet, track which users have already claimed their potential reward
mapping(string => mapping(address => bool)) public claimedBets;
// If the user wins the bet, let them know along with the reward amount.
event WonBet(address indexed winner, uint256 won);
// If the user lost no funds are claimable.
event LostBet(address indexed loser);
/* If no one wins the funds can be refunded to the user,
after the bet's owner takes their commission. */
event UnwonBet(address indexed refunded);
/* If the oracle service's scheduled callback was not executed after 5 days,
a user can reclaim his funds after the bet's execution threshold has passed.
Note that even if the callback execution is delayed,
Provable's oracle should've extracted the result at the originally scheduled time. */
uint64 constant betThreshold = 5 * 24 * 60 * 60;
function claimBet(string calldata betId) public {
bool betExpired = betSchedules[betId] + betThreshold < block.timestamp;
// If the bet has not finished but its threshold has been reached, let the user get back their funds
require(
(finishedBets[betId] || betExpired)
&& !claimedBets[betId][msg.sender]
&& userPools[betId][msg.sender] != 0,
"Invalid bet state while claiming reward.");
claimedBets[betId][msg.sender] = true;
// What's the final result?
string memory result = betResults[betId];
// Did the user bet on the correct result?
uint256 userBet = userBets[betId][msg.sender][result];
// How much did everyone pool into the correct result?
uint256 winnerPool = resultPools[betId][result];
uint256 reward;
// If no one won then all bets are refunded
if (winnerPool == 0) {
emit UnwonBet(msg.sender);
reward = userPools[betId][msg.sender];
} else if (userBet != 0) {
// User won the bet and receives their corresponding share of the loser's pool
uint256 loserPool = betPools[betId] - winnerPool;
emit WonBet(msg.sender, reward);
// User gets their corresponding fraction of the loser's pool, along with their original bet
reward = loserPool / (winnerPool / userBet) + userBet;
} else {
// Sad violin noises
emit LostBet(msg.sender);
return;
}
// Bet owner gets their commission
uint256 ownerFee = reward / betCommissions[betId];
reward -= ownerFee;
(bool success, ) = msg.sender.call.value(reward)("");
require(success, "Failed to transfer reward to user.");
(success, ) = betOwners[betId].call.value(ownerFee)("");
require(success, "Failed to transfer commission to bet owner.");
}
// Keep track of when a bet ends and what its result was
mapping(string => bool) public finishedBets;
mapping(string => string) public betResults;
// Function executed by Provable's oracle when the bet is scheduled to run
function __callback(bytes32 queryId, string memory result) override public {
string memory betId = queryBets[queryId];
/* Callback is sometimes executed twice, so we add an additional check
to make sure state is only modified the first time. */
require(msg.sender == provable_cbAddress() && !finishedBets[betId]);
betResults[betId] = result;
finishedBets[betId] = true;
}