Skip to content

LaChance-Lab/Web3-Prediction-Market-Smart-Contract

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Prediction Market — Smart Contract Specification (Multi-Sport & Crypto)

Version: 2.1
Target: Solana (Anchor)
Scope: Core Smart Contracts (generic markets, multi-outcome, escrow, resolve, fees). Designed to scale across many sports and crypto prediction trading. Aligned with security best practices and defensive implementation.


polymarket_1.mp4
claim.mp4

API Docs: https://api.suffle.bet/api/docs

1. Overview

1.1 Purpose

A generic Solana prediction-market program: one program supports any event that resolves to a single outcome from a fixed set. Subjects group related markets (e.g. a match, a league, a crypto question); each market is created under a subject (name, id). Users stake SOL only on one or more outcomes per market; they may place multiple bets (including multiple times on the same outcome). After the event resolves, the market settles to one outcome and winners share the pool (minus protocol fee).

Use cases (same program):

  • Cricket (per-ball): One market per delivery; outcomes 0–7 = dot, 1, 2, 3, 4, 6, wicket, other. market_id = hash or encoding of (match_id, innings, over, ball).
  • Other sports: Football (home/draw/away), basketball, tennis, etc. Each event has a unique market_id and 2–N outcomes defined by the frontend/oracle.
  • Crypto prediction: e.g. “BTC above $100k by Friday?” Binary (outcome 0 = no, 1 = yes) or bucketed (price ranges). market_id identifies the question; oracle resolves.

The program does not interpret market_id or outcome labels; it only enforces escrow, resolution, and payouts. Semantics (sport, event, outcome meaning) are off-chain (APIs, oracles, UI).

1.2 Design Principles

  • One market per event: Deterministic PDA from market_id (e.g. 32 bytes). No sport-specific fields on-chain; market_id is supplied by the client/oracle (from any source: cricket API, football API, crypto oracle).
  • Configurable outcomes per market: Each market has outcome_count (2 to MAX_OUTCOMES, e.g. 16). Binary markets (crypto yes/no) use 2; cricket per-ball uses 8; other sports use as needed.
  • Multi-outcome & multiple bets: Users may bet on multiple outcomes in the same market and multiple times per outcome. Amounts are summed per outcome.
  • SOL only: All stakes and payouts in SOL (lamports). No SPL token for betting in M1.
  • Bet only before resolution: Once a market is resolved, no further bets.
  • Escrow-based: Single vault per market; no AMM in M1.
  • Deterministic resolve: Single resolved_outcome; once set, no double settlement.
  • Protocol fee: Configurable bps at resolution; on every resolution the protocol fee is transferred to the treasury wallet. No resolution without treasury receiving the fee.
  • Security by design: Single resolution path; no user-controlled CPI; vault only withdrawable by program; checked arithmetic; strict access control and validation (see §16).

1.3 Betting Rules (summary)

Rule Description
Stake currency SOL only (lamports).
Multiple outcomes User may place bets on several different outcomes in the same market.
Multiple bets per outcome User may call place_bet multiple times for the same outcome; amounts are summed.
Betting window Bets allowed only while the market is open (not resolved).

2. Domain Model

2.1 Generic Event and Market

  • Market: A single prediction event, identified by market_id (fixed-size, e.g. 32 bytes). The program does not create or store sport- or asset-specific state; market_id is provided by the client/oracle and can come from any source (cricket API, football API, crypto oracle, etc.).
  • Outcome count: Each market has outcome_count in [2, MAX_OUTCOMES] (e.g. 2–16). Outcome indices are 0 .. outcome_count - 1. The meaning of each index (e.g. “home win”, “BTC > 100k”) is defined off-chain.
  • Resolution: Exactly one outcome occurs per market. The resolution authority sets resolved_outcome to that index.

2.2 Outcome Set (per market)

The program supports up to MAX_OUTCOMES (e.g. 16) outcomes per market. Only the first outcome_count are valid for that market. Outcome labels (e.g. “Dot”, “Home”, “Yes”) are off-chain; on-chain only the index (0..=N-1) is stored.

Example — Cricket (per-ball): outcome_count = 8. Indices 0–7 = Dot, 1, 2, 3, 4, 6, Wicket, Other.

