Building a Prediction Market on Solana with Anchor: Complete Rust Smart Contract Guide


Building a Prediction Market on Solana with Anchor: Complete Rust Smart Contract Guide

TL;DR

I built an open-source prediction market program on Solana using Anchor framework. Features include pot-based binary markets, PDA-based account management, SPL token integration, and proportional payout distribution. 48 comprehensive tests covering the full market lifecycle.

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


Table of Contents

  1. Introduction
  2. Solana vs EVM: Key Differences
  3. Project Architecture
  4. Account Design with PDAs
  5. Instruction Implementation
  6. SPL Token Integration
  7. Error Handling
  8. Testing with Anchor
  9. Deployment Guide
  10. Conclusion

Introduction

After building a prediction market on EVM chains, I ported the same logic to Solana using the Anchor framework. This article covers the complete implementation, highlighting Solana-specific patterns like PDAs, CPIs, and the account model.

What We’re Building

  • Binary prediction markets (YES/NO outcomes)
  • Pot-based parimutuel betting system
  • SPL Token integration (USDC, any SPL token)
  • Admin-controlled resolution
  • PDA-based state management

Tech Stack

  • Rust - Programming language
  • Anchor 0.32+ - Solana development framework
  • SPL Token - Token standard
  • TypeScript - Test suite
  • Mocha/Chai - Testing framework

Solana vs EVM: Key Differences

Before diving in, let’s understand the paradigm shift:

AspectEVM (Ethereum)Solana
State StorageContract stores its own stateAccounts are separate from programs
Account ModelSingle contract addressProgram + multiple data accounts
ExecutionSequentialParallel (if accounts don’t overlap)
FeesGas (paid in ETH)Rent + transaction fees (paid in SOL)
Token StandardERC20 (per-contract)SPL Token (unified program)
RandomnessBlock hash (weak)VRF or external
ComposabilityInternal callsCross-Program Invocations (CPI)

The Account Model

In Solana, programs are stateless. All state lives in accounts:

┌─────────────────┐     ┌─────────────────┐
│    Program      │     │  Data Account   │
│  (Executable)   │────▶│  (State/Data)   │
│                 │     │                 │
│  prediction_    │     │  Config PDA     │
│  market.so      │     │  Market PDA     │
│                 │     │  Position PDA   │
│                 │     │  Vault PDA      │
└─────────────────┘     └─────────────────┘

Project Architecture

Directory Structure

programs/prediction_market/
├── src/
│   ├── lib.rs              # Program entrypoint
│   ├── constants.rs        # Global constants
│   ├── error.rs            # Custom errors
│   ├── state/
│   │   ├── mod.rs
│   │   ├── config.rs       # Config account
│   │   ├── market.rs       # Market account
│   │   └── user_position.rs# User position account
│   └── instructions/
│       ├── mod.rs
│       ├── initialize.rs
│       ├── create_market.rs
│       ├── place_bet.rs
│       ├── resolve_market.rs
│       ├── cancel_market.rs
│       ├── claim_winnings.rs
│       ├── update_config.rs
│       └── pause.rs
├── Cargo.toml
└── Xargo.toml

State Machine

                    ┌──────────┐
                    │  Active  │◀── create_market
                    └────┬─────┘

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

             claim_winnings

Account Design with PDAs

What are PDAs?

Program Derived Addresses (PDAs) are deterministic addresses derived from seeds and a program ID. They:

  • Don’t have private keys (can’t sign externally)
  • Can only be “signed” by the program that derived them
  • Are perfect for program-owned accounts

Config Account

// state/config.rs
use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct Config {
    /// Admin who can resolve/cancel markets
    pub admin: Pubkey,
    /// Fee recipient address
    pub fee_recipient: Pubkey,
    /// SPL token mint for betting
    pub token_mint: Pubkey,
    /// Token decimals (cached for convenience)
    pub token_decimals: u8,
    /// Maximum fee in basis points (1000 = 10%)
    pub max_fee_bps: u16,
    /// Global market counter
    pub market_counter: u64,
    /// Pause flag
    pub paused: bool,
    /// PDA bump seed
    pub bump: u8,
}

