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
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_idand 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_ididentifies 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).
- One market per event: Deterministic PDA from
market_id(e.g. 32 bytes). No sport-specific fields on-chain;market_idis supplied by the client/oracle (from any source: cricket API, football API, crypto oracle). - Configurable outcomes per market: Each market has
outcome_count(2 toMAX_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).
| 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). |
- 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_idis provided by the client/oracle and can come from any source (cricket API, football API, crypto oracle, etc.). - Outcome count: Each market has
outcome_countin[2, MAX_OUTCOMES](e.g. 2–16). Outcome indices are0 .. 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_outcometo that index.
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.
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.
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".
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_idon 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; thenresolved_outcome.is_some()and never changes. Once resolved, no further bets are allowed.total_stake == sum(total_stake_per_outcome[i])fori < outcome_count.2 <= outcome_count <= MAX_OUTCOMES.
Vault: Dedicated vault PDA per market: ["vault", market.key()]. SOL only; no SPL token.
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.
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. Requiretreasury != Pubkey::default()(and optionallytreasury != system_program::id()).
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).
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).
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.
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_BETandamount <= MAX_BET(in lamports). - Transfer
amountlamports (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.
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. Transferfeelamports from vault toconfig.treasury. This happens on every resolve; the treasury always receives the protocol fee when a market is resolved. Require vault has at leastmarket.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 remainingpool_after_feefrom 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”.
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]. Requiretotal_winning_stake > 0. - Payout:
payout = (market.resolved_pool_after_fee.unwrap() * user_stake_on_win) / total_winning_stake. - Transfer
payoutfrom 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.
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.
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 }
Define custom errors, e.g.:
ConfigAlreadyInitializedUnauthorized(wrong authority / resolution_authority)MarketAlreadyResolvedInvalidOutcomeIndex(outcome_index >= market.outcome_count)InvalidAmount(below min or above max)NotWinner(claim when user has no stake on winning outcome)AlreadyClaimedProtocolPausedInvalidOutcomeCount(outcome_count < 2 or > MAX_OUTCOMES)MarketNotResolved(claim before resolve)SubjectNotFoundor 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)
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)
| 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) |
- 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 toconfig.treasury. If there are no winners (total_stake_per_outcome[winning_outcome] == 0), also transferpool_after_feeto treasury so the vault is fully settled. - claim: No fee; user receives
(resolved_pool_after_fee * user_win_stake) / total_winning_stake.
- 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 transferpool_after_feefrom 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.claimedand 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.
- 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).
- 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.
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
The program is sport- and asset-agnostic. Event identity and outcome semantics live off-chain.
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.
- 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 uniquesubject_id(32 bytes). - Define event semantics and outcome set off-chain (API or oracle).
- Assign a unique
market_id(32 bytes) per event. - 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. - Users place bets; oracle calls
resolve(market_id, outcome_index)when the event is known. - No program upgrade required; same instructions and accounts for all categories.
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.
This section defines security requirements and implementation best practices so the program is safe in production.
| 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.
- 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
seedsandbumpconstraints; 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, requiretreasury.key() == config.treasury. Transfer fee only toconfig.treasury. - Cross-account consistency: In
create_market, require the passed Subject account’ssubject_idmatches the Subject PDA seeds (Anchor constraint). Inclaim, require user_stake.market == market.key() and user_stake.user == user.key().
- 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). Ensurepool_after_fee = total_stake - feedoes not underflow. - Claim payout:
payout = (resolved_pool_after_fee * user_stake_on_win).checked_div(total_winning_stake).expect("div zero"). Requiretotal_winning_stake > 0before divide. - Stake updates: When adding to
total_stake_per_outcomeandtotal_stake, use checked_add; on claim, use checked_sub for user_stake and vault balance.
- 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; requiretreasury != Pubkey::default()in initialize_config and update_config.
- place_bet:
outcome_index < market.outcome_count;amount >= MIN_BETandamount <= 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_000on init and update.
- 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.
- 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_outcomeis set, it must never change. No double resolution; no “re-open” of a resolved market.
- 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.
- 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.