Index Cricket (per-ball) Football (match winner) Crypto (binary)
0 Dot Home No
1 One Draw Yes
2 Two Away

Constant: MAX_OUTCOMES = 16 (or 8 for smaller accounts). All account arrays use this size; per-market only indices < outcome_count are used.


3. Accounts and State Layout

3.1 Global Config (singleton)

PDA: ["config"]

Field Type Description
authority Pubkey Admin; can update config, pause
treasury Pubkey Receives protocol fee
protocol_fee_bps u16 Fee in basis points (e.g. 500 = 5%)
resolution_authority Pubkey Allowed to call resolve (or oracle)
paused bool If true, place_bet disabled
bump u8 PDA bump
initialized bool Config has been set

Invariants: protocol_fee_bps <= 10_000. Only authority can set paused, treasury, protocol_fee_bps, resolution_authority. Treasury must not be the system program or default Pubkey so that fees are never sent to an invalid address.


3.2 Subject (per category / event group)

PDA: ["subject", subject_id]
A subject groups related markets (e.g. a cricket match, a football league, a crypto symbol). Markets are created under a subject so that UIs and indexers can list and filter by subject.

Field Type Description
subject_id [u8; 32] Unique subject identifier (chosen by creator)
name String Display name (max length e.g. 64 bytes)
creator Pubkey Account that created the subject
created_at i64 Clock::get().unix_timestamp at creation
bump u8 PDA bump

Invariants: subject_id is unique (one PDA per subject_id). The name and subject_id are set at creation; they can be chosen to match external APIs (e.g. match title, symbol, league name).

Examples:

  • Cricket: subject_id = hash(match_id), name = "India vs Australia, 2025".
  • Crypto: subject_id = hash("BTC-100k"), name = "BTC above $100k by Friday?".
  • Football: subject_id = hash(match_id), name = "Team A vs Team B".

3.3 Market (per event — any sport or crypto)

PDA: ["market", market_id]
market_id is a fixed-size byte array (e.g. [u8; 32]). It uniquely identifies the event within the whole program. Every market belongs to one subject; the market stores subject_id (the same 32-byte id as the Subject account), not the Subject’s Pubkey.

Why store subject_id instead of subject (Pubkey)?

  • Logical key: Your external system (APIs, oracles) thinks in terms of subject identifiers (match_id, question_id, etc.). Storing subject_id on the market keeps that canonical id on-chain and makes indexing and API alignment simple (“all markets for subject_id X”).
  • Derivation: The Subject PDA is always derivable as PDA(["subject", subject_id]), so you can load the Subject account (e.g. for name, creator) whenever needed. You don’t lose the link.
  • Self-describing: A market account alone tells you which subject it belongs to without loading another account.
  • Same size: Both are 32 bytes (Pubkey vs [u8; 32]).
Field Type Description
market_id [u8; 32] Unique event identifier (from any source)
subject_id [u8; 32] Subject this market belongs to (same id as Subject account)
outcome_count u8 Number of valid outcomes (2..=MAX_OUTCOMES)
vault Pubkey PDA that holds SOL (vault PDA)
vault_bump u8 For vault PDA
total_stake_per_outcome [u64; 16] Sum of stakes per outcome index (use first outcome_count)
total_stake u64 Sum of all stakes
resolved_outcome Option None until resolved; then Some(0..=outcome_count-1)
resolved_pool_after_fee Option Set at resolve; used in claim
resolved_at Option Resolution time (optional)
bump u8 Market PDA bump

Invariants:

  • resolved_outcome.is_none() until resolve is called; then resolved_outcome.is_some() and never changes. Once resolved, no further bets are allowed.
  • total_stake == sum(total_stake_per_outcome[i]) for i < outcome_count.
  • 2 <= outcome_count <= MAX_OUTCOMES.

Vault: Dedicated vault PDA per market: ["vault", market.key()]. SOL only; no SPL token.


3.4 User Stake (per user per market)

PDA: ["stake", user.key(), market.key()]

Stores a user’s stakes per outcome for one market. One stake account per (user, market).