impl Config {
    pub const SEED: &'static [u8] = b"config";
}

PDA Derivation:

seeds = [b"config"]

Market Account

// state/market.rs
use anchor_lang::prelude::*;

#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)]
pub enum MarketState {
    Active,
    Resolved,
    Cancelled,
}

#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace, Debug)]
pub enum Outcome {
    None,
    Yes,
    No,
}

#[account]
#[derive(InitSpace)]
pub struct Market {
    pub id: u64,
    #[max_len(200)]
    pub question: String,
    pub resolution_time: i64,
    pub state: MarketState,
    pub winning_outcome: Outcome,
    pub yes_pool: u64,
    pub no_pool: u64,
    pub creation_fee: u64,
    pub creator: Pubkey,
    pub created_at: i64,
    /// Config snapshot - fee recipient at creation
    pub config_fee_recipient: Pubkey,
    /// Config snapshot - max fee at creation
    pub config_max_fee_bps: u16,
    pub bump: u8,
    pub vault_bump: u8,
}

impl Market {
    pub const SEED: &'static [u8] = b"market";
    pub const VAULT_SEED: &'static [u8] = b"vault";
}

PDA Derivation:

// Market PDA
seeds = [b"market", market_id.to_le_bytes()]

// Vault PDA (token account owned by market)
seeds = [b"vault", market_id.to_le_bytes()]

User Position Account

// state/user_position.rs
use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct UserPosition {
    pub market_id: u64,
    pub user: Pubkey,
    pub yes_bet: u64,
    pub no_bet: u64,
    pub claimed: bool,
    pub bump: u8,
}

impl UserPosition {
    pub const SEED: &'static [u8] = b"position";
}

PDA Derivation:

seeds = [b"position", market_id.to_le_bytes(), user.key()]

Instruction Implementation

Initialize Instruction

// instructions/initialize.rs
use anchor_lang::prelude::*;
use anchor_spl::token::Mint;
use crate::constants::MAX_FEE_LIMIT;
use crate::error::PredictionMarketError;
use crate::state::Config;

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub admin: Signer<'info>,

    #[account(
        init,
        payer = admin,
        space = 8 + Config::INIT_SPACE,
        seeds = [Config::SEED],
        bump
    )]
    pub config: Account<'info, Config>,

    /// The SPL token mint for betting
    pub token_mint: Account<'info, Mint>,

    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<Initialize>,
    fee_recipient: Pubkey,
    max_fee_bps: u16
) -> Result<()> {
    require!(
        max_fee_bps <= MAX_FEE_LIMIT,
        PredictionMarketError::InvalidFee
    );

    let config = &mut ctx.accounts.config;
    config.admin = ctx.accounts.admin.key();
    config.fee_recipient = fee_recipient;
    config.token_mint = ctx.accounts.token_mint.key();
    config.token_decimals = ctx.accounts.token_mint.decimals;
    config.max_fee_bps = max_fee_bps;
    config.market_counter = 0;
    config.paused = false;
    config.bump = ctx.bumps.config;

    msg!("Prediction Market initialized");
    Ok(())
}

Create Market Instruction

