Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Intelligence View
⚡ tsecurity.de Intelligence

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…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!




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











































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









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!






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

Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Building, ProductionReady, Prediction, Market · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY Kritische Sicherheitsmeldung
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick