DEV Community

Cover image for Building a Production-Ready Prediction Market Smart Contract in Solidity: Complete Guide with Foundry
Sivaram
Sivaram

Posted on

Building a Production-Ready Prediction Market Smart Contract in Solidity: Complete Guide with Foundry

TL;DR

I built an open-source, gas-optimized prediction market smart contract in Solidity using Foundry. It features pot-based binary markets, proportional payout distribution, admin resolution, and comprehensive security patterns. 95 tests, 98%+ coverage, deployable to Ethereum, Base, Polygon, Arbitrum, Optimism, and BSC.

GitHub: https://github.com/SivaramPg/evm-simple-prediction-market-contract


Table of Contents

  1. Introduction
  2. Architecture Overview
  3. Smart Contract Design
  4. Payout Formula Deep Dive
  5. Security Patterns Implemented
  6. Gas Optimization Techniques
  7. Testing with Foundry
  8. Multi-Chain Deployment
  9. Conclusion

Introduction

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.

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

Architecture Overview

System Components

┌─────────────────────────────────────────────────────────────┐
│                    PredictionMarket.sol                      │
├─────────────────────────────────────────────────────────────┤
│  Config                │  Market[]              │  Positions │
│  - admin               │  - id                  │  - yesBet  │
│  - stablecoin          │  - question            │  - noBet   │
│  - feeRecipient        │  - resolutionTime      │  - claimed │
│  - maxFeePercentage    │  - state               │            │
│  - paused              │  - yesPool / noPool    │            │
│                        │  - winningOutcome      │            │
│                        │  - configSnapshot      │            │
├─────────────────────────────────────────────────────────────┤
│  Functions                                                   │
│  - createMarket()      - placeBet()       - claimWinnings() │
│  - resolveMarket()     - cancelMarket()   - claimMultiple() │
│  - pause/unpause()     - updateConfig()                     │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

State Machine

Markets follow a strict state machine:

    ┌──────────┐
    │  Active  │ ←── createMarket()
    └────┬─────┘
         │
         │ (resolution time reached)
         ▼
    ┌─────────────────────────────┐
    │                             │
    ▼                             ▼
┌──────────┐              ┌───────────┐
│ Resolved │              │ Cancelled │
└──────────┘              └───────────┘
    │                             │
    └──────────┬──────────────────┘
               ▼
        claimWinnings()
Enter fullscreen mode Exit fullscreen mode

Smart Contract Design

Storage Layout

Efficient storage packing is crucial for gas optimization:

struct Config {
    address admin;           // slot 0 (20 bytes)
    bool paused;             // slot 0 (1 byte) - packed!
    address stablecoin;      // slot 1
    uint8 stablecoinDecimals;// slot 1 - packed!
    address feeRecipient;    // slot 2
    uint16 maxFeePercentage; // slot 2 - packed!
    uint256 marketCounter;   // slot 3
}

struct Market {
    uint256 id;
    string question;         // dynamic, separate slot
    uint256 resolutionTime;
    MarketState state;       // uint8
    Outcome winningOutcome;  // uint8
    uint256 yesPool;
    uint256 noPool;
    uint256 creationFee;
    address creator;
    ConfigSnapshot configSnapshot; // frozen at creation
}

struct UserPosition {
    uint256 yesBet;
    uint256 noBet;
    bool claimed;
}
Enter fullscreen mode Exit fullscreen mode

Key Design Decisions

1. Config Snapshot at Market Creation

function createMarket(...) external returns (uint256) {
    // Snapshot config at creation time
    market.configSnapshot = ConfigSnapshot({
        feeRecipient: config.feeRecipient,
        maxFeePercentage: config.maxFeePercentage
    });
}
Enter fullscreen mode Exit fullscreen mode

Why? If admin changes fee settings mid-market, existing markets retain their original terms. This prevents rug-pull scenarios where admins could change fees after users have committed funds.

2. No Opposition = Must Cancel