Field Type Description
user Pubkey Staker
market Pubkey Market
stake_per_outcome [u64; 16] User’s stake per outcome
total_staked u64 Sum of stake_per_outcome
claimed bool User has claimed winnings
bump u8 PDA bump

Invariants: total_staked == sum(stake_per_outcome[i]). After resolution, winners have claimed == false until they call claim; then claimed == true.


4. Instructions

4.1 initialize_config (admin, one-time)

Accounts:

  • Config (PDA ["config"], init)
  • Authority (signer)
  • System program

Args: treasury: Pubkey, protocol_fee_bps: u16, resolution_authority: Pubkey

Logic:

  • Set config: authority = signer, treasury, protocol_fee_bps, resolution_authority, paused = false, initialized = true, bump.
  • Require protocol_fee_bps <= 10_000. Require treasury != Pubkey::default() (and optionally treasury != system_program::id()).

4.2 update_config (authority)

Accounts: Config (mut), Authority (signer)

Args: Optional fields: treasury, protocol_fee_bps, resolution_authority, paused

Logic: Require config.authority == authority.key(). Update only provided fields. If protocol_fee_bps provided, require <= 10_000. If treasury provided, require != Pubkey::default() (and optionally not system program).


4.3 add_subject (anyone — register a subject)

Accounts:

  • Subject (PDA ["subject", subject_id], init)
  • Payer (signer)
  • System program

Args: subject_id: [u8; 32], name: String (max length e.g. 64)

Logic:

  • Require name length <= MAX_SUBJECT_NAME_LEN (e.g. 64).
  • Init subject: subject_id, name, creator = payer.key(), created_at = Clock::get().unix_timestamp, bump.
  • Emit event SubjectAdded { subject, subject_id, name, creator }.

Note: Anyone can add a subject. subject_id is chosen by the caller (e.g. hash of match_id, symbol, or a UUID). It must be unique; duplicate subject_id will fail at init (PDA already exists). Use subjects to group markets (e.g. one subject per cricket match, then many ball markets under that subject).


4.4 create_market (anyone — market created on demand)

Accounts:

  • Config (read, optional — to check paused if you want to gate creation)
  • Subject (read) — subject this market belongs to; must exist
  • Market (PDA ["market", market_id], init)
  • Vault (PDA ["vault", market.key()], init — empty account to hold SOL)
  • Payer (signer)
  • System program

Args: market_id: [u8; 32], outcome_count: u8

Logic:

  • Require Subject account exists (passed in accounts) so the market is tied to a subject.
  • Require 2 <= outcome_count && outcome_count <= MAX_OUTCOMES.
  • Init market: market_id, subject_id = subject.subject_id (copy from Subject account), outcome_count, vault = vault.key(), vault_bump, total_stake_per_outcome = [0; MAX_OUTCOMES], total_stake = 0, resolved_outcome = None, resolved_pool_after_fee = None, resolved_at = None, bump.
  • Init vault PDA: space 0, rent-exempt, owned by program (only program can transfer SOL out).
  • Emit event CreateMarket { market, market_id, subject_id, outcome_count }.

Note: There is no separate “event” or “match” account. market_id is the unique event identifier from any source (cricket API, football API, crypto oracle). The same market_id must be used when resolving. Anyone may create a market for a given market_id; typically the first better or an indexer creates it before the first bet.

Cricket mapping: Create one subject per match (subject_id = hash(match_id), name = "Team A vs Team B"). Then create ball markets with market_id = hash(match_id || innings || over || ball), passing that Subject in accounts; market stores subject_id from the Subject. Outcome count = 8. Football: One subject per match; markets for winner/correct score with same subject_id. Crypto: One subject per question (subject_id = hash(question_id), name = "BTC above $100k?"); market(s) under that subject with outcome_count = 2.


4.5 place_bet (user)

Accounts:

  • Config (read) — check !paused
  • Market (mut)
  • User stake (PDA ["stake", user.key(), market.key()], init if needed)
  • User (signer)
  • Vault (mut) — market’s vault PDA (for SOL transfer)
  • System program

Args: outcome_index: u8, amount: u64