// instructions/create_market.rs
#[derive(Accounts)]
pub struct CreateMarket<'info> {
    #[account(mut)]
    pub creator: Signer<'info>,

    #[account(
        mut,
        seeds = [Config::SEED],
        bump = config.bump,
        constraint = !config.paused @ PredictionMarketError::Paused
    )]
    pub config: Account<'info, Config>,

    #[account(
        init,
        payer = creator,
        space = 8 + Market::INIT_SPACE,
        seeds = [Market::SEED, (config.market_counter + 1).to_le_bytes().as_ref()],
        bump
    )]
    pub market: Account<'info, Market>,

    /// Market vault for holding tokens
    #[account(
        init,
        payer = creator,
        seeds = [Market::VAULT_SEED, (config.market_counter + 1).to_le_bytes().as_ref()],
        bump,
        token::mint = token_mint,
        token::authority = market  // Market PDA owns the vault
    )]
    pub market_vault: Account<'info, TokenAccount>,

    #[account(constraint = token_mint.key() == config.token_mint)]
    pub token_mint: Account<'info, Mint>,

    #[account(
        mut,
        token::mint = token_mint,
        token::authority = creator
    )]
    pub creator_token_account: Account<'info, TokenAccount>,

    #[account(mut, token::mint = token_mint)]
    pub fee_recipient_token_account: Account<'info, TokenAccount>,

    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<CreateMarket>,
    question: String,
    resolution_time: i64,
    fee_amount: u64,
) -> Result<()> {
    // Validations
    require!(!question.is_empty(), PredictionMarketError::EmptyQuestion);
    require!(
        question.len() <= MAX_QUESTION_LENGTH,
        PredictionMarketError::QuestionTooLong
    );

    let clock = Clock::get()?;
    require!(
        resolution_time > clock.unix_timestamp,
        PredictionMarketError::InvalidResolutionTime
    );

    // Transfer fee if applicable
    if fee_amount > 0 {
        let cpi_accounts = Transfer {
            from: ctx.accounts.creator_token_account.to_account_info(),
            to: ctx.accounts.fee_recipient_token_account.to_account_info(),
            authority: ctx.accounts.creator.to_account_info(),
        };
        let cpi_ctx = CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            cpi_accounts
        );
        transfer(cpi_ctx, fee_amount)?;
    }

    // Increment market counter
    let config = &mut ctx.accounts.config;
    config.market_counter = config.market_counter
        .checked_add(1)
        .ok_or(PredictionMarketError::Overflow)?;

    // Initialize market
    let market = &mut ctx.accounts.market;
    market.id = config.market_counter;
    market.question = question;
    market.resolution_time = resolution_time;
    market.state = MarketState::Active;
    market.winning_outcome = Outcome::None;
    market.yes_pool = 0;
    market.no_pool = 0;
    market.creation_fee = fee_amount;
    market.creator = ctx.accounts.creator.key();
    market.created_at = clock.unix_timestamp;
    // Snapshot config at creation
    market.config_fee_recipient = config.fee_recipient;
    market.config_max_fee_bps = config.max_fee_bps;
    market.bump = ctx.bumps.market;
    market.vault_bump = ctx.bumps.market_vault;

    msg!("Market created: {}", market.id);
    Ok(())
}

Place Bet Instruction

