Prediction Market Smart Contract

date
Jan 20, 2026
slug
prediction-market-smart-contract
status
Published
tags
Blockchain
summary
Prediction markets are fascinating DeFi primitives that allow users to bet on the outcome of future events. Unlike AMM-based prediction markets (like Polymarket's CLAMM), this implementation uses a simpler pot-based parimutuel system - perfect for learning smart contract development or bootstrapping your own prediction market protocol.
type
Post

What We're Building

  • Binary prediction markets (YES/NO outcomes)
  • Pot-based parimutuel betting (proportional payouts)
  • ERC20 stablecoin integration (USDC, USDT, DAI)
  • Admin-controlled resolution (oracle-free for simplicity)
  • Multi-chain deployment (6 EVM chains)

Tech Stack

  • Solidity ^0.8.20 - Smart contract language
  • Foundry - Development framework (forge, cast, anvil)
  • OpenZeppelin patterns - Security best practices
  • Slither/Mythril compatible - Static analysis ready
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {IERC20} from "./interfaces/IERC20.sol"; import {ERC1155} from "../lib/solmate/src/tokens/ERC1155.sol"; import {Types} from "./types/Types.sol"; import {LMSRMath} from "./libraries/LMSRMath.sol"; /// @title PredictionMarket /// @author Polymarket-lite (LMSR + ERC1155) /// @notice A shares-based binary prediction market with LMSR pricing and ERC1155 shares /// @dev Educational LMSR + ERC1155 implementation: shares are ERC1155 tokens, prices are probabilities /// @custom:security-contact [email protected] /// @custom:disclaimer THIS CONTRACT IS FOR EDUCATIONAL PURPOSES ONLY. IT IS UNAUDITED AND /// SHOULD NOT BE USED IN PRODUCTION. THE AUTHORS ARE NOT RESPONSIBLE FOR ANY DAMAGES OR /// LOSSES THAT MAY RESULT FROM ITS USE. ALWAYS CONDUCT YOUR OWN SECURITY AUDIT. contract PredictionMarket is ERC1155 { using Types for Types.Market; using LMSRMath for Types.Market; // ============ State Variables ============ /// @notice Global configuration Types.Config public config; /// @notice Total number of markets created uint256 public marketCounter; /// @notice Collateral token used for trading (stablecoin) IERC20 public immutable collateral; /// @notice Collateral decimals uint8 public immutable collateralDecimals; /// @notice Reentrancy lock uint256 private _locked = 1; /// @notice Mapping of market ID to Market data mapping(uint256 => Types.Market) public markets; // ============ Modifiers ============ /// @notice Ensures caller is admin modifier onlyAdmin() { if (msg.sender != config.admin) revert Types.NotAdmin(); _; } /// @notice Ensures contract is not paused modifier whenNotPaused() { if (config.paused) revert Types.Paused(); _; } /// @notice Prevents reentrancy modifier nonReentrant() { if (_locked == 2) revert Types.ReentrancyGuard(); _locked = 2; _; _locked = 1; } /// @notice Ensures market exists modifier marketExists(uint256 _marketId) { if (_marketId == 0 || _marketId > marketCounter) revert Types.InvalidMarket(); _; } // ============ Constructor ============ /// @notice Initializes the prediction market contract /// @param _collateral Address of the collateral token (stablecoin) /// @param _collateralDecimals Decimal places of the collateral token /// @param _admin Admin address for contract control /// @param _feeRecipient Address to receive trading fees /// @param _tradingFeeBps Trading fee in basis points (100 = 1%) constructor( address _collateral, uint8 _collateralDecimals, address _admin, address _feeRecipient, uint256 _tradingFeeBps ) { if (_collateral == address(0)) revert Types.InvalidMarket(); if (_admin == address(0)) revert Types.NotAdmin(); if (_tradingFeeBps > Types.MAX_FEE_LIMIT) revert Types.InvalidFee(); collateral = IERC20(_collateral); collateralDecimals = _collateralDecimals; config = Types.Config({ admin: _admin, feeRecipient: _feeRecipient, tradingFeeBps: _tradingFeeBps, paused: false }); } // ============ ERC1155 Override ============ /// @notice ERC1155 URI function - returns metadata URI for shares /// @return URI string (can be empty for now) function uri(uint256) public pure override returns (string memory) { return ""; } // ============ Token ID Helpers ============ /// @notice Gets YES token ID for a market /// @param _marketId Market ID /// @return Token ID for YES shares function getYesTokenId(uint256 _marketId) public pure returns (uint256) { return _marketId * 2; } /// @notice Gets NO token ID for a market /// @param _marketId Market ID /// @return Token ID for NO shares function getNoTokenId(uint256 _marketId) public pure returns (uint256) { return _marketId * 2 + 1; } /// @notice Gets token ID for an outcome /// @param _marketId Market ID /// @param _outcome Outcome (Yes or No) /// @return Token ID function getTokenId(uint256 _marketId, Types.Outcome _outcome) public pure returns (uint256) { if (_outcome == Types.Outcome.Yes) return getYesTokenId(_marketId); if (_outcome == Types.Outcome.No) return getNoTokenId(_marketId); revert Types.InvalidOutcome(); } // ============ Admin Functions ============ /// @notice Updates the global configuration /// @param _feeRecipient New fee recipient address /// @param _tradingFeeBps New trading fee in basis points function updateConfig(address _feeRecipient, uint256 _tradingFeeBps) external onlyAdmin { if (_tradingFeeBps > Types.MAX_FEE_LIMIT) revert Types.InvalidFee(); config.feeRecipient = _feeRecipient; config.tradingFeeBps = _tradingFeeBps; emit Types.ConfigUpdated(msg.sender, _feeRecipient, _tradingFeeBps); } /// @notice Pauses the contract function pause() external onlyAdmin { if (config.paused) revert Types.AlreadyPaused(); config.paused = true; emit Types.ContractPaused(msg.sender); } /// @notice Unpauses the contract function unpause() external onlyAdmin { if (!config.paused) revert Types.NotPaused(); config.paused = false; emit Types.ContractUnpaused(msg.sender); } /// @notice Resolves a market with a winning outcome /// @param _marketId ID of the market to resolve /// @param _winningOutcome Winning outcome (Yes or No) function resolveMarket( uint256 _marketId, Types.Outcome _winningOutcome ) external onlyAdmin nonReentrant marketExists(_marketId) { Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) revert Types.MarketAlreadyFinalized(); if (block.timestamp < market.resolutionTime) revert Types.MarketNotActive(); if (_winningOutcome != Types.Outcome.Yes && _winningOutcome != Types.Outcome.No) { revert Types.InvalidOutcome(); } market.state = Types.MarketState.Resolved; market.winningOutcome = _winningOutcome; (uint256 yesPriceWad, uint256 noPriceWad) = LMSRMath.getPricesWad(market, collateralDecimals); emit Types.MarketResolved(_marketId, _winningOutcome, yesPriceWad, noPriceWad, block.timestamp); } /// @notice Cancels a market /// @param _marketId ID of the market to cancel function cancelMarket(uint256 _marketId) external onlyAdmin nonReentrant marketExists(_marketId) { Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) revert Types.MarketAlreadyFinalized(); if (block.timestamp < market.resolutionTime) revert Types.MarketNotActive(); market.state = Types.MarketState.Cancelled; emit Types.MarketCancelled(_marketId, block.timestamp); } // ============ Market Functions ============ /// @notice Creates a new prediction market (LMSR) /// @param _question Market question /// @param _resolutionTime Unix timestamp when trading ends /// @param _b LMSR liquidity parameter in token units (higher => deeper liquidity) /// @param _feeAmount Creation fee amount (token units) /// @return marketId ID of the created market function createMarket( string calldata _question, uint256 _resolutionTime, uint256 _b, uint256 _feeAmount ) external nonReentrant whenNotPaused returns (uint256 marketId) { if (bytes(_question).length == 0) revert Types.EmptyQuestion(); if (_resolutionTime <= block.timestamp) revert Types.InvalidResolutionTime(); if (_b == 0) revert Types.InvalidLiquidityParameter(); // Transfer fee if applicable if (_feeAmount > 0) { if (collateral.balanceOf(msg.sender) < _feeAmount) revert Types.InsufficientBalance(); if (collateral.allowance(msg.sender, address(this)) < _feeAmount) { revert Types.InsufficientAllowance(); } bool feeSuccess = collateral.transferFrom(msg.sender, config.feeRecipient, _feeAmount); if (!feeSuccess) revert Types.TransferFailed(); } // Required LMSR funding: b * ln(2) uint256 requiredFunding = LMSRMath.requiredFundingForB(_b, collateralDecimals); if (collateral.balanceOf(msg.sender) < requiredFunding) revert Types.InsufficientBalance(); if (collateral.allowance(msg.sender, address(this)) < requiredFunding) { revert Types.InsufficientAllowance(); } bool fundingSuccess = collateral.transferFrom(msg.sender, address(this), requiredFunding); if (!fundingSuccess) revert Types.TransferFailed(); // Create market marketCounter++; marketId = marketCounter; markets[marketId] = Types.Market({ id: marketId, question: _question, resolutionTime: _resolutionTime, state: Types.MarketState.Active, winningOutcome: Types.Outcome.None, qYesWad: 0, qNoWad: 0, b: _b, funding: requiredFunding, creationFee: _feeAmount, creator: msg.sender, createdAt: block.timestamp, configSnapshot: Types.ConfigSnapshot({ feeRecipient: config.feeRecipient, tradingFeeBps: config.tradingFeeBps, b: _b, funding: requiredFunding }) }); emit Types.MarketCreated(marketId, _question, _resolutionTime, msg.sender, _b, requiredFunding, _feeAmount); return marketId; } // ============ Trading Functions ============ /// @notice Buys shares of a specific outcome using LMSR /// @param _marketId ID of the market /// @param _outcome Outcome to buy shares for (Yes or No) /// @param _sharesAmount Amount of shares to buy (token units) /// @return sharesReceived Amount of shares received (equals _sharesAmount) function buyShares( uint256 _marketId, Types.Outcome _outcome, uint256 _sharesAmount ) external nonReentrant whenNotPaused marketExists(_marketId) returns (uint256 sharesReceived) { if (_sharesAmount == 0) revert Types.ZeroAmount(); if (_outcome != Types.Outcome.Yes && _outcome != Types.Outcome.No) revert Types.InvalidOutcome(); Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) revert Types.MarketNotActive(); if (block.timestamp >= market.resolutionTime) revert Types.MarketExpired(); uint256 cost = LMSRMath.costToBuySharesView(market, _outcome, _sharesAmount, collateralDecimals); uint256 fee = (cost * market.configSnapshot.tradingFeeBps) / 10000; uint256 totalCharge = cost + fee; if (collateral.balanceOf(msg.sender) < totalCharge) revert Types.InsufficientBalance(); if (collateral.allowance(msg.sender, address(this)) < totalCharge) revert Types.InsufficientAllowance(); bool ok = collateral.transferFrom(msg.sender, address(this), totalCharge); if (!ok) revert Types.TransferFailed(); if (fee > 0) { bool feeOk = collateral.transfer(config.feeRecipient, fee); if (!feeOk) revert Types.TransferFailed(); } _applyTrade(market, _outcome, int256(LMSRMath.sharesToWad(_sharesAmount, collateralDecimals))); // Mint ERC1155 shares uint256 tokenId = getTokenId(_marketId, _outcome); _mint(msg.sender, tokenId, _sharesAmount, ""); emit Types.SharesBought(_marketId, msg.sender, _outcome, cost, _sharesAmount, block.timestamp); return _sharesAmount; } /// @notice Sells shares back to the AMM for collateral /// @param _marketId ID of the market /// @param _outcome Outcome of shares to sell (Yes or No) /// @param _sharesAmount Amount of shares to sell /// @return collateralReceived Amount of collateral received function sellShares( uint256 _marketId, Types.Outcome _outcome, uint256 _sharesAmount ) external nonReentrant whenNotPaused marketExists(_marketId) returns (uint256 collateralReceived) { if (_sharesAmount == 0) revert Types.ZeroAmount(); if (_outcome != Types.Outcome.Yes && _outcome != Types.Outcome.No) revert Types.InvalidOutcome(); Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) revert Types.MarketNotActive(); if (block.timestamp >= market.resolutionTime) revert Types.MarketExpired(); // Check ERC1155 balance uint256 tokenId = getTokenId(_marketId, _outcome); if (balanceOf[msg.sender][tokenId] < _sharesAmount) revert Types.InsufficientShares(); // Burn ERC1155 shares _burn(msg.sender, tokenId, _sharesAmount); uint256 refund = LMSRMath.refundForSellSharesView(market, _outcome, _sharesAmount, collateralDecimals); uint256 fee = (refund * market.configSnapshot.tradingFeeBps) / 10000; uint256 payout = refund - fee; _applyTrade(market, _outcome, -int256(LMSRMath.sharesToWad(_sharesAmount, collateralDecimals))); if (fee > 0) { bool feeOk = collateral.transfer(config.feeRecipient, fee); if (!feeOk) revert Types.TransferFailed(); } bool ok = collateral.transfer(msg.sender, payout); if (!ok) revert Types.TransferFailed(); emit Types.SharesSold(_marketId, msg.sender, _outcome, _sharesAmount, refund, block.timestamp); return payout; } // ============ Redemption Functions ============ /// @notice Redeems shares for payout after market resolution /// @param _marketId ID of the market /// @param _outcome Outcome of shares to redeem (Yes or No) /// @param _sharesAmount Amount of shares to redeem function redeemShares( uint256 _marketId, Types.Outcome _outcome, uint256 _sharesAmount ) external nonReentrant marketExists(_marketId) { if (_sharesAmount == 0) revert Types.ZeroAmount(); if (_outcome != Types.Outcome.Yes && _outcome != Types.Outcome.No) revert Types.InvalidOutcome(); Types.Market storage market = markets[_marketId]; if (market.state == Types.MarketState.Active) revert Types.MarketNotFinalized(); // Check ERC1155 balance uint256 tokenId = getTokenId(_marketId, _outcome); if (balanceOf[msg.sender][tokenId] < _sharesAmount) revert Types.InsufficientShares(); // Burn ERC1155 shares _burn(msg.sender, tokenId, _sharesAmount); uint256 payout = 0; if (market.state == Types.MarketState.Resolved) { // Only winning shares can be redeemed 1:1 if (market.winningOutcome == _outcome) { payout = _sharesAmount; // 1 share = 1 collateral token } // Losing shares are worthless (payout = 0) } else { // Cancelled: unwind via LMSR (sell back into the book), no fee on cancel redemption. payout = LMSRMath.refundForSellSharesView(market, _outcome, _sharesAmount, collateralDecimals); _applyTrade(market, _outcome, -int256(LMSRMath.sharesToWad(_sharesAmount, collateralDecimals))); } // Transfer payout if (payout > 0) { bool success = collateral.transfer(msg.sender, payout); if (!success) revert Types.TransferFailed(); } emit Types.SharesRedeemed(_marketId, msg.sender, _outcome, _sharesAmount, payout, block.timestamp); } /// @notice Redeems all shares for a user in a market /// @param _marketId ID of the market function redeemAllShares(uint256 _marketId) external nonReentrant marketExists(_marketId) { Types.Market storage market = markets[_marketId]; if (market.state == Types.MarketState.Active) revert Types.MarketNotFinalized(); uint256 yesTokenId = getYesTokenId(_marketId); uint256 noTokenId = getNoTokenId(_marketId); uint256 yesShares = balanceOf[msg.sender][yesTokenId]; uint256 noShares = balanceOf[msg.sender][noTokenId]; uint256 totalPayout = 0; if (market.state == Types.MarketState.Resolved) { if (market.winningOutcome == Types.Outcome.Yes && yesShares > 0) { totalPayout = yesShares; _burn(msg.sender, yesTokenId, yesShares); } else if (market.winningOutcome == Types.Outcome.No && noShares > 0) { totalPayout = noShares; _burn(msg.sender, noTokenId, noShares); } } else { // Cancelled: unwind both legs via LMSR if (yesShares > 0) { uint256 yesRefund = LMSRMath.refundForSellSharesView(market, Types.Outcome.Yes, yesShares, collateralDecimals); _applyTrade(market, Types.Outcome.Yes, -int256(LMSRMath.sharesToWad(yesShares, collateralDecimals))); _burn(msg.sender, yesTokenId, yesShares); totalPayout += yesRefund; } if (noShares > 0) { uint256 noRefund = LMSRMath.refundForSellSharesView(market, Types.Outcome.No, noShares, collateralDecimals); _applyTrade(market, Types.Outcome.No, -int256(LMSRMath.sharesToWad(noShares, collateralDecimals))); _burn(msg.sender, noTokenId, noShares); totalPayout += noRefund; } } // Transfer payout if (totalPayout > 0) { bool success = collateral.transfer(msg.sender, totalPayout); if (!success) revert Types.TransferFailed(); emit Types.SharesRedeemed( _marketId, msg.sender, market.state == Types.MarketState.Resolved ? market.winningOutcome : Types.Outcome.None, totalPayout, totalPayout, block.timestamp ); } } // ============ View Functions ============ /// @notice Gets market details /// @param _marketId ID of the market /// @return Market struct with all details function getMarket(uint256 _marketId) external view returns (Types.Market memory) { if (_marketId == 0 || _marketId > marketCounter) revert Types.InvalidMarket(); return markets[_marketId]; } /// @notice Gets user's position (shares) in a market from ERC1155 balances /// @param _marketId ID of the market /// @param _user User address /// @return UserPosition struct function getUserPosition( uint256 _marketId, address _user ) external view returns (Types.UserPosition memory) { if (_marketId == 0 || _marketId > marketCounter) revert Types.InvalidMarket(); uint256 yesTokenId = getYesTokenId(_marketId); uint256 noTokenId = getNoTokenId(_marketId); return Types.UserPosition({ yesShares: balanceOf[_user][yesTokenId], noShares: balanceOf[_user][noTokenId] }); } /// @notice Calculates cost (token units) to buy a given number of shares under LMSR /// @param _marketId ID of the market /// @param _outcome Outcome to buy /// @param _sharesAmount Amount of shares to buy (token units) /// @return cost Cost in token units (excluding fee) function calculateCostToBuy( uint256 _marketId, Types.Outcome _outcome, uint256 _sharesAmount ) external view marketExists(_marketId) returns (uint256 cost) { Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) return 0; if (_sharesAmount == 0) return 0; if (_outcome != Types.Outcome.Yes && _outcome != Types.Outcome.No) return 0; return LMSRMath.costToBuySharesView(market, _outcome, _sharesAmount, collateralDecimals); } /// @notice Calculates refund (token units) for selling a given number of shares under LMSR /// @param _marketId ID of the market /// @param _outcome Outcome of shares to sell /// @param _sharesAmount Amount of shares to sell (token units) /// @return refund Refund in token units (before fee) function calculateRefundForSell( uint256 _marketId, Types.Outcome _outcome, uint256 _sharesAmount ) external view marketExists(_marketId) returns (uint256 refund) { Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) return 0; if (_sharesAmount == 0) return 0; if (_outcome != Types.Outcome.Yes && _outcome != Types.Outcome.No) return 0; return LMSRMath.refundForSellSharesView(market, _outcome, _sharesAmount, collateralDecimals); } /// @notice Gets the current price of YES shares (in collateral per share) /// @param _marketId ID of the market /// @return price Price in collateral tokens per share (scaled by 1e18) function getYesPriceWad(uint256 _marketId) external view marketExists(_marketId) returns (uint256) { Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) return 0; (uint256 yesPriceWad,) = LMSRMath.getPricesWad(market, collateralDecimals); return yesPriceWad; } /// @notice Gets the current price of NO shares (in collateral per share) /// @param _marketId ID of the market /// @return price Price in collateral tokens per share (scaled by 1e18) function getNoPriceWad(uint256 _marketId) external view marketExists(_marketId) returns (uint256) { Types.Market storage market = markets[_marketId]; if (market.state != Types.MarketState.Active) return 0; (,uint256 noPriceWad) = LMSRMath.getPricesWad(market, collateralDecimals); return noPriceWad; } /// @notice Gets current configuration /// @return Config struct function getConfig() external view returns (Types.Config memory) { return config; } /// @notice Gets total number of markets /// @return Total market count function getMarketCount() external view returns (uint256) { return marketCounter; } // ============ Internal Helpers ============ /// @notice Applies a trade to update market quantities /// @param market Market storage reference /// @param outcome Outcome being traded /// @param deltaSharesWad Change in shares (WAD) function _applyTrade(Types.Market storage market, Types.Outcome outcome, int256 deltaSharesWad) internal { if (outcome == Types.Outcome.Yes) market.qYesWad += deltaSharesWad; else market.qNoWad += deltaSharesWad; }