function resolveMarket(uint256 marketId, Outcome outcome) external onlyAdmin {
    require(market.yesPool > 0 && market.noPool > 0, NoOpposition());
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Why? If only one side has bets, there's no losing pool to distribute. The market must be cancelled with full refunds.

3. Manual Admin Resolution

We chose manual resolution over oracles for simplicity. For production, consider integrating:

  • Chainlink Functions for API-based resolution
  • UMA Optimistic Oracle for dispute-based resolution
  • Reality.eth for crowd-sourced resolution

Payout Formula Deep Dive

The Parimutuel Formula

payout = user_bet + (user_bet / winning_pool) * losing_pool
Enter fullscreen mode Exit fullscreen mode

Or equivalently:

payout = user_bet * (1 + losing_pool / winning_pool)
payout = user_bet * total_pool / winning_pool
Enter fullscreen mode Exit fullscreen mode

Implementation

function _calculatePayout(
    uint256 marketId,
    address user
) internal view returns (uint256) {
    Market storage market = markets[marketId];
    UserPosition storage position = userPositions[marketId][user];

    if (market.state == MarketState.Cancelled) {
        // Full refund on cancellation
        return position.yesBet + position.noBet;
    }

    // Resolved market
    uint256 winningPool;
    uint256 losingPool;
    uint256 userWinningBet;

    if (market.winningOutcome == Outcome.Yes) {
        winningPool = market.yesPool;
        losingPool = market.noPool;
        userWinningBet = position.yesBet;
    } else {
        winningPool = market.noPool;
        losingPool = market.yesPool;
        userWinningBet = position.noBet;
    }

    if (userWinningBet == 0) return 0;

    // payout = userBet + (userBet * losingPool) / winningPool
    uint256 winnings = (userWinningBet * losingPool) / winningPool;
    return userWinningBet + winnings;
}
Enter fullscreen mode Exit fullscreen mode

Example Scenarios

Scenario 1: Equal Pools

  • YES pool: 100 USDC, NO pool: 100 USDC
  • Alice bet 100 USDC on YES, YES wins
  • Payout: 100 + (100 * 100) / 100 = 200 USDC (2x return)

Scenario 2: Unequal Pools

  • YES pool: 100 USDC, NO pool: 400 USDC
  • Alice bet 100 USDC on YES, YES wins
  • Payout: 100 + (100 * 400) / 100 = 500 USDC (5x return)

Scenario 3: Multiple Winners

  • YES pool: 200 USDC (Alice: 100, Bob: 100), NO pool: 100 USDC
  • YES wins
  • Alice: 100 + (100 * 100) / 200 = 150 USDC
  • Bob: 100 + (100 * 100) / 200 = 150 USDC

Security Patterns Implemented

1. Checks-Effects-Interactions (CEI)

function claimWinnings(uint256 marketId) external {
    // CHECKS
    require(market.state != MarketState.Active, MarketNotFinalized());
    require(!position.claimed, AlreadyClaimed());
    require(position.yesBet > 0 || position.noBet > 0, NoPosition());

    // EFFECTS
    position.claimed = true;
    uint256 payout = _calculatePayout(marketId, msg.sender);

    // INTERACTIONS
    if (payout > 0) {
        IERC20(config.stablecoin).transfer(msg.sender, payout);
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Reentrancy Protection

Although we follow CEI, we also use state flags:

// The `claimed` flag is set BEFORE external call
position.claimed = true; // EFFECT
// ...
IERC20(config.stablecoin).transfer(...); // INTERACTION
Enter fullscreen mode Exit fullscreen mode

3. Integer Overflow Protection

Solidity 0.8+ has built-in overflow checks, but we're explicit:

// Safe accumulation
market.yesPool += amount; // Reverts on overflow in 0.8+
Enter fullscreen mode Exit fullscreen mode

4. Access Control

modifier onlyAdmin() {
    require(msg.sender == config.admin, NotAdmin());
    _;
}

modifier whenNotPaused() {
    require(!config.paused, Paused());
    _;
}
Enter fullscreen mode Exit fullscreen mode

5. Input Validation

function createMarket(
    string calldata question,
    uint256 resolutionTime,
    uint256 fee
) external whenNotPaused returns (uint256) {
    require(bytes(question).length > 0, EmptyQuestion());
    require(resolutionTime > block.timestamp, InvalidResolutionTime());
    // ...
}
Enter fullscreen mode Exit fullscreen mode

6. Balance Checks Before Transfer

function placeBet(...) external {
    require(
        IERC20(config.stablecoin).balanceOf(msg.sender) >= amount,
        InsufficientBalance()
    );
    require(
        IERC20(config.stablecoin).allowance(msg.sender, address(this)) >= amount,
        InsufficientAllowance()
    );
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Gas Optimization Techniques

1. Custom Errors (Solidity 0.8.4+)

// Gas expensive
require(condition, "This is an error message");

// Gas efficient (saves ~50 gas per error)
error NotAdmin();
require(condition, NotAdmin());
Enter fullscreen mode Exit fullscreen mode

2. Calldata vs Memory

// Use calldata for read-only dynamic parameters
function createMarket(
    string calldata question,  // calldata, not memory
    uint256 resolutionTime,
    uint256 fee
) external { ... }
Enter fullscreen mode Exit fullscreen mode

3. Storage Packing

struct Config {
    address admin;      // 20 bytes
    bool paused;        // 1 byte  ─┐
    uint8 decimals;     // 1 byte  ─┼─ packed into same slot
    uint16 maxFee;      // 2 bytes ─┘
}
Enter fullscreen mode Exit fullscreen mode

4. Unchecked Arithmetic (Where Safe)

// When we know overflow is impossible
unchecked {
    for (uint256 i = 0; i < marketIds.length; ++i) {
        // ++i is cheaper than i++
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Short-Circuit Evaluation

// Cheaper check first
require(market.state == MarketState.Active && 
        block.timestamp < market.resolutionTime, 
        MarketNotActive());
Enter fullscreen mode Exit fullscreen mode

Testing with Foundry

Test Structure

test/
├── BaseTest.sol           # Common setup and helpers
├── unit/
│   ├── MarketCreation.t.sol
│   ├── Betting.t.sol
│   ├── Resolution.t.sol
│   ├── Cancellation.t.sol
│   ├── Claiming.t.sol
│   ├── AccessControl.t.sol
│   └── ViewFunctions.t.sol
├── integration/
│   └── MarketLifecycle.t.sol
└── fuzz/
    └── PayoutFuzz.t.sol
Enter fullscreen mode Exit fullscreen mode

Base Test Setup

abstract contract BaseTest is Test {
    PredictionMarket public market;
    MockERC20 public stablecoin;

    address public admin = makeAddr("admin");
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    uint256 constant DECIMALS = 6;
    uint256 constant ONE_WEEK = 7 days;

    function setUp() public virtual {
        vm.startPrank(admin);
        stablecoin = new MockERC20("USDC", "USDC", DECIMALS);
        market = new PredictionMarket(
            address(stablecoin),
            DECIMALS,
            admin,
            feeRecipient,
            500 // 5% max fee
        );
        vm.stopPrank();

        // Fund test accounts
        stablecoin.mint(alice, usdc(10_000));
        stablecoin.mint(bob, usdc(10_000));

        // Approve
        vm.prank(alice);
        stablecoin.approve(address(market), type(uint256).max);
        vm.prank(bob);
        stablecoin.approve(address(market), type(uint256).max);
    }

    function usdc(uint256 amount) internal pure returns (uint256) {
        return amount * 10**DECIMALS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Unit Test Example

contract ClaimingTest is BaseTest {
    function test_ClaimWinnings_WinningSide() public {
        // Setup
        uint256 marketId = createDefaultMarket();
        placeBet(alice, marketId, Outcome.Yes, usdc(100));
        placeBet(bob, marketId, Outcome.No, usdc(50));
        warpToResolution(marketId);
        resolveMarket(marketId, Outcome.Yes);

        uint256 balanceBefore = stablecoin.balanceOf(alice);

        // Execute
        vm.prank(alice);
        market.claimWinnings(marketId);

        // Assert: 100 + (100/100) * 50 = 150
        assertEq(
            stablecoin.balanceOf(alice), 
            balanceBefore + usdc(150)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Fuzz Testing

contract PayoutFuzzTest is BaseTest {
    function testFuzz_PayoutDistribution(
        uint256 yesAmount,
        uint256 noAmount
    ) public {
        // Bound inputs to reasonable ranges
        yesAmount = bound(yesAmount, usdc(1), usdc(1_000_000));
        noAmount = bound(noAmount, usdc(1), usdc(1_000_000));

        // Setup
        uint256 marketId = createDefaultMarket();
        placeBet(alice, marketId, Outcome.Yes, yesAmount);
        placeBet(bob, marketId, Outcome.No, noAmount);
        warpToResolution(marketId);
        resolveMarket(marketId, Outcome.Yes);

        // Claim
        vm.prank(alice);
        market.claimWinnings(marketId);
        vm.prank(bob);
        market.claimWinnings(marketId);

        // Invariant: Contract should have 0 balance
        assertEq(stablecoin.balanceOf(address(market)), 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Running Tests

# Run all tests
forge test

# Run with verbosity
forge test -vvv

# Run specific test
forge test --match-test test_ClaimWinnings

# Run with gas reporting
forge test --gas-report

# Run coverage
forge coverage
Enter fullscreen mode Exit fullscreen mode

Test Results

Running 95 tests...
✓ All tests passed

Coverage:
- Line coverage: 98.35%
- Function coverage: 100%
- Branch coverage: 94.12%
Enter fullscreen mode Exit fullscreen mode

Multi-Chain Deployment

Supported Networks

Network Chain ID Stablecoin
Ethereum Mainnet 1 USDC
Base 8453 USDC
Polygon 137 USDC
Arbitrum One 42161 USDC
Optimism 10 USDC
BSC 56 USDT/BUSD

Deployment Script

// script/Deploy.s.sol
contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

        address stablecoin = vm.envOr("STABLECOIN_ADDRESS", address(0));
        uint8 decimals = uint8(vm.envOr("STABLECOIN_DECIMALS", uint256(6)));
        address admin = vm.envOr("ADMIN_ADDRESS", msg.sender);
        address feeRecipient = vm.envOr("FEE_RECIPIENT", msg.sender);
        uint16 maxFee = uint16(vm.envOr("MAX_FEE_PERCENTAGE", uint256(500)));

        vm.startBroadcast(deployerPrivateKey);

        // Deploy mock token if needed (testnet)
        if (stablecoin == address(0)) {
            MockERC20 token = new MockERC20("Mock USDC", "mUSDC", decimals);
            stablecoin = address(token);
        }

        PredictionMarket market = new PredictionMarket(
            stablecoin,
            decimals,
            admin,
            feeRecipient,
            maxFee
        );

        vm.stopBroadcast();

        console.log("PredictionMarket deployed at:", address(market));
    }
}
Enter fullscreen mode Exit fullscreen mode

Deploy Commands

# Deploy to Base Sepolia (testnet)
forge script script/Deploy.s.sol:DeployScript \
    --rpc-url $BASE_SEPOLIA_RPC \
    --broadcast \
    --verify

# Deploy to Polygon Mainnet
forge script script/Deploy.s.sol:DeployScript \
    --rpc-url $POLYGON_MAINNET_RPC \
    --broadcast \
    --verify \
    --etherscan-api-key $POLYGONSCAN_API_KEY
Enter fullscreen mode Exit fullscreen mode

Foundry Configuration

# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
optimizer = true
optimizer_runs = 200
via_ir = false

[rpc_endpoints]
ethereum = "${ETHEREUM_MAINNET_RPC}"
base = "${BASE_MAINNET_RPC}"
polygon = "${POLYGON_MAINNET_RPC}"
arbitrum = "${ARBITRUM_MAINNET_RPC}"
optimism = "${OPTIMISM_MAINNET_RPC}"
bsc = "${BSC_MAINNET_RPC}"

[etherscan]
ethereum = { key = "${ETHERSCAN_API_KEY}" }
base = { key = "${BASESCAN_API_KEY}" }
polygon = { key = "${POLYGONSCAN_API_KEY}" }
arbitrum = { key = "${ARBISCAN_API_KEY}" }
optimism = { key = "${OPSCAN_API_KEY}" }
bsc = { key = "${BSCSCAN_API_KEY}" }
Enter fullscreen mode Exit fullscreen mode

Conclusion

This prediction market smart contract demonstrates:

  • Clean architecture with separation of concerns
  • Gas-efficient Solidity patterns
  • Comprehensive security measures
  • Thorough testing with Foundry
  • Multi-chain deployment capability

What's Next?

For a production deployment, consider adding:

  1. Oracle Integration - Chainlink, UMA, or Reality.eth
  2. Time-weighted fees - Dynamic fees based on market age
  3. Liquidity incentives - Rewards for early participants
  4. Governance - DAO-controlled resolution disputes
  5. Cross-chain - LayerZero or Axelar for unified liquidity

Resources


Disclaimer: This code is for educational purposes only. It has not been audited and should not be used in production without proper security review.


Found this useful? Star the repo and follow for more Web3 tutorials!

solidity #ethereum #smartcontracts #defi #predictionmarket #foundry #web3 #blockchain #tutorial #opensource

Top comments (0)