Logic:

  • Require !config.paused.
  • Require market not yet resolved: market.resolved_outcome.is_none(). Once the market is resolved, no more bets are allowed.
  • Require outcome_index < market.outcome_count.
  • Require amount >= MIN_BET and amount <= MAX_BET (in lamports).
  • Transfer amount lamports (SOL) from user to vault. SOL only; no SPL token.
  • Market: total_stake_per_outcome[outcome_index] += amount, total_stake += amount.
  • User stake: init if first bet; then stake_per_outcome[outcome_index] += amount, total_staked += amount. Supports multiple bets per outcome and multiple outcomes per market.
  • Emit event BetPlaced { user, market, outcome_index, amount }.

Security: Use checked_add for all stake updates to avoid overflow. Validate vault is the market’s vault PDA. Require config.paused == false and market.resolved_outcome.is_none().

Optional Check user’s total exposure across all markets (e.g. sum of stakes in open markets) and enforce a cap.


4.6 resolve (resolution authority only)

Accounts:

  • Config (read) — for protocol_fee_bps and treasury
  • Market (mut)
  • Vault (mut) — source of SOL
  • Treasury (mut) — receives protocol fee
  • Resolution authority (signer)
  • System program

Args: outcome_index: u8

Logic:

  • Require config.resolution_authority == resolution_authority.key().
  • Require market.resolved_outcome.is_none() (no double settlement).
  • Require outcome_index < market.outcome_count.
  • Set market.resolved_outcome = Some(outcome_index), market.resolved_at = Some(Clock::get().unix_timestamp).
  • Protocol fee to treasury (every resolution): fee = market.total_stake * protocol_fee_bps / 10_000. Transfer fee lamports from vault to config.treasury. This happens on every resolve; the treasury always receives the protocol fee when a market is resolved. Require vault has at least market.total_stake.
  • Pool after fee: pool_after_fee = market.total_stake - fee. Store in market: market.resolved_pool_after_fee = Some(pool_after_fee) (used in claim).
  • No winners: If market.total_stake_per_outcome[outcome_index] == 0, there are no winning stakes; transfer the remaining pool_after_fee from vault to treasury as well (so the full pool is accounted for and vault is drained).
  • Do not distribute to winners in this instruction (pull model); only set resolved_outcome, send fee (and optional pool_after_fee when no winners) to treasury here.

Security: Use checked arithmetic for fee and pool_after_fee. Ensure vault is the program-owned PDA; use invoke_signed to transfer SOL. Require vault.lamports() >= market.total_stake before any transfer.

Deterministic: Once resolved_outcome is set, any subsequent call to resolve for this market must exit with error “already resolved”.


4.7 claim (user, pull model)

Accounts:

  • Config (read)
  • Market (read)
  • User stake (mut)
  • User (signer)
  • Vault (mut)
  • System program

Args: none (market and user identified by accounts)

Logic:

  • Require market.resolved_outcome.is_some().
  • Require !user_stake.claimed.
  • Let winning_outcome = market.resolved_outcome.unwrap().
  • User’s stake on winning outcome: user_stake_on_win = user_stake.stake_per_outcome[winning_outcome].
  • If user_stake_on_win == 0, return error “not a winner”.
  • Total stake on winning outcome: total_winning_stake = market.total_stake_per_outcome[winning_outcome]. Require total_winning_stake > 0.
  • Payout: payout = (market.resolved_pool_after_fee.unwrap() * user_stake_on_win) / total_winning_stake.
  • Transfer payout from vault to user.
  • Set user_stake.claimed = true.
  • Emit Claimed { user, market, payout }.

Security: Use checked arithmetic for payout (checked_mul, checked_div); require total_winning_stake > 0. Transfer from vault using program signer only. Validate user_stake.market == market.key() and user_stake.user == user.key().

Rounding: Use integer division; last claimer may get 1 lamport less. Document this. Alternatively store “remaining pool” and give last claimant the remainder.


5. Storing pool_after_fee at resolution

The Market account includes resolved_pool_after_fee: Option<u64>. In resolve: set resolved_pool_after_fee = Some(market.total_stake - fee). In claim: use payout = (market.resolved_pool_after_fee.unwrap() * user_stake_on_win) / total_winning_stake.


6. Events