// instructions/place_bet.rs
#[derive(Accounts)]
#[instruction(market_id: u64)]
pub struct PlaceBet<'info> {
    #[account(mut)]
    pub bettor: Signer<'info>,

    #[account(
        seeds = [Config::SEED],
        bump = config.bump,
        constraint = !config.paused @ PredictionMarketError::Paused
    )]
    pub config: Account<'info, Config>,

    #[account(
        mut,
        seeds = [Market::SEED, market_id.to_le_bytes().as_ref()],
        bump = market.bump,
        constraint = market.state == MarketState::Active @ PredictionMarketError::MarketNotActive
    )]
    pub market: Account<'info, Market>,

    #[account(
        mut,
        seeds = [Market::VAULT_SEED, market_id.to_le_bytes().as_ref()],
        bump = market.vault_bump
    )]
    pub market_vault: Account<'info, TokenAccount>,

    #[account(
        init_if_needed,  // Creates position on first bet
        payer = bettor,
        space = 8 + UserPosition::INIT_SPACE,
        seeds = [UserPosition::SEED, market_id.to_le_bytes().as_ref(), bettor.key().as_ref()],
        bump
    )]
    pub user_position: Account<'info, UserPosition>,

    #[account(
        mut,
        token::mint = config.token_mint,
        token::authority = bettor
    )]
    pub bettor_token_account: Account<'info, TokenAccount>,

    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<PlaceBet>,
    market_id: u64,
    outcome: Outcome,
    amount: u64,
) -> Result<()> {
    require!(amount > 0, PredictionMarketError::ZeroAmount);
    require!(
        outcome == Outcome::Yes || outcome == Outcome::No,
        PredictionMarketError::InvalidOutcome
    );

    let clock = Clock::get()?;
    require!(
        clock.unix_timestamp < ctx.accounts.market.resolution_time,
        PredictionMarketError::MarketExpired
    );

    // Transfer tokens to vault
    let cpi_accounts = Transfer {
        from: ctx.accounts.bettor_token_account.to_account_info(),
        to: ctx.accounts.market_vault.to_account_info(),
        authority: ctx.accounts.bettor.to_account_info(),
    };
    let cpi_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts
    );
    transfer(cpi_ctx, amount)?;

    // Update market pools
    let market = &mut ctx.accounts.market;
    if outcome == Outcome::Yes {
        market.yes_pool = market.yes_pool
            .checked_add(amount)
            .ok_or(PredictionMarketError::Overflow)?;
    } else {
        market.no_pool = market.no_pool
            .checked_add(amount)
            .ok_or(PredictionMarketError::Overflow)?;
    }

    // Update user position
    let position = &mut ctx.accounts.user_position;
    if position.market_id == 0 {
        // Initialize new position
        position.market_id = market_id;
        position.user = ctx.accounts.bettor.key();
        position.yes_bet = 0;
        position.no_bet = 0;
        position.claimed = false;
        position.bump = ctx.bumps.user_position;
    }

    if outcome == Outcome::Yes {
        position.yes_bet = position.yes_bet
            .checked_add(amount)
            .ok_or(PredictionMarketError::Overflow)?;
    } else {
        position.no_bet = position.no_bet
            .checked_add(amount)
            .ok_or(PredictionMarketError::Overflow)?;
    }

    msg!("Bet placed: {} on {:?}", amount, outcome);
    Ok(())
}

Claim Winnings Instruction

// instructions/claim_winnings.rs
#[derive(Accounts)]
#[instruction(market_id: u64)]
pub struct ClaimWinnings<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        seeds = [Market::SEED, market_id.to_le_bytes().as_ref()],
        bump = market.bump,
        constraint = market.state != MarketState::Active @ PredictionMarketError::MarketNotFinalized
    )]
    pub market: Account<'info, Market>,

    #[account(
        mut,
        seeds = [Market::VAULT_SEED, market_id.to_le_bytes().as_ref()],
        bump = market.vault_bump
    )]
    pub market_vault: Account<'info, TokenAccount>,

    #[account(
        mut,
        seeds = [UserPosition::SEED, market_id.to_le_bytes().as_ref(), user.key().as_ref()],
        bump = user_position.bump,
        constraint = user_position.user == user.key() @ PredictionMarketError::InvalidAdmin,
        constraint = !user_position.claimed @ PredictionMarketError::AlreadyClaimed
    )]
    pub user_position: Account<'info, UserPosition>,

    #[account(mut, token::authority = user)]
    pub user_token_account: Account<'info, TokenAccount>,

    pub token_program: Program<'info, Token>,
}

