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


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()                     │
└─────────────────────────────────────────────────────────────┘

State Machine

Markets follow a strict state machine:

    ┌──────────┐
    │  Active  │ ←── createMarket()
    └────┬─────┘

         │ (resolution time reached)

    ┌─────────────────────────────┐
    │                             │
    ▼                             ▼
┌──────────┐              ┌───────────┐
│ Resolved │              │ Cancelled │
└──────────┘              └───────────┘
    │                             │
    └──────────┬──────────────────┘

        claimWinnings()

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;
}

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
    });
}

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());
    // ...
}

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

Or equivalently:

payout = user_bet * (1 + losing_pool / winning_pool)
payout = user_bet * total_pool / winning_pool

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;
}

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);
    }
}

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

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+

4. Access Control

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

modifier whenNotPaused() {
    require(!config.paused, Paused());
    _;
}

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());
    // ...
}

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()
    );
    // ...
}

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());

2. Calldata vs Memory

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

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 ─┘
}

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++
    }
}

5. Short-Circuit Evaluation

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

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

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;
    }
}

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)
        );
    }
}

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);
    }
}

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

Test Results

Running 95 tests...
✓ All tests passed

Coverage:
- Line coverage: 98.35%
- Function coverage: 100%
- Branch coverage: 94.12%

Multi-Chain Deployment

Supported Networks

NetworkChain IDStablecoin
Ethereum Mainnet1USDC
Base8453USDC
Polygon137USDC
Arbitrum One42161USDC
Optimism10USDC
BSC56USDT/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));
    }
}

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

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}" }

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!

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