Emit Anchor events for:

  • InitializeConfig { authority, treasury, protocol_fee_bps }
  • SubjectAdded { subject, subject_id, name, creator }
  • CreateMarket { market, market_id, subject_id, outcome_count }
  • BetPlaced { user, market, outcome_index, amount }
  • Resolved { market, outcome_index, pool_after_fee }
  • Claimed { user, market, payout }

7. Errors

Define custom errors, e.g.:

  • ConfigAlreadyInitialized
  • Unauthorized (wrong authority / resolution_authority)
  • MarketAlreadyResolved
  • InvalidOutcomeIndex (outcome_index >= market.outcome_count)
  • InvalidAmount (below min or above max)
  • NotWinner (claim when user has no stake on winning outcome)
  • AlreadyClaimed
  • ProtocolPaused
  • InvalidOutcomeCount (outcome_count < 2 or > MAX_OUTCOMES)
  • MarketNotResolved (claim before resolve)
  • SubjectNotFound or invalid Subject account (create_market with missing/wrong subject)
  • InvalidTreasury (treasury is default or system program)
  • ArithmeticOverflow / ArithmeticUnderflow (or use checked ops and panic with clear message)

8. Constants

  • MAX_OUTCOMES = 16 (max outcomes per market; use 8 for smaller accounts)
  • MIN_BET (lamports, e.g. 10_000_000)
  • MAX_BET (lamports, e.g. 100_000_000_000)
  • MARKET_ID_LEN = 32 (for PDA seed)
  • MAX_SUBJECT_NAME_LEN (e.g. 64 bytes for subject name)

9. Access Control Summary

Instruction Who can call
initialize_config Any (one-time init)
update_config config.authority
add_subject Any
create_market Any
place_bet Any (if !paused and market not resolved)
resolve config.resolution_authority
claim User (own stake)

10. Fee and Treasury Flow

  • place_bet: No fee (or optional small fee; if so, transfer to treasury in place_bet).
  • resolve: On every market resolution, the protocol fee is sent to the treasury: protocol_fee = total_stake * protocol_fee_bps / 10_000; transfer that amount from the market vault to config.treasury. If there are no winners (total_stake_per_outcome[winning_outcome] == 0), also transfer pool_after_fee to treasury so the vault is fully settled.
  • claim: No fee; user receives (resolved_pool_after_fee * user_win_stake) / total_winning_stake.

11. Edge Cases and Invariants

  • No stakes on winning outcome: If total_stake_per_outcome[winning_outcome] == 0, no winners. In resolve, after sending the protocol fee to treasury, also transfer pool_after_fee from vault to treasury so the full pool is settled and the vault is drained.
  • Double resolve: Prevented by resolved_outcome.is_none() check.
  • Double claim: Prevented by user_stake.claimed and setting it to true.
  • Vault balance: At resolve, vault must have >= total_stake (all user funds). Ensure no one can withdraw from vault except via resolve (fee) and claim (winners).
  • Scalability: Many sports and crypto markets use the same program; each event is a distinct market_id. No per-sport or per-asset logic on-chain.

12. Testing Checklist (for 90%+ coverage)

  • Config: init once, update fields, reject non-authority, reject fee_bps > 10_000.
  • Subject: add_subject with subject_id and name; duplicate subject_id fails.
  • Market: create for market_id with subject and outcome_count 2 and 8, duplicate (same market_id) fails.
  • place_bet: success; multiple bets same outcome; multiple outcomes per market; below min / above max fails; resolved market fails (bet after resolve); paused fails; wrong outcome index (>= outcome_count) fails.
  • resolve: success, double resolve fails, non–resolution_authority fails, fee and treasury balance correct.
  • claim: winner gets correct share, non-winner fails, double claim fails, multiple winners each get correct proportion.
  • No winners: resolve when no one bet on winning outcome; pool_after_fee handling (e.g. to treasury).
  • Security: Reject place_bet when paused; reject resolve from non–resolution_authority; reject claim when not winner or already claimed; reject config update from non-authority; reject initialize_config with treasury = default; overflow/underflow: use amounts that could overflow and ensure checked ops are used (or error).