pub fn handler(ctx: Context<ClaimWinnings>, market_id: u64) -> Result<()> {
    let market = &ctx.accounts.market;
    let position = &ctx.accounts.user_position;

    require!(
        position.yes_bet > 0 || position.no_bet > 0,
        PredictionMarketError::NoPosition
    );

    // Calculate payout
    let payout: u64 = if market.state == MarketState::Resolved {
        if market.winning_outcome == Outcome::Yes && position.yes_bet > 0 {
            // User bet on YES and YES won
            let winning_pool = market.yes_pool;
            let losing_pool = market.no_pool;
            let share = (position.yes_bet as u128)
                .checked_mul(losing_pool as u128)
                .ok_or(PredictionMarketError::Overflow)?
                .checked_div(winning_pool as u128)
                .ok_or(PredictionMarketError::Overflow)?;
            position.yes_bet
                .checked_add(share as u64)
                .ok_or(PredictionMarketError::Overflow)?
        } else if market.winning_outcome == Outcome::No && position.no_bet > 0 {
            // User bet on NO and NO won
            let winning_pool = market.no_pool;
            let losing_pool = market.yes_pool;
            let share = (position.no_bet as u128)
                .checked_mul(losing_pool as u128)
                .ok_or(PredictionMarketError::Overflow)?
                .checked_div(winning_pool as u128)
                .ok_or(PredictionMarketError::Overflow)?;
            position.no_bet
                .checked_add(share as u64)
                .ok_or(PredictionMarketError::Overflow)?
        } else {
            0 // Bet on losing side
        }
    } else {
        // Cancelled - full refund
        position.yes_bet
            .checked_add(position.no_bet)
            .ok_or(PredictionMarketError::Overflow)?
    };

    // Mark as claimed
    ctx.accounts.user_position.claimed = true;

    // Transfer payout using PDA signature
    if payout > 0 {
        let market_id_bytes = market_id.to_le_bytes();
        let seeds = &[
            Market::SEED,
            market_id_bytes.as_ref(),
            &[market.bump]
        ];
        let signer_seeds = &[&seeds[..]];

        let cpi_accounts = Transfer {
            from: ctx.accounts.market_vault.to_account_info(),
            to: ctx.accounts.user_token_account.to_account_info(),
            authority: ctx.accounts.market.to_account_info(),
        };
        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            cpi_accounts,
            signer_seeds,
        );
        transfer(cpi_ctx, payout)?;
    }

    msg!("Claimed {} tokens", payout);
    Ok(())
}

SPL Token Integration

Cross-Program Invocations (CPI)

Solana uses CPIs to interact with other programs. For SPL tokens:

use anchor_spl::token::{transfer, Token, TokenAccount, Transfer};

// Transfer FROM user TO vault (user signs)
let cpi_accounts = Transfer {
    from: ctx.accounts.user_token_account.to_account_info(),
    to: ctx.accounts.vault.to_account_info(),
    authority: ctx.accounts.user.to_account_info(),
};
let cpi_ctx = CpiContext::new(
    ctx.accounts.token_program.to_account_info(),
    cpi_accounts,
);
transfer(cpi_ctx, amount)?;

// Transfer FROM vault TO user (PDA signs)
let seeds = &[b"vault", market_id.as_ref(), &[bump]];
let signer = &[&seeds[..]];

let cpi_accounts = Transfer {
    from: ctx.accounts.vault.to_account_info(),
    to: ctx.accounts.user_token_account.to_account_info(),
    authority: ctx.accounts.vault_authority.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
    ctx.accounts.token_program.to_account_info(),
    cpi_accounts,
    signer,
);
transfer(cpi_ctx, amount)?;

Token Account Constraints

Anchor provides powerful constraints for token accounts:

#[account(
    mut,
    token::mint = expected_mint,      // Verify mint
    token::authority = expected_owner  // Verify owner
)]
pub token_account: Account<'info, TokenAccount>,

#[account(
    init,
    payer = payer,
    seeds = [b"vault", id.as_ref()],
    bump,
    token::mint = mint,
    token::authority = vault_authority  // PDA owns this account
)]
pub vault: Account<'info, TokenAccount>,

Error Handling

Custom Errors

// error.rs
use anchor_lang::prelude::*;

#[error_code]
pub enum PredictionMarketError {
    #[msg("Invalid admin")]
    InvalidAdmin,

    #[msg("Contract is paused")]
    Paused,

    #[msg("Contract is not paused")]
    NotPaused,

    #[msg("Invalid fee")]
    InvalidFee,

    #[msg("Empty question")]
    EmptyQuestion,

    #[msg("Question too long")]
    QuestionTooLong,

    #[msg("Invalid resolution time")]
    InvalidResolutionTime,

    #[msg("Market not active")]
    MarketNotActive,

    #[msg("Market expired")]
    MarketExpired,

    #[msg("Market not expired")]
    MarketNotExpired,