13. Deployment and IDL

  • Build with anchor build.
  • Deploy program to devnet (and testnet) with anchor deploy.
  • Export IDL for frontend; ensure all accounts and args match this spec.
  • Dummy UI: connect wallet, add subject (name, subject_id), create market (market_id, subject, outcome_count from any API), place bet (single and multiple outcomes / multiple amounts), resolve, claim. Use devnet. Same flow for cricket, football, or crypto markets.

14. File Structure

programs/
  prediction_market/
    src/
      lib.rs           # Declare program, modules
      state/
        config.rs      # Config account
        subject.rs     # Subject account
        market.rs      # Market account (generic)
        stake.rs       # User stake account
      instructions/
        initialize_config.rs
        update_config.rs
        add_subject.rs
        create_market.rs
        place_bet.rs
        resolve.rs
        claim.rs
      events.rs
      errors.rs
      constants.rs

15. Extensibility: Multi-Sport & Crypto

The program is sport- and asset-agnostic. Event identity and outcome semantics live off-chain.

15.1 market_id from any source

market_id is not created on-chain. It is provided by the client/oracle and can come from:

  • Cricket: Encode or hash (match_id, innings, over, ball) to 32 bytes. Same API used for creation and resolution. Outcome count = 8 (dot, 1, 2, 3, 4, 6, wicket, other).
  • Other sports: e.g. hash(match_id + "winner") for match-winner markets; outcome_count = 3 (home, draw, away). Or match_id + "correct_score", outcome_count = N.
  • Crypto prediction: e.g. hash(symbol + question_id + expiry) for “BTC above $100k by Friday?”; outcome_count = 2 (no, yes). For bucketed price markets, use outcome_count = 4–8 for ranges.

Use a fixed 32-byte representation (hash or zero-padded string). Ensure IDs are unique per event across all sports and crypto to avoid collisions.

15.2 Adding new sports or crypto

  1. Add a subject (optional but recommended): Call add_subject(subject_id, name) to register a category or event group (e.g. one subject per cricket match, per crypto question). Use a unique subject_id (32 bytes).
  2. Define event semantics and outcome set off-chain (API or oracle).
  3. Assign a unique market_id (32 bytes) per event.
  4. Call create_market(market_id, outcome_count) with the Subject account in accounts; the program copies subject_id from the Subject onto the new market.
  5. Users place bets; oracle calls resolve(market_id, outcome_index) when the event is known.
  6. No program upgrade required; same instructions and accounts for all categories.

15.3 Optional: category / market_type (indexing only)

For UIs and indexers, you can add an optional category: u8 or market_type: u8 to the Market account (e.g. 0 = cricket, 1 = football, 2 = crypto). This is not used for program logic—only for filtering and display. Omit in M1 if not needed.


16. Security & Best Practices

This section defines security requirements and implementation best practices so the program is safe in production.

16.1 Access control & privilege separation

Role Capability Mitigation
authority (config) Update config, set paused, change treasury / protocol_fee_bps / resolution_authority Use a multisig or timelock for config.authority in production. Restrict to trusted keys.
resolution_authority Call resolve for any market; sets outcome and triggers fee transfer Single key or dedicated oracle key. In production, use a secure oracle or multisig; consider time-delay or governance for high-value markets.
Users place_bet, claim (only own stake) place_bet: signer = payer; claim: signer must own user_stake account. No escalation.
Anyone add_subject, create_market No privileged data; only creates new accounts. Subject/market IDs are client-chosen; no gain from spoofing.

Best practice:

  • Initialize config once and use require(config.initialized) (or similar) where needed.
  • All privileged instructions must verify the signer against the correct account (config.authority or config.resolution_authority).
  • Do not allow resolution_authority to update config; only authority can.

16.2 PDA and account validation

  • Seeds and bumps: Every PDA (Config, Subject, Market, Vault, User stake) must be derived with the canonical seeds and bump stored where needed. Use Anchor’s seeds and bump constraints; do not accept arbitrary account keys for PDAs.
  • Vault: Market vault must be the PDA with seeds ["vault", market.key()] and owned by the program. Only the program (via CPI with program signer) can transfer SOL out. No user or external program may withdraw from the vault.
  • Treasury: In resolve, require treasury.key() == config.treasury. Transfer fee only to config.treasury.
  • Cross-account consistency: In create_market, require the passed Subject account’s subject_id matches the Subject PDA seeds (Anchor constraint). In claim, require user_stake.market == market.key() and user_stake.user == user.key().

16.3 Arithmetic and overflow/underflow

  • Use checked arithmetic for all stake and fee math: checked_add, checked_sub, checked_mul (and division only where divisor is non-zero). Never use plain +, -, * for user-controlled or derived amounts that could overflow.
  • Fee: fee = (market.total_stake * protocol_fee_bps).checked_div(10_000).expect("fee overflow") (or return error). Ensure pool_after_fee = total_stake - fee does not underflow.
  • Claim payout: payout = (resolved_pool_after_fee * user_stake_on_win).checked_div(total_winning_stake).expect("div zero"). Require total_winning_stake > 0 before divide.
  • Stake updates: When adding to total_stake_per_outcome and total_stake, use checked_add; on claim, use checked_sub for user_stake and vault balance.

16.4 Vault and treasury safety

  • Vault: Only two ways SOL may leave the vault: (1) resolve — transfer to config.treasury (fee, and pool_after_fee when no winners); (2) claim — transfer to the user (winner payout). Both must use invoke_signed with the program as signer (vault PDA). No other instruction may debit the vault.
  • Balance checks: Before resolve, require vault.lamports() >= market.total_stake. After all claims, vault balance should go to zero (or dust); document acceptable dust.
  • Treasury: Config holds treasury: Pubkey. Do not allow zero address; require treasury != Pubkey::default() in initialize_config and update_config.

16.5 Input validation

  • place_bet: outcome_index < market.outcome_count; amount >= MIN_BET and amount <= MAX_BET; !market.resolved_outcome.is_some(); !config.paused.
  • resolve: outcome_index < market.outcome_count; market.resolved_outcome.is_none(); signer == config.resolution_authority.
  • create_market: 2 <= outcome_count && outcome_count <= MAX_OUTCOMES; Subject account loaded and valid.
  • add_subject: name.len() <= MAX_SUBJECT_NAME_LEN; no zero-length name if that is invalid.
  • Config: protocol_fee_bps <= 10_000 on init and update.

16.6 Pause and circuit breaker

  • config.paused: When true, place_bet must be disabled (revert with ProtocolPaused). Optionally allow resolve and claim even when paused so existing markets can settle and users can withdraw. Document the intended behavior.
  • Best practice: Emit an event when pause state changes. Consider a timelock or multisig for setting paused = true so the authority cannot single-handedly freeze funds without governance.

16.7 Resolution authority and oracle trust

  • The resolution_authority can set the outcome for any market. This is a trusted role: a malicious or compromised key can resolve incorrectly and cause loss to users.
  • Mitigation: Use a dedicated key for the oracle; run resolution from a secure, monitored service; consider multisig or governance for high-stakes markets. The program does not verify that the outcome is “correct”; that is the oracle’s responsibility.
  • Determinism: Once resolved_outcome is set, it must never change. No double resolution; no “re-open” of a resolved market.

16.8 Event logging and audit trail

  • Emit Anchor events for every state-changing instruction: InitializeConfig, SubjectAdded, CreateMarket, BetPlaced, Resolved, Claimed (and optionally PauseChanged). Include relevant identifiers (market, user, subject_id, outcome_index, amounts).
  • Events support off-chain indexing, analytics, and dispute resolution. Do not skip events for “gas” savings.

16.9 Implementation checklist (summary)

  • All PDAs validated with canonical seeds and bump.
  • Vault and treasury: only program transfers from vault; treasury address from config only.
  • Checked arithmetic for stakes, fees, and payouts; no unchecked add/sub/mul/div.
  • place_bet / resolve / claim: strict signer and account checks; no privilege escalation.
  • Config: protocol_fee_bps <= 10_000; treasury != default; pause enforced in place_bet.
  • Resolution: idempotent (already_resolved error); no double settlement.
  • Events emitted for all critical actions.
  • Production: use multisig or governance for authority and, where appropriate, resolution_authority.

Releases

No releases published

Packages

 
 
 

Contributors