    #[msg("Market already finalized")]
    MarketAlreadyFinalized,

    #[msg("Market not finalized")]
    MarketNotFinalized,

    #[msg("Invalid outcome")]
    InvalidOutcome,

    #[msg("No opposition")]
    NoOpposition,

    #[msg("Zero amount")]
    ZeroAmount,

    #[msg("No position")]
    NoPosition,

    #[msg("Already claimed")]
    AlreadyClaimed,

    #[msg("Arithmetic overflow")]
    Overflow,
}

Using Errors

// With require! macro
require!(amount > 0, PredictionMarketError::ZeroAmount);

// With constraint
#[account(
    constraint = !config.paused @ PredictionMarketError::Paused
)]
pub config: Account<'info, Config>,

// Manual return
if some_condition {
    return Err(PredictionMarketError::InvalidAdmin.into());
}

Testing with Anchor

Test Setup

// tests/prediction_market.ts
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { PredictionMarket } from "../target/types/prediction_market";
import {
  Keypair,
  PublicKey,
  SystemProgram,
  LAMPORTS_PER_SOL,
} from "@solana/web3.js";
import {
  TOKEN_PROGRAM_ID,
  createMint,
  createAccount,
  mintTo,
  getAccount,
} from "@solana/spl-token";
import { assert } from "chai";

describe("prediction_market", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.PredictionMarket as Program<PredictionMarket>;

  let admin: Keypair;
  let alice: Keypair;
  let bob: Keypair;
  let tokenMint: PublicKey;
  let configPda: PublicKey;

  const DECIMALS = 6;
  const usdc = (amount: number) => amount * 10 ** DECIMALS;

  // Helper: derive market PDAs
  const getMarketPdas = (marketId: number) => {
    const marketIdBuffer = Buffer.alloc(8);
    marketIdBuffer.writeBigUInt64LE(BigInt(marketId));

    const [marketPda] = PublicKey.findProgramAddressSync(
      [Buffer.from("market"), marketIdBuffer],
      program.programId
    );
    const [vaultPda] = PublicKey.findProgramAddressSync(
      [Buffer.from("vault"), marketIdBuffer],
      program.programId
    );
    return { marketPda, vaultPda };
  };

  before(async () => {
    // Setup accounts, mint tokens, etc.
  });
});

Test Categories

describe("Market Creation", () => {
  it("TC-MC-001: should create market successfully", async () => {
    // ...
  });

  it("TC-MC-008: should reject empty question", async () => {
    try {
      await program.methods
        .createMarket("", new anchor.BN(resolutionTime), new anchor.BN(0))
        .accounts({ /* ... */ })
        .signers([alice])
        .rpc();
      assert.fail("Should have thrown");
    } catch (err: any) {
      assert.include(err.message, "EmptyQuestion");
    }
  });
});

describe("Betting", () => {
  it("TC-BT-001: should place YES bet", async () => {
    const balanceBefore = (await getAccount(connection, aliceToken)).amount;

    await placeBet(alice, marketId, { yes: {} }, usdc(100));

    const balanceAfter = (await getAccount(connection, aliceToken)).amount;
    assert.equal(balanceBefore - balanceAfter, BigInt(usdc(100)));
  });
});

describe("Integration - Full Lifecycle", () => {
  it("TC-IT-001: complete flow YES wins", async () => {
    // 1. Create market
    const marketId = await createMarket("Test?", 2, alice);

    // 2. Place bets
    await placeBet(alice, marketId, { yes: {} }, usdc(100));
    await placeBet(bob, marketId, { no: {} }, usdc(50));

    // 3. Wait for resolution time
    await sleep(3000);

    // 4. Resolve
    await resolveMarket(marketId, { yes: {} });

    // 5. Claim
    const aliceBefore = (await getAccount(connection, aliceToken)).amount;
    await claimWinnings(alice, marketId);
    const aliceAfter = (await getAccount(connection, aliceToken)).amount;

    // Alice: 100 + (100/100) * 50 = 150
    assert.equal(aliceAfter - aliceBefore, BigInt(usdc(150)));
  });
});

Running Tests

# Start local validator and run tests
anchor test

# Run specific test
anchor test -- --grep "should create market"

# Run with logs
anchor test -- --verbose

Test Results

  prediction_market
    Initialize
      ✔ should initialize the program
    Market Creation
      ✔ TC-MC-001: should create market successfully
      ✔ TC-MC-002: should create market with fee
      ✔ TC-MC-008: should reject empty question
      ✔ TC-MC-009: should reject when paused
    Betting
      ✔ TC-BT-001: should place YES bet successfully
      ✔ TC-BT-002: should place NO bet successfully
      ✔ TC-BT-003: should allow multiple bets same side
      ✔ TC-BT-004: should allow hedged bets (both sides)
      ✔ TC-BT-005: should reject zero amount bet
    Resolution
      ✔ TC-RS-001: should resolve with YES winning
      ✔ TC-RS-002: should resolve with NO winning
      ✔ TC-RS-004: should reject non-admin resolution
      ✔ TC-RS-007/008: should reject with no opposition
    Cancellation
      ✔ TC-CN-001: should cancel market successfully
      ✔ TC-CN-003: should reject non-admin cancellation
    Claiming
      ✔ TC-CL-001: should claim winnings (winning side)
      ✔ TC-CL-002: should claim winnings (losing side - zero)
      ✔ TC-CL-003: should claim refund (cancelled market)
      ✔ TC-CL-006: should reject double claim
    Access Control
      ✔ TC-AC-001: should allow admin to update config
      ✔ TC-AC-003: should allow admin to pause
    Integration
      ✔ TC-IT-001: complete flow YES wins
      ✔ TC-IT-002: complete flow NO wins
      ✔ TC-IT-003: complete flow cancelled

  48 passing (2m)

Deployment Guide

Prerequisites

# Install Solana CLI
sh -c "$(curl -sSfL https://release.solana.com/v1.18.0/install)"

# Install Anchor
cargo install --git https://github.com/coral-xyz/anchor anchor-cli

# Create wallet
solana-keygen new

Build and Deploy

# Build
anchor build

# Get program ID
solana-keygen pubkey target/deploy/prediction_market-keypair.json

# Update declare_id! in lib.rs with the program ID
# Update Anchor.toml with the program ID

# Deploy to devnet
anchor deploy --provider.cluster devnet

# Deploy to mainnet
anchor deploy --provider.cluster mainnet

Anchor.toml Configuration

[toolchain]

[features]
resolution = true
skip-lint = false

[programs.localnet]
prediction_market = "YourProgramId..."

[programs.devnet]
prediction_market = "YourProgramId..."

[programs.mainnet]
prediction_market = "YourProgramId..."

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "devnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

Initialize After Deployment

// scripts/initialize.ts
const tx = await program.methods
  .initialize(feeRecipient, 500) // 5% max fee
  .accounts({
    admin: wallet.publicKey,
    config: configPda,
    tokenMint: USDC_MINT,
    systemProgram: SystemProgram.programId,
  })
  .rpc();

console.log("Initialized:", tx);

Conclusion

This Solana prediction market demonstrates:

  • PDA-based architecture for deterministic account addresses
  • SPL Token integration with CPIs
  • Anchor framework patterns and macros
  • Comprehensive testing with TypeScript
  • Production-ready error handling

Key Takeaways

  1. Accounts are separate from programs - think of them as database tables
  2. PDAs enable program-owned accounts - crucial for holding funds
  3. CPIs are how programs interact - like internal function calls in EVM
  4. Anchor simplifies everything - macros handle serialization, validation

What’s Next?

For production, consider:

  1. Switchboard VRF for randomness
  2. Pyth/Switchboard oracles for price feeds
  3. Clockwork for automated resolution
  4. Compressed NFTs for position receipts
  5. Governance with Realms

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 Solana tutorials!

Tags: #solana #rust #anchor #smartcontracts #defi #predictionmarket #web3 #blockchain #tutorial #opensource #spl #pda