//! Science Markets — `science_binary`: fully collateralized USDC YES/NO binary //! outcome markets. //! //! Draft program. Not deployed, not audited. `[internal location withheld]` records what //! was actually run against it and what could not be. //! //! **This replaces the retired UP/DOWN perpetual design.** `science_market` and //! `science_dealer` beside this crate implement the old instrument //! (`initialize_market`, `open_position`, `settle_position`, `commit_reserve`, //! `quote`). They are legacy and their instruction set is not this one. //! //! The instrument //! -------------- //! One YES token and one NO token per fixed scientific question. USDC is the //! collateral and the unit of account. No leverage, no borrowing, no funding, no //! liquidation, no rehypothecation. //! //! mint N pairs pay N * (1 + m) exactly N enters the reserve, N*m to fees //! merge N pairs receive N * (1 - r) both sides burned, N released //! redeem N winning receive N * (1 - r) the winning claim burned, N released //! losing token receive 0, no fee //! //! `m` and `r` are basis points fixed when the market is created. No instruction //! raises them, for this market or any other. //! //! The reserve invariant, enforced //! ------------------------------- //! One invariant carries the contract, and every money path checks it before it //! returns: //! //! pre-resolution vault >= max(yes_supply, no_supply) //! FINAL vault >= winning_supply //! CANCELLED vault >= cancel_payout(yes) + cancel_payout(no) //! //! It holds because the vault moves in lockstep with the supplies: a mint takes //! exactly `N` in and mints exactly `N` of each side; a merge or a redemption //! burns first and then releases exactly the burned backing. Fees are taken from //! the payer, never from the reserve, so a fee never reduces coverage. //! //! Supplies are reconciled downward against the SPL mint before money operations: //! holders/delegates can burn claims directly. Unexpected increases are refused, //! and exact post-CPI supply equality is still required for touched mints. Backing USDC is //! a segregated per-market vault; it is never counted as pool liquidity and no //! instruction here moves it into a pool. There is no sweep on the vault at all. //! //! Resolution //! ---------- //! OPEN -> PROPOSED -> CHALLENGE -> DISPUTED -> FINAL, plus CANCELLED. The //! transition table lives in `science_binary_core`, which is the same table as //! `ALLOWED_TRANSITIONS` in `[internal location withheld]`, asserted equal by a unit //! test and exhaustively by a Kani harness. //! //! No payout happens before FINAL. `redeem_winning` requires FINAL and there is //! no other post-resolution payout except `redeem_cancelled`. A proposal opens a //! timelock; `finalize` is a separate explicit call that is refused while the //! window is open, and it never runs on its own. A market that reaches FINAL or //! CANCELLED never leaves it, so a settlement cannot be applied twice and a //! finalized market cannot be reopened. //! //! New pair issuance stops at PROPOSED, which is the "stop new pair issuance" //! rule from the policy. Merging stays available in every state where a payout //! has not already been fixed, so an exit exists through a proposal, a challenge, //! and a dispute. //! //! What this program does not do, said plainly //! ------------------------------------------ //! * It cannot check that a proposed outcome is scientifically correct, timely, //! or backed by three agreeing models. It checks *who* signed, *which* round, //! *whether* the key was already used, and the *order* of transitions. That is //! replay and process integrity, not truth. //! * It does not create the Raydium pools, seed them, or verify their identity //! beyond the addresses the admin records. //! * It does not gate who may hold or transfer the outcome tokens. The access //! policy (`[internal location withheld]` section 10) is not a //! program-level restriction here, and the policy itself points out that a //! restriction living only in a website is not one. //! * It does not implement a bonded, permissionless challenge. Challenging takes //! a designated key. The timelock, not the challenger, is the guarantee that //! nothing pays out instantly. use anchor_lang::prelude::*; use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount, Transfer}; use science_binary_core as bc; declare_id!("ApygmCdgkbXXzNMo1HJ13fPeqDwh5t15yfQoUjYf4SHP"); // --------------------------------------------------------------------------- // Program constants, re-exported from the core crate so the program, the tests, // Miri, and Kani all read one definition. // --------------------------------------------------------------------------- pub const BPS_DENOM: u64 = bc::BPS_DENOM; pub const FEE_BPS_CAP: u16 = bc::FEE_BPS_CAP; pub const CANCEL_BPS_CAP: u16 = bc::CANCEL_BPS_CAP; pub const MIN_CHALLENGE_WINDOW: i64 = bc::MIN_CHALLENGE_WINDOW; pub const MAX_CHALLENGE_WINDOW: i64 = bc::MAX_CHALLENGE_WINDOW; /// Market lifecycle. PENDING means the mints and the vault exist and only the /// admin may issue pairs, which is the controlled-seeding window in the redesign /// plan; ACTIVE means public issuance is on. Neither value changes a holder's /// right to merge or to redeem. pub const STATUS_PENDING: u8 = 0; pub const STATUS_ACTIVE: u8 = 1; /// USDC precision. The "1 pair = 1 USDC" redemption right is only meaningful if /// the outcome mints share the collateral's decimals, so all three must match. pub const USDC_DECIMALS: u8 = 6; // --------------------------------------------------------------------------- // Program // --------------------------------------------------------------------------- #[program] pub mod science_binary { use super::*; /// Create a market: its two outcome mints, its segregated USDC vault, its /// fee account, and the frozen terms. /// /// The terms written here are written once. No instruction in this program /// edits `question_hash`, `criteria_hash`, `methodology_hash`, the fee rates, /// or the cancellation rule, so changing any of them is a new market with a /// new account. That is the immutability the settlement-trust design asks for /// and the reason they are parameters of this instruction rather than of a /// later one. /// /// `cancel_payout_bps` is required and must be in `1..=5000`. The policy /// allows a predefined 0.5/0.5 complete-set-preserving cancellation but warns /// it is a candidate, not a decision, and must be set before any trade. /// Requiring it here is the safest reading: the rule can never be invented /// after a dispute, and it cannot be zero, because a zero would pay nothing /// and hand the reserve to the issuer. /// /// The market starts PENDING. `activate_market` turns on public issuance /// after both pools exist. The program does not create the pools. #[allow(clippy::too_many_arguments)] pub fn create_market( ctx: Context, market_id: u64, question_hash: [u8; 32], criteria_hash: [u8; 32], methodology_hash: [u8; 32], mint_fee_bps: u16, redeem_fee_bps: u16, cancel_payout_bps: u16, challenge_window_secs: i64, proposer: Pubkey, challenger: Pubkey, adjudicator: Pubkey, ) -> Result<()> { require!( question_hash != [0u8; 32] && criteria_hash != [0u8; 32], BinaryError::EmptyMetadata ); require!(mint_fee_bps <= FEE_BPS_CAP, BinaryError::FeeTooHigh); require!(redeem_fee_bps <= FEE_BPS_CAP, BinaryError::FeeTooHigh); require!(cancel_payout_bps > 0, BinaryError::CancelRuleUnset); require!( bc::cancel_rule_preserves_the_complete_set(cancel_payout_bps), BinaryError::CancelRuleBreaksTheSet ); require!( (MIN_CHALLENGE_WINDOW..=MAX_CHALLENGE_WINDOW).contains(&challenge_window_secs), BinaryError::BadChallengeWindow ); require!( proposer != Pubkey::default() && challenger != Pubkey::default() && adjudicator != Pubkey::default(), BinaryError::MissingAuthority ); require!( ctx.accounts.usdc_mint.decimals == USDC_DECIMALS, BinaryError::WrongDecimals ); let market = &mut ctx.accounts.market; market.admin = ctx.accounts.admin.key(); market.creator = ctx.accounts.admin.key(); market.terminal_mode = false; market.issuance_started = false; market.terminal_attestors = [Pubkey::default(); 3]; market.evidence_cutoff = 0; market.terminal_max_age = 0; market.proposer = proposer; market.challenger = challenger; market.adjudicator = adjudicator; market.usdc_mint = ctx.accounts.usdc_mint.key(); market.yes_mint = ctx.accounts.yes_mint.key(); market.no_mint = ctx.accounts.no_mint.key(); // Filled in by `create_reserve`; empty (and flagged by `vault_bump == 0`) // until then. No money instruction reads them without the bump check. market.vault = Pubkey::default(); market.fee_account = Pubkey::default(); market.question_hash = question_hash; market.criteria_hash = criteria_hash; market.methodology_hash = methodology_hash; // The pool addresses are empty until `set_pools` records them, which is // the "write the pool addresses back" step of the creation flow. market.yes_pool = Pubkey::default(); market.no_pool = Pubkey::default(); market.market_id = market_id; market.mint_fee_bps = mint_fee_bps; market.redeem_fee_bps = redeem_fee_bps; market.cancel_payout_bps = cancel_payout_bps; market.challenge_window_secs = challenge_window_secs; market.status = STATUS_PENDING; market.paused = false; market.reserve_created = false; market.resolution_state = bc::STATE_OPEN; market.proposed_outcome = bc::OUTCOME_NONE; market.final_outcome = bc::OUTCOME_NONE; market.yes_supply = 0; market.no_supply = 0; market.proposed_round = 0; market.last_proposal_key = [0u8; 32]; market.proposed_at = 0; market.challenge_until = 0; market.finalized_at = 0; market.cancelled_at = 0; market.challenged_at = 0; market.evidence_hash = [0u8; 32]; market.challenge_reason_hash = [0u8; 32]; market.cancel_reason_hash = [0u8; 32]; market.adjudication_note_hash = [0u8; 32]; market.bump = ctx.bumps.market; market.vault_bump = 0; market.fee_account_bump = 0; market.yes_mint_bump = ctx.bumps.yes_mint; market.no_mint_bump = ctx.bumps.no_mint; emit!(MarketCreated { market: market.key(), admin: market.admin, market_id, usdc_mint: market.usdc_mint, yes_mint: market.yes_mint, no_mint: market.no_mint, mint_fee_bps, redeem_fee_bps, cancel_payout_bps, challenge_window_secs, }); Ok(()) } /// Freeze an explicit no-appeal policy before issuance. Existing delayed /// markets cannot be converted after claims exist. No probabilities accepted. pub fn configure_terminal_consensus( ctx: Context, attestors: [Pubkey; 3], evidence_cutoff: i64, max_age_secs: i64, ) -> Result<()> { let m = &mut ctx.accounts.market; require!( m.status == STATUS_PENDING && m.resolution_state == bc::STATE_OPEN && m.yes_supply == 0 && m.no_supply == 0 && !m.terminal_mode && !m.issuance_started, BinaryError::TerminalPolicyFrozen ); require!(m.methodology_hash != [0; 32], BinaryError::EmptyMetadata); require!( attestors.iter().all(|k| *k != Pubkey::default()) && attestors[0] != attestors[1] && attestors[0] != attestors[2] && attestors[1] != attestors[2], BinaryError::BadTerminalAttestors ); require!( evidence_cutoff > 0 && max_age_secs > 0 && max_age_secs <= MAX_CHALLENGE_WINDOW, BinaryError::BadTerminalReport ); m.terminal_attestors = attestors; m.evidence_cutoff = evidence_cutoff; m.terminal_max_age = max_age_secs; m.terminal_mode = true; emit!(TerminalPolicyConfigured { market: m.key(), attestors, evidence_cutoff, max_age_secs }); Ok(()) } /// New policy, never a migration of an issued fixed-cutoff market. The /// existing Market layout stays unchanged; -2 in terminal_max_age is a v2 /// discriminator, not a TTL. The legacy settlement instruction rejects it. pub fn configure_terminal_consensus_v2( ctx: Context, attestors: [Pubkey; 3], config_hash: [u8; 32], initial_cutoff: i64, first_round: u64, max_age_secs: i64, ) -> Result<()> { let m = &mut ctx.accounts.market; let now = Clock::get()?.unix_timestamp; require!( m.status == STATUS_PENDING && m.resolution_state == bc::STATE_OPEN && m.yes_supply == 0 && m.no_supply == 0 && !m.terminal_mode && !m.issuance_started, BinaryError::TerminalPolicyFrozen ); require!(m.methodology_hash != [0; 32] && config_hash != [0; 32], BinaryError::EmptyMetadata); require!(attestors.iter().all(|k| *k != Pubkey::default()) && attestors[0] != attestors[1] && attestors[0] != attestors[2] && attestors[1] != attestors[2], BinaryError::BadTerminalAttestors); require!(initial_cutoff > 0 && initial_cutoff <= now && first_round > 0 && first_round < i64::MAX as u64 && max_age_secs > 0 && max_age_secs <= MAX_CHALLENGE_WINDOW, BinaryError::BadTerminalReport); let p = &mut ctx.accounts.policy; p.market = m.key(); p.config_hash = config_hash; p.initial_cutoff = initial_cutoff; p.first_round = first_round; p.max_age_secs = max_age_secs; m.terminal_attestors = attestors; m.evidence_cutoff = initial_cutoff; m.terminal_max_age = -2; m.terminal_mode = true; Ok(()) } /// A round's evidence upper bound is initial_cutoff + (round-first_round) /// seconds. Skipped round IDs are allowed. This immutable schedule makes /// later evidence eligible and forbids cutoff regression even for rounds /// that never submit on chain. Every signer signs the whole transaction, /// including market, policy, config, round, cutoff and evidence commitment. #[allow(clippy::too_many_arguments)] pub fn settle_terminal_consensus_v2( ctx: Context, verdicts: [TerminalVerdict; 3], round_no: u64, question_hash: [u8; 32], criteria_hash: [u8; 32], methodology_hash: [u8; 32], config_hash: [u8; 32], evidence_cutoff: i64, evidence_hash: [u8; 32], report_key: [u8; 32], observed_at: i64, ) -> Result<()> { let now = Clock::get()?.unix_timestamp; let m = &mut ctx.accounts.market; let p = &ctx.accounts.policy; require!(m.terminal_mode && m.terminal_max_age == -2 && m.status == STATUS_ACTIVE && m.resolution_state == bc::STATE_OPEN, BinaryError::NotOpen); require!(ctx.accounts.attestor_one.key() == m.terminal_attestors[0] && ctx.accounts.attestor_two.key() == m.terminal_attestors[1] && ctx.accounts.attestor_three.key() == m.terminal_attestors[2], BinaryError::Unauthorized); require!(question_hash == m.question_hash && criteria_hash == m.criteria_hash && methodology_hash == m.methodology_hash && config_hash == p.config_hash && m.evidence_cutoff == p.initial_cutoff, BinaryError::BadTerminalReport); require!(round_no > m.proposed_round && report_key != m.last_proposal_key, BinaryError::StaleProposal); validate_terminal_v2_time(p, round_no, evidence_cutoff, observed_at, now)?; require!(report_key != [0; 32] && evidence_hash != [0; 32] && verdicts.iter().all(|v| v.report_hash != [0; 32]), BinaryError::EmptyMetadata); require!(verdicts.iter().all(|v| bc::outcome_valid(v.outcome) && v.outcome == verdicts[0].outcome), BinaryError::NoTerminalAgreement); m.proposed_round = round_no; m.last_proposal_key = report_key; m.evidence_hash = evidence_hash; m.proposed_outcome = verdicts[0].outcome; m.final_outcome = verdicts[0].outcome; m.finalized_at = now; m.resolution_state = bc::STATE_FINAL; emit!(TerminalConsensusV2Settled { market: m.key(), config_hash, round_no, evidence_cutoff, observed_at, report_key, evidence_hash, report_hashes: verdicts.map(|v| v.report_hash), outcome: m.final_outcome }); emit!(Finalized { market: m.key(), outcome: m.final_outcome, round_no, finalized_at: now, adjudication_note_hash: [0; 32] }); Ok(()) } /// All three frozen adapters sign one domain-bound transaction containing /// three matching explicit terminal verdicts. Atomic FINAL, no appeal path. /// Signatures authenticate adapters' assertions, not prose/provider truth. #[allow(clippy::too_many_arguments)] pub fn settle_terminal_consensus( ctx: Context, verdicts: [TerminalVerdict; 3], round_no: u64, question_hash: [u8; 32], criteria_hash: [u8; 32], methodology_hash: [u8; 32], evidence_cutoff: i64, evidence_hash: [u8; 32], report_key: [u8; 32], observed_at: i64, ) -> Result<()> { let now = Clock::get()?.unix_timestamp; let m = &mut ctx.accounts.market; require!( m.terminal_mode && m.terminal_max_age > 0 && m.status == STATUS_ACTIVE && m.resolution_state == bc::STATE_OPEN, BinaryError::NotOpen ); require!( ctx.accounts.attestor_one.key() == m.terminal_attestors[0] && ctx.accounts.attestor_two.key() == m.terminal_attestors[1] && ctx.accounts.attestor_three.key() == m.terminal_attestors[2], BinaryError::Unauthorized ); require!( question_hash == m.question_hash && criteria_hash == m.criteria_hash && methodology_hash == m.methodology_hash && evidence_cutoff == m.evidence_cutoff, BinaryError::BadTerminalReport ); require!( report_key != [0; 32] && evidence_hash != [0; 32] && verdicts.iter().all(|v| v.report_hash != [0; 32]), BinaryError::EmptyMetadata ); require!( round_no > m.proposed_round && report_key != m.last_proposal_key, BinaryError::StaleProposal ); require!( observed_at >= evidence_cutoff && now >= observed_at && now .checked_sub(observed_at) .ok_or(BinaryError::MathOverflow)? <= m.terminal_max_age, BinaryError::BadTerminalReport ); require!( verdicts .iter() .all(|v| bc::outcome_valid(v.outcome) && v.outcome == verdicts[0].outcome), BinaryError::NoTerminalAgreement ); m.proposed_round = round_no; m.last_proposal_key = report_key; m.evidence_hash = evidence_hash; m.proposed_outcome = verdicts[0].outcome; m.final_outcome = verdicts[0].outcome; m.finalized_at = now; // This opt-in policy intentionally bypasses the legacy proposal/timelock. m.resolution_state = bc::STATE_FINAL; emit!(TerminalConsensusSettled { market: m.key(), outcome: m.final_outcome, round_no, report_key, evidence_hash, report_hashes: verdicts.map(|v| v.report_hash), finalized_at: now }); emit!(Finalized { market: m.key(), outcome: m.final_outcome, round_no, finalized_at: now, adjudication_note_hash: [0; 32] }); Ok(()) } /// Create the market's segregated USDC reserve and its fee account. /// /// Admin-only and only while PENDING. After this returns, the reserve /// addresses in the market account are frozen: no instruction writes /// `vault` or `fee_account` again, and `set_pools` and `activate_market` /// do not touch them. pub fn create_reserve(ctx: Context) -> Result<()> { let market = &mut ctx.accounts.market; require!( market.status == STATUS_PENDING, BinaryError::MarketNotPending ); require!(!market.reserve_created, BinaryError::ReserveAlreadyCreated); require!( ctx.accounts.usdc_mint.key() == market.usdc_mint, BinaryError::WrongMint ); market.vault = ctx.accounts.vault.key(); market.fee_account = ctx.accounts.fee_account.key(); // Independently derive and check each canonical reserve bump. A zero // bump is valid; reserve_created, not the bump, records initialization. market.vault_bump = reserve_bump(&market.key(), b"vault", ctx.accounts.vault.key())?; market.fee_account_bump = reserve_bump( &market.key(), b"fee_account", ctx.accounts.fee_account.key(), )?; market.reserve_created = true; emit!(ReserveCreated { market: market.key(), vault: market.vault, fee_account: market.fee_account, }); Ok(()) } /// Record the two Raydium pool addresses. Only while PENDING, so a live /// market's addresses cannot be repointed. pub fn set_pools(ctx: Context, yes_pool: Pubkey, no_pool: Pubkey) -> Result<()> { let market = &mut ctx.accounts.market; require!( market.status == STATUS_PENDING, BinaryError::MarketNotPending ); require!( yes_pool != Pubkey::default() && no_pool != Pubkey::default(), BinaryError::MissingPool ); require!(yes_pool != no_pool, BinaryError::PoolsNotDistinct); market.yes_pool = yes_pool; market.no_pool = no_pool; emit!(PoolsRecorded { market: market.key(), yes_pool, no_pool }); Ok(()) } /// Turn on public issuance. One way, and only from PENDING. pub fn activate_market(ctx: Context) -> Result<()> { let market = &mut ctx.accounts.market; require!( market.status == STATUS_PENDING, BinaryError::MarketNotPending ); require!(market.reserve_is_created(), BinaryError::ReserveNotCreated); require!( market.yes_pool != Pubkey::default() && market.no_pool != Pubkey::default(), BinaryError::MissingPool ); market.status = STATUS_ACTIVE; emit!(MarketActivated { market: market.key() }); Ok(()) } /// Stop new pair issuance, and only that. /// /// Pausing must never strand a claim, so `redeem_pair`, `redeem_winning`, and /// `redeem_cancelled` never read this flag. A pause stops creation; it does /// not stop an exit. pub fn set_paused(ctx: Context, paused: bool) -> Result<()> { ctx.accounts.market.paused = paused; emit!(PauseChanged { market: ctx.accounts.market.key(), paused }); Ok(()) } /// Replace the admin. Narrow on purpose: the admin has no instruction that /// moves the vault. pub fn transfer_admin(ctx: Context, new_admin: Pubkey) -> Result<()> { require!( new_admin != Pubkey::default(), BinaryError::MissingAuthority ); let market = &mut ctx.accounts.market; let before = market.admin; market.admin = new_admin; emit!(AdminTransferred { market: market.key(), before, after: new_admin }); Ok(()) } /// Replace the proposer, challenger, or adjudicator. Narrow on purpose: none /// of the three can move the vault, and none can do the other's job. pub fn set_authorities( ctx: Context, proposer: Pubkey, challenger: Pubkey, adjudicator: Pubkey, ) -> Result<()> { require!( proposer != Pubkey::default() && challenger != Pubkey::default() && adjudicator != Pubkey::default(), BinaryError::MissingAuthority ); let market = &mut ctx.accounts.market; require!(!market.terminal_mode, BinaryError::TerminalPolicyFrozen); market.proposer = proposer; market.challenger = challenger; market.adjudicator = adjudicator; emit!(AuthoritiesChanged { market: market.key(), proposer, challenger, adjudicator }); Ok(()) } /// Move the fee account's balance to a destination chosen by the admin. /// /// The destination is validated only as a token account of this market's own /// collateral mint. The instruction cannot touch the vault, which has no /// sweep path at all, so no admin action can move claim collateral. Fee /// revenue and backing stay separate accounts. pub fn sweep_fees(ctx: Context) -> Result<()> { require!( ctx.accounts.market.reserve_is_created(), BinaryError::ReserveNotCreated ); let amount = ctx.accounts.fee_account.amount; if amount == 0 { return Ok(()); } let owned_seeds = market_signer_seeds(&ctx.accounts.market); let seeds = seeds_ref(&owned_seeds); token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.fee_account.to_account_info(), to: ctx.accounts.destination.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), amount, )?; emit!(FeesSwept { market: ctx.accounts.market.key(), destination: ctx.accounts.destination.key(), amount, }); Ok(()) } /// Lock `pairs` USDC into this market's reserve and mint `pairs` YES and /// `pairs` NO to the caller. /// /// The caller pays `pairs + fee(pairs * m)` in total. Exactly `pairs` of that /// enters the vault, so the claims this instruction creates are backed the /// moment they exist; the fee goes to the fee account and never touches the /// reserve. `max_gross` is caller slippage protection against a fee the /// caller did not expect. /// /// Public issuance requires ACTIVE and an OPEN market. While PENDING only the /// admin may mint, which is the controlled seeding window: the admin's own /// collateral funds it, so it still creates no unbacked claim. Issuance stops /// at PROPOSED, per the "stop new pair issuance" rule; merging does not. pub fn mint_pair(ctx: Context, pairs: u64, max_gross: u64) -> Result<()> { require!(pairs > 0, BinaryError::ZeroAmount); reconcile_pair_supplies( &mut ctx.accounts.market, ctx.accounts.yes_mint.supply, ctx.accounts.no_mint.supply, )?; let market = &ctx.accounts.market; require!(market.reserve_is_created(), BinaryError::ReserveNotCreated); require!(!market.paused, BinaryError::MarketPaused); require!( market.status == STATUS_ACTIVE || ctx.accounts.owner.key() == market.admin, BinaryError::MarketNotActive ); require!( market.resolution_state == bc::STATE_OPEN, BinaryError::IssuanceClosed ); let fee = bc::fee_on(pairs, market.mint_fee_bps).ok_or(BinaryError::MathOverflow)?; let gross = bc::mint_gross(pairs, market.mint_fee_bps).ok_or(BinaryError::MathOverflow)?; require!(gross <= max_gross, BinaryError::SlippageExceeded); // Collateral first, so the reserve is funded before the claims exist. token::transfer( CpiContext::new( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.owner_usdc.to_account_info(), to: ctx.accounts.vault.to_account_info(), authority: ctx.accounts.owner.to_account_info(), }, ), pairs, )?; if fee > 0 { token::transfer( CpiContext::new( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.owner_usdc.to_account_info(), to: ctx.accounts.fee_account.to_account_info(), authority: ctx.accounts.owner.to_account_info(), }, ), fee, )?; } let owned_seeds = market_signer_seeds(&ctx.accounts.market); let seeds = seeds_ref(&owned_seeds); token::mint_to( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), MintTo { mint: ctx.accounts.yes_mint.to_account_info(), to: ctx.accounts.owner_yes.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), pairs, )?; token::mint_to( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), MintTo { mint: ctx.accounts.no_mint.to_account_info(), to: ctx.accounts.owner_no.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), pairs, )?; let market = &mut ctx.accounts.market; market.issuance_started = true; market.yes_supply = market .yes_supply .checked_add(pairs) .ok_or(BinaryError::MathOverflow)?; market.no_supply = market .no_supply .checked_add(pairs) .ok_or(BinaryError::MathOverflow)?; // The token program is the source of truth for what was minted. This // refuses to accept a supply the program believes but the mints do not // show, and it is also what makes an unsolicited mint (impossible while // the market holds the mint authority, but cheap to check) fail closed. ctx.accounts.yes_mint.reload()?; ctx.accounts.no_mint.reload()?; ctx.accounts.vault.reload()?; require!( ctx.accounts.yes_mint.supply == market.yes_supply && ctx.accounts.no_mint.supply == market.no_supply, BinaryError::SupplyMismatch ); assert_solvent(market, &ctx.accounts.vault)?; emit!(PairMinted { market: market.key(), owner: ctx.accounts.owner.key(), pairs, fee, gross, }); Ok(()) } /// Burn `pairs` YES and `pairs` NO and release their backing. /// /// Available in every state where a payout has not already been fixed, so an /// exit exists through a proposal, a challenge, and a dispute. The caller /// receives `pairs - fee(pairs * r)`; the reserve releases exactly `pairs`, /// of which the fee goes to the fee account. `min_net` is caller slippage /// protection. pub fn redeem_pair(ctx: Context, pairs: u64, min_net: u64) -> Result<()> { require!( ctx.accounts.market.reserve_is_created(), BinaryError::ReserveNotCreated ); require!(pairs > 0, BinaryError::ZeroAmount); reconcile_pair_supplies( &mut ctx.accounts.market, ctx.accounts.yes_mint.supply, ctx.accounts.no_mint.supply, )?; let market = &ctx.accounts.market; require!( bc::is_pre_resolution(market.resolution_state), BinaryError::AlreadyResolved ); require!( pairs <= market.yes_supply && pairs <= market.no_supply, BinaryError::InsufficientSupply ); let (fee, net) = bc::redeem_net(pairs, market.redeem_fee_bps).ok_or(BinaryError::MathOverflow)?; require!(net >= min_net, BinaryError::SlippageExceeded); // Burn first. A claim that has not been burned cannot be paid, and a // burned claim cannot be presented twice. token::burn( CpiContext::new( ctx.accounts.token_program.to_account_info(), Burn { mint: ctx.accounts.yes_mint.to_account_info(), from: ctx.accounts.owner_yes.to_account_info(), authority: ctx.accounts.owner.to_account_info(), }, ), pairs, )?; token::burn( CpiContext::new( ctx.accounts.token_program.to_account_info(), Burn { mint: ctx.accounts.no_mint.to_account_info(), from: ctx.accounts.owner_no.to_account_info(), authority: ctx.accounts.owner.to_account_info(), }, ), pairs, )?; // Then release exactly the burned backing, split between the caller and // the fee account. let owned_seeds = market_signer_seeds(&ctx.accounts.market); let seeds = seeds_ref(&owned_seeds); if net > 0 { token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault.to_account_info(), to: ctx.accounts.owner_usdc.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), net, )?; } if fee > 0 { token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault.to_account_info(), to: ctx.accounts.fee_account.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), fee, )?; } let market = &mut ctx.accounts.market; market.yes_supply = market .yes_supply .checked_sub(pairs) .ok_or(BinaryError::MathOverflow)?; market.no_supply = market .no_supply .checked_sub(pairs) .ok_or(BinaryError::MathOverflow)?; ctx.accounts.yes_mint.reload()?; ctx.accounts.no_mint.reload()?; ctx.accounts.vault.reload()?; require!( ctx.accounts.yes_mint.supply == market.yes_supply && ctx.accounts.no_mint.supply == market.no_supply, BinaryError::SupplyMismatch ); assert_solvent(market, &ctx.accounts.vault)?; emit!(PairMerged { market: market.key(), owner: ctx.accounts.owner.key(), pairs, fee, net, }); Ok(()) } /// Burn `units` winning tokens and release their backing. /// /// The only post-resolution payout other than cancellation, and it requires /// FINAL. There is no path that pays at PROPOSED. The losing mint is refused /// outright rather than paid zero: a losing holder has nothing to redeem, and /// a refusal is clearer than a zero-transfer success. pub fn redeem_winning(ctx: Context, units: u64, min_net: u64) -> Result<()> { require!( ctx.accounts.market.reserve_is_created(), BinaryError::ReserveNotCreated ); require!(units > 0, BinaryError::ZeroAmount); reconcile_claim_supply( &mut ctx.accounts.market, ctx.accounts.claim_mint.key(), ctx.accounts.claim_mint.supply, )?; let market = &ctx.accounts.market; require!( market.resolution_state == bc::STATE_FINAL, BinaryError::NotFinal ); require!( ctx.accounts.claim_mint.key() == winning_mint(market), BinaryError::NotWinningMint ); let (fee, net) = bc::redeem_net(units, market.redeem_fee_bps).ok_or(BinaryError::MathOverflow)?; require!(net >= min_net, BinaryError::SlippageExceeded); token::burn( CpiContext::new( ctx.accounts.token_program.to_account_info(), Burn { mint: ctx.accounts.claim_mint.to_account_info(), from: ctx.accounts.owner_claim.to_account_info(), authority: ctx.accounts.owner.to_account_info(), }, ), units, )?; let owned_seeds = market_signer_seeds(&ctx.accounts.market); let seeds = seeds_ref(&owned_seeds); if net > 0 { token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault.to_account_info(), to: ctx.accounts.owner_usdc.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), net, )?; } if fee > 0 { token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault.to_account_info(), to: ctx.accounts.fee_account.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), fee, )?; } let market = &mut ctx.accounts.market; match market.final_outcome { bc::OUTCOME_YES => { market.yes_supply = market .yes_supply .checked_sub(units) .ok_or(BinaryError::MathOverflow)?; } bc::OUTCOME_NO => { market.no_supply = market .no_supply .checked_sub(units) .ok_or(BinaryError::MathOverflow)?; } _ => return err!(BinaryError::NoOutcome), } // Only the winning mint is compared: the losing supply is real but // irrelevant to coverage, and a mismatch there would not be a solvency // problem. ctx.accounts.claim_mint.reload()?; ctx.accounts.vault.reload()?; require!( ctx.accounts.claim_mint.supply == winning_supply(market), BinaryError::SupplyMismatch ); assert_solvent(market, &ctx.accounts.vault)?; emit!(WinningRedeemed { market: market.key(), owner: ctx.accounts.owner.key(), claim_mint: ctx.accounts.claim_mint.key(), units, fee, net, }); Ok(()) } /// Burn outstanding claims at the cancellation rate, under the rule fixed at /// creation. Either mint may be presented; both pay the same per unit. /// /// No redemption fee is charged on this path. The policy's table names fees /// for mint, merge, and winning redemption only, and the cancellation rule is /// a payout rule chosen in advance. Keeping it fee-free also means a holder /// always receives exactly what the disclosed rule says. pub fn redeem_cancelled(ctx: Context) -> Result<()> { reconcile_claim_supply( &mut ctx.accounts.market, ctx.accounts.claim_mint.key(), ctx.accounts.claim_mint.supply, )?; require!( ctx.accounts.market.reserve_is_created(), BinaryError::ReserveNotCreated ); let market = &ctx.accounts.market; require!( market.resolution_state == bc::STATE_CANCELLED, BinaryError::NotCancelled ); let is_yes = ctx.accounts.claim_mint.key() == market.yes_mint; require!( is_yes || ctx.accounts.claim_mint.key() == market.no_mint, BinaryError::NotOutcomeMint ); let units = ctx.accounts.owner_claim.amount; require!(units > 0, BinaryError::ZeroAmount); let payout = bc::cancel_payout(units, market.cancel_payout_bps).ok_or(BinaryError::MathOverflow)?; token::burn( CpiContext::new( ctx.accounts.token_program.to_account_info(), Burn { mint: ctx.accounts.claim_mint.to_account_info(), from: ctx.accounts.owner_claim.to_account_info(), authority: ctx.accounts.owner.to_account_info(), }, ), units, )?; let owned_seeds = market_signer_seeds(&ctx.accounts.market); let seeds = seeds_ref(&owned_seeds); if payout > 0 { token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault.to_account_info(), to: ctx.accounts.owner_usdc.to_account_info(), authority: ctx.accounts.market.to_account_info(), }, &[seeds.as_slice()], ), payout, )?; } let market = &mut ctx.accounts.market; if is_yes { market.yes_supply = market .yes_supply .checked_sub(units) .ok_or(BinaryError::MathOverflow)?; } else { market.no_supply = market .no_supply .checked_sub(units) .ok_or(BinaryError::MathOverflow)?; } let expected = if is_yes { market.yes_supply } else { market.no_supply }; ctx.accounts.claim_mint.reload()?; ctx.accounts.vault.reload()?; require!( ctx.accounts.claim_mint.supply == expected, BinaryError::SupplyMismatch ); assert_solvent(market, &ctx.accounts.vault)?; emit!(CancelledClaimRedeemed { market: market.key(), owner: ctx.accounts.owner.key(), claim_mint: ctx.accounts.claim_mint.key(), units, payout, }); Ok(()) } /// Submit the designated proposer's asserted outcome and open the challenge window. /// No provider/model attestations or three-report agreement are verified here. /// /// Requires the proposer key, a strictly increasing round number, and an /// idempotency key that has not been used before. Only an OPEN market may be /// proposed. The outcome must be a definite YES or NO; UNDECIDED is not a /// proposal. /// /// This instruction does not finalize and does not pay. It opens a window of /// the length fixed at creation, and the window is enforced on `finalize`, /// not here. /// /// Replay protection has three layers: the state machine refuses PROPOSED to /// PROPOSED, `proposal_key` is refused when it repeats the last key used, and /// `round_no` must exceed the last accepted round. A repeated, stale, or /// out-of-order report cannot re-fire a transition. pub fn propose( ctx: Context, outcome: u8, round_no: u64, proposal_key: [u8; 32], evidence_hash: [u8; 32], ) -> Result<()> { require!(bc::outcome_valid(outcome), BinaryError::BadOutcome); require!( proposal_key != [0u8; 32] && evidence_hash != [0u8; 32], BinaryError::EmptyMetadata ); let market = &ctx.accounts.market; require!(!market.terminal_mode, BinaryError::TerminalPolicyFrozen); require_keys_eq!( ctx.accounts.proposer.key(), market.proposer, BinaryError::Unauthorized ); require!( market.resolution_state == bc::STATE_OPEN, BinaryError::NotOpen ); require!( proposal_key != market.last_proposal_key, BinaryError::DuplicateProposal ); require!(round_no > market.proposed_round, BinaryError::StaleProposal); let now = Clock::get()?.unix_timestamp; let challenge_until = now .checked_add(market.challenge_window_secs) .ok_or(BinaryError::MathOverflow)?; let market = &mut ctx.accounts.market; transition(market, bc::STATE_PROPOSED)?; market.proposed_outcome = outcome; market.proposed_round = round_no; market.proposed_at = now; market.challenge_until = challenge_until; market.last_proposal_key = proposal_key; market.evidence_hash = evidence_hash; market.challenge_reason_hash = [0u8; 32]; market.challenged_at = 0; emit!(Proposed { market: market.key(), outcome, round_no, proposed_at: now, challenge_until, evidence_hash, proposal_key, }); Ok(()) } /// Reject a premature proposal: clear it and return the market to OPEN. /// /// Signed by the adjudicator, the designated conflict-free reviewer. This is /// the "rejecting a premature proposal returns the market to OPEN" rule. It /// settles nothing and pays nothing. pub fn cancel_proposal(ctx: Context, reason_hash: [u8; 32]) -> Result<()> { require!(reason_hash != [0u8; 32], BinaryError::EmptyMetadata); let market = &mut ctx.accounts.market; require_keys_eq!( ctx.accounts.adjudicator.key(), market.adjudicator, BinaryError::Unauthorized ); transition(market, bc::STATE_OPEN)?; market.proposed_outcome = bc::OUTCOME_NONE; market.proposed_at = 0; market.challenge_until = 0; market.challenged_at = 0; market.challenge_reason_hash = reason_hash; emit!(ProposalCancelled { market: market.key(), reason_hash }); Ok(()) } /// File a qualifying challenge: the proposal goes to DISPUTED and can no /// longer be finalized by the window alone. /// /// Signed by the designated challenger. A bonded, permissionless challenge is /// not implemented here; see SECURITY.md for why the timelock, not this key, /// is the guarantee that matters. pub fn challenge(ctx: Context, reason_hash: [u8; 32]) -> Result<()> { require!(reason_hash != [0u8; 32], BinaryError::EmptyMetadata); let now = Clock::get()?.unix_timestamp; let market = &mut ctx.accounts.market; require_keys_eq!( ctx.accounts.challenger.key(), market.challenger, BinaryError::Unauthorized ); transition(market, bc::STATE_DISPUTED)?; market.challenged_at = now; market.challenge_reason_hash = reason_hash; emit!(Challenged { market: market.key(), reason_hash }); Ok(()) } /// Record the proposed outcome irreversibly. /// /// From PROPOSED or CHALLENGE the gate is the clock: the call is refused /// while the challenge window is open, and nothing finalizes on its own, /// because this must be called. Any signer may call it once the window has /// elapsed. Finalization cannot happen early, and requiring a keeper key /// would let that key hold the market open — an unavailable reviewer must /// never cause acceptance. /// /// From DISPUTED the gate is the adjudicator, with a note on record. A /// disputed market therefore cannot be finalized by the clock, and it cannot /// be finalized by the proposer. pub fn finalize(ctx: Context, adjudication_note_hash: [u8; 32]) -> Result<()> { let now = Clock::get()?.unix_timestamp; let market = &mut ctx.accounts.market; match market.resolution_state { bc::STATE_PROPOSED | bc::STATE_CHALLENGE => { require!(market.challenge_until > 0, BinaryError::NoChallengeWindow); require!( !bc::challenge_window_open(now, market.challenge_until), BinaryError::ChallengeWindowOpen ); } bc::STATE_DISPUTED => { require_keys_eq!( ctx.accounts.adjudicator.key(), market.adjudicator, BinaryError::Unauthorized ); require!( adjudication_note_hash != [0u8; 32], BinaryError::MissingAdjudicationNote ); } _ => return err!(BinaryError::NotFinalizable), } require!( bc::outcome_valid(market.proposed_outcome), BinaryError::NoOutcome ); market.final_outcome = market.proposed_outcome; market.finalized_at = now; market.adjudication_note_hash = adjudication_note_hash; transition(market, bc::STATE_FINAL)?; emit!(Finalized { market: market.key(), outcome: market.final_outcome, round_no: market.proposed_round, finalized_at: now, adjudication_note_hash, }); Ok(()) } /// Cancel an invalid market and enable the cancellation payout fixed at /// creation. One way, and never from FINAL. /// /// The rule is not a parameter of this instruction, which is the point: it /// was chosen and disclosed before any trade, and this call can only execute /// it. pub fn cancel_market(ctx: Context, reason_hash: [u8; 32]) -> Result<()> { require!(reason_hash != [0u8; 32], BinaryError::EmptyMetadata); let now = Clock::get()?.unix_timestamp; let market = &mut ctx.accounts.market; require!(!market.terminal_mode, BinaryError::TerminalPolicyFrozen); require!(market.cancel_payout_bps > 0, BinaryError::CancelRuleUnset); market.cancelled_at = now; market.cancel_reason_hash = reason_hash; transition(market, bc::STATE_CANCELLED)?; emit!(MarketCancelled { market: market.key(), cancel_payout_bps: market.cancel_payout_bps, reason_hash, }); Ok(()) } } #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy)] pub struct TerminalVerdict { /// Only explicit definitive YES=1/NO=2. No confidence/probability input. pub outcome: u8, pub report_hash: [u8; 32], } #[event] pub struct TerminalPolicyConfigured { pub market: Pubkey, pub attestors: [Pubkey; 3], pub evidence_cutoff: i64, pub max_age_secs: i64, } #[event] pub struct TerminalConsensusSettled { pub market: Pubkey, pub outcome: u8, pub round_no: u64, pub report_key: [u8; 32], pub evidence_hash: [u8; 32], pub report_hashes: [[u8; 32]; 3], pub finalized_at: i64, } /// Separate versioned PDA avoids changing/deserializing any historical Market. #[account] #[derive(InitSpace)] pub struct TerminalPolicyV2 { pub market: Pubkey, pub config_hash: [u8; 32], pub initial_cutoff: i64, pub first_round: u64, pub max_age_secs: i64, } #[derive(Accounts)] pub struct ConfigureTerminalV2<'info> { #[account(mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = admin @ BinaryError::Unauthorized)] pub market: Box>, #[account(init, payer = admin, space = 8 + TerminalPolicyV2::INIT_SPACE, seeds = [b"terminal_v2", market.key().as_ref()], bump)] pub policy: Account<'info, TerminalPolicyV2>, #[account(mut)] pub admin: Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct TerminalConsensusV2<'info> { #[account(mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump)] pub market: Box>, #[account(seeds = [b"terminal_v2", market.key().as_ref()], bump, has_one = market)] pub policy: Account<'info, TerminalPolicyV2>, pub attestor_one: Signer<'info>, pub attestor_two: Signer<'info>, pub attestor_three: Signer<'info>, } #[event] pub struct TerminalConsensusV2Settled { pub market: Pubkey, pub config_hash: [u8; 32], pub round_no: u64, pub evidence_cutoff: i64, pub observed_at: i64, pub report_key: [u8; 32], pub evidence_hash: [u8; 32], pub report_hashes: [[u8; 32]; 3], pub outcome: u8, } #[cfg(test)] mod terminal_v2_tests { use super::*; #[test] fn later_evidence_has_a_monotonic_nonfuture_round_cutoff() { let p = TerminalPolicyV2 { market: Pubkey::new_unique(), config_hash: [1; 32], initial_cutoff: 100, first_round: 7, max_age_secs: 300 }; assert!(validate_terminal_v2_time(&p, 7, 100, 100, 100).is_ok()); // Initial round remained unresolved. Later discovery at t=110 is eligible. assert!(validate_terminal_v2_time(&p, 17, 110, 110, 110).is_ok()); assert!(validate_terminal_v2_time(&p, 17, 100, 110, 110).is_err()); assert!(validate_terminal_v2_time(&p, 6, 99, 110, 110).is_err()); assert!(validate_terminal_v2_time(&p, 18, 111, 111, 110).is_err()); assert!(validate_terminal_v2_time(&p, 17, 110, 109, 110).is_err()); assert!(validate_terminal_v2_time(&p, 17, 110, 110, 411).is_err()); assert!(validate_terminal_v2_time(&p, u64::MAX, 110, 110, 110).is_err()); let overflow = TerminalPolicyV2 { initial_cutoff: i64::MAX, ..p }; assert!(validate_terminal_v2_time(&overflow, 8, 1, 110, 110).is_err()); } #[test] fn terminal_v2_freshness_is_inclusive_and_arithmetic_fails_closed() { let p = TerminalPolicyV2 { market: Pubkey::new_unique(), config_hash: [1; 32], initial_cutoff: 100, first_round: 7, max_age_secs: 300 }; assert!(validate_terminal_v2_time(&p, 7, 100, 100, 400).is_ok()); assert!(validate_terminal_v2_time(&p, 7, 100, 100, 401).is_err()); assert!(validate_terminal_v2_time(&p, 7, 100, i64::MIN, i64::MAX).is_err()); let limit = TerminalPolicyV2 { initial_cutoff: i64::MAX - 1, ..p }; assert!(validate_terminal_v2_time(&limit, 8, i64::MAX, i64::MAX, i64::MAX).is_ok()); assert!(validate_terminal_v2_time(&limit, 9, i64::MAX, i64::MAX, i64::MAX).is_err()); } #[test] fn terminal_v2_schedule_checks_ten_thousand_skipped_rounds() { let p = TerminalPolicyV2 { market: Pubkey::new_unique(), config_hash: [1; 32], initial_cutoff: 1_700_000_000, first_round: 100, max_age_secs: 300 }; for delta in 0u64..10_000 { let cutoff = p.initial_cutoff + delta as i64; let round = p.first_round + delta; assert!(validate_terminal_v2_time(&p, round, cutoff, cutoff, cutoff).is_ok()); assert!(validate_terminal_v2_time(&p, round, cutoff - 1, cutoff, cutoff).is_err()); assert!(validate_terminal_v2_time(&p, round, cutoff + 1, cutoff + 1, cutoff + 1).is_err()); assert!(validate_terminal_v2_time(&p, round, cutoff, cutoff, cutoff - 1).is_err()); } } } pub fn validate_terminal_v2_time(p: &TerminalPolicyV2, round: u64, cutoff: i64, observed: i64, now: i64) -> Result<()> { let delta = round.checked_sub(p.first_round) .and_then(|v| i64::try_from(v).ok()).ok_or(BinaryError::BadTerminalReport)?; let expected = p.initial_cutoff.checked_add(delta).ok_or(BinaryError::BadTerminalReport)?; require!(cutoff == expected && cutoff > 0 && observed >= cutoff && now >= observed && now.checked_sub(observed).ok_or(BinaryError::BadTerminalReport)? <= p.max_age_secs, BinaryError::BadTerminalReport); Ok(()) } #[derive(Accounts)] pub struct TerminalConsensus<'info> { #[account(mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump)] pub market: Box>, pub attestor_one: Signer<'info>, pub attestor_two: Signer<'info>, pub attestor_three: Signer<'info>, } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /// One market: the two outcome mints, the segregated reserve, the fee account, /// the frozen terms, and the resolution record. /// /// Fixed size, no variable-length data, so the account footprint cannot drift. #[account] #[derive(InitSpace)] pub struct Market { /// May activate, pause, transfer authority, set authorities, sweep fees, and /// cancel. Cannot move the vault: no instruction does. pub admin: Pubkey, /// May submit a proposal. Cannot finalize, challenge, or cancel. pub proposer: Pubkey, /// May file a challenge. pub challenger: Pubkey, /// May reject a premature proposal and finalize a disputed market. pub adjudicator: Pubkey, pub usdc_mint: Pubkey, pub yes_mint: Pubkey, pub no_mint: Pubkey, /// The segregated backing reserve, held by this market's PDA. Set by /// `create_reserve`. pub vault: Pubkey, /// Mint and redemption fees accumulate here. Separate from the vault, so fee /// revenue never mixes with claim collateral. Set by `create_reserve`. pub fee_account: Pubkey, /// Hashes of the frozen contract. The program commits to the text, which is /// what makes "the wording cannot change" checkable rather than promised. pub question_hash: [u8; 32], pub criteria_hash: [u8; 32], pub methodology_hash: [u8; 32], /// The Raydium pool addresses. Recorded, never created or verified here. pub yes_pool: Pubkey, pub no_pool: Pubkey, pub market_id: u64, /// Fixed at creation. No instruction raises either rate. pub mint_fee_bps: u16, pub redeem_fee_bps: u16, /// Fixed at creation, in `1..=5000`. A pair pays at most 1 USDC under it. pub cancel_payout_bps: u16, /// Fixed at creation. The policy's proposed default is 72 hours. pub challenge_window_secs: i64, /// PENDING until the operator activates public issuance. pub status: u8, pub paused: bool, /// True once `create_reserve` has run. Every money instruction checks it, so /// a market with no reserve can never be minted into or redeemed from. A /// real flag rather than a `vault_bump == 0` trick: a PDA's canonical bump /// is legitimately zero often enough (it was zero for the first market this /// test suite created) that the trick silently refuses valid markets. pub reserve_created: bool, /// The resolution state, one of `science_binary_core::STATE_*`. pub resolution_state: u8, pub proposed_outcome: u8, pub final_outcome: u8, /// The program's accounting of the outstanding claims. Checked against the /// mints' own supplies after every mint and burn. pub yes_supply: u64, pub no_supply: u64, /// Replay protection. pub proposed_round: u64, pub last_proposal_key: [u8; 32], pub proposed_at: i64, pub challenge_until: i64, pub finalized_at: i64, pub cancelled_at: i64, pub challenged_at: i64, /// Hashes of the off-chain records for the current proposal, challenge, /// cancellation, and adjudication. The records themselves live off chain; /// the program commits to them. pub evidence_hash: [u8; 32], pub challenge_reason_hash: [u8; 32], pub cancel_reason_hash: [u8; 32], pub adjudication_note_hash: [u8; 32], pub bump: u8, pub vault_bump: u8, pub fee_account_bump: u8, pub yes_mint_bump: u8, pub no_mint_bump: u8, /// Immutable original creator used in the market PDA seeds. Administrative /// authority may rotate, but an existing account address and CPI signer cannot. pub creator: Pubkey, /// Opt-in policy frozen before any issuance. Attestors are trusted adapters, /// not a claim that model providers sign Solana transactions themselves. pub terminal_mode: bool, pub issuance_started: bool, pub terminal_attestors: [Pubkey; 3], pub evidence_cutoff: i64, pub terminal_max_age: i64, } impl Market { pub const SPACE: usize = 8 + Self::INIT_SPACE; /// True once `create_reserve` has recorded the vault and the fee account. pub fn reserve_is_created(&self) -> bool { self.reserve_created && self.vault != Pubkey::default() && self.fee_account != Pubkey::default() } } // --------------------------------------------------------------------------- // Pure helpers. These are also what the unit tests below drive. // --------------------------------------------------------------------------- /// The mint of the side that pays out under a recorded outcome. Before an /// outcome is recorded there is no winning mint, and the default key is returned /// so a caller cannot match it by accident. pub fn winning_mint(market: &Market) -> Pubkey { match market.final_outcome { bc::OUTCOME_YES => market.yes_mint, bc::OUTCOME_NO => market.no_mint, _ => Pubkey::default(), } } /// The program's accounting of the outstanding winning claims. pub fn winning_supply(market: &Market) -> u64 { match market.final_outcome { bc::OUTCOME_YES => market.yes_supply, bc::OUTCOME_NO => market.no_supply, _ => 0, } } /// What the vault must hold, given the state. The invariant, written once so the /// checks and the tests cannot disagree about what "solvent" means. pub fn required_coverage(market: &Market) -> Option { match market.resolution_state { bc::STATE_FINAL => Some(winning_supply(market)), bc::STATE_CANCELLED => { let yes = bc::cancel_payout(market.yes_supply, market.cancel_payout_bps)?; let no = bc::cancel_payout(market.no_supply, market.cancel_payout_bps)?; yes.checked_add(no) } state if bc::is_pre_resolution(state) => { Some(bc::max_u64(market.yes_supply, market.no_supply)) } _ => None, } } /// Apply a transition, refusing anything the map does not allow, and leaving the /// state untouched on a refusal. pub fn transition(market: &mut Market, to: u8) -> Result<()> { require!( bc::transition_allowed(market.resolution_state, to), BinaryError::IllegalTransition ); market.resolution_state = to; Ok(()) } /// The market PDA's own signer seeds. Returned as a `Vec` of slices because the /// components are assembled from copies of the market's fields, so the caller /// does not hold a borrow on the account while it signs a CPI. fn market_signer_seeds(market: &Market) -> Vec> { vec![ b"market".to_vec(), market.creator.to_bytes().to_vec(), market.market_id.to_le_bytes().to_vec(), vec![market.bump], ] } /// Turn the owned seed vectors into the `&[&[u8]]` slice the CPI needs. fn seeds_ref(seeds: &[Vec]) -> Vec<&[u8]> { seeds.iter().map(|s| s.as_slice()).collect() } /// The canonical bump for a `[prefix, market]` PDA, checked against the address /// that was actually passed. /// /// `ctx.bumps` is unusable for the `init`-created reserve accounts: with /// `Box>` fields Anchor's bumps accessor yields 0, and 0 is almost /// never the canonical bump. Storing it would make every later /// `seeds = [.., bump = market.vault_bump]` check fail. The prefix and the market /// key are the only seeds those accounts use, so recomputing is exact rather /// than a guess, and the `require!` keeps a mismatched address from being /// recorded. fn reserve_bump(market_key: &Pubkey, prefix: &[u8], account: Pubkey) -> Result { let (expected, bump) = Pubkey::find_program_address(&[prefix, market_key.as_ref()], &crate::ID); require!(expected == account, BinaryError::WrongMint); Ok(bump) } /// SPL owners and delegates can burn their own claims without this program. /// Accept only supply decreases before applying this instruction's exact delta; /// an unexplained increase still fails closed. Post-CPI equality remains enforced. fn reconcile_pair_supplies(market: &mut Market, yes: u64, no: u64) -> Result<()> { require!( yes <= market.yes_supply && no <= market.no_supply, BinaryError::SupplyMismatch ); market.yes_supply = yes; market.no_supply = no; Ok(()) } fn reconcile_claim_supply(market: &mut Market, mint: Pubkey, supply: u64) -> Result<()> { let tracked = if mint == market.yes_mint { &mut market.yes_supply } else if mint == market.no_mint { &mut market.no_supply } else { return err!(BinaryError::NotOutcomeMint); }; require!(supply <= *tracked, BinaryError::SupplyMismatch); *tracked = supply; Ok(()) } /// The vault must cover every outstanding claim. Called at the end of every /// money path, after the vault has been reloaded from the token program. fn assert_solvent(market: &Market, vault: &Account) -> Result<()> { let required = required_coverage(market).ok_or(BinaryError::BadState)?; require!(vault.amount >= required, BinaryError::VaultUnderfunded); Ok(()) } // --------------------------------------------------------------------------- // Accounts // --------------------------------------------------------------------------- #[derive(Accounts)] #[instruction(market_id: u64)] pub struct CreateMarket<'info> { #[account(mut)] pub admin: Signer<'info>, #[account( init, payer = admin, space = Market::SPACE, seeds = [b"market", admin.key().as_ref(), &market_id.to_le_bytes()], bump )] pub market: Box>, /// Pinned by decimals to USDC precision. A deployment would also pin the mint /// address itself; the program cannot know which USDC is the reviewed one. pub usdc_mint: Box>, #[account( init, payer = admin, seeds = [b"yes_mint", market.key().as_ref()], bump, mint::decimals = USDC_DECIMALS, mint::authority = market, mint::freeze_authority = market, )] pub yes_mint: Box>, #[account( init, payer = admin, seeds = [b"no_mint", market.key().as_ref()], bump, mint::decimals = USDC_DECIMALS, mint::authority = market, mint::freeze_authority = market, )] pub no_mint: Box>, pub token_program: Program<'info, Token>, pub system_program: Program<'info, System>, } /// Create the market's segregated USDC reserve and its fee account. /// /// Split out of `create_market` for one reason: two `init` token-account /// constraints on top of the market and two mint inits push the SBF stack frame /// of `create_market` past the 4096-byte limit. The split is mechanical, not a /// change in authority, and `create_reserve` is admin-only and only possible /// while the market is PENDING, so a live market cannot have its reserve or its /// fee account created or replaced after issuance starts. #[derive(Accounts)] pub struct CreateReserve<'info> { #[account(mut)] pub admin: Signer<'info>, #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = admin @ BinaryError::Unauthorized, )] pub market: Box>, pub usdc_mint: Box>, #[account( init, payer = admin, seeds = [b"vault", market.key().as_ref()], bump, token::mint = usdc_mint, token::authority = market, )] pub vault: Account<'info, TokenAccount>, #[account( init, payer = admin, seeds = [b"fee_account", market.key().as_ref()], bump, token::mint = usdc_mint, token::authority = market, )] pub fee_account: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct AdminOnly<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = admin @ BinaryError::Unauthorized, )] pub market: Box>, #[account(mut)] pub admin: Signer<'info>, } #[derive(Accounts)] pub struct AdjudicatorOnly<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, pub adjudicator: Signer<'info>, } #[derive(Accounts)] pub struct ChallengerOnly<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, pub challenger: Signer<'info>, } #[derive(Accounts)] pub struct Propose<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, pub proposer: Signer<'info>, } /// `adjudicator` is required to be present but is only *checked* from DISPUTED. /// From PROPOSED or CHALLENGE any signer may finalize once the window has /// elapsed, which is what makes finalization permissionless without making it /// predicable. #[derive(Accounts)] pub struct Finalize<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, pub adjudicator: Signer<'info>, } #[derive(Accounts)] pub struct SweepFees<'info> { #[account( seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = admin @ BinaryError::Unauthorized, )] pub market: Box>, #[account(mut)] pub admin: Signer<'info>, #[account( mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump, token::mint = market.usdc_mint, token::authority = market, )] pub fee_account: Box>, /// Caller-chosen but constrained to this market's own collateral mint, so a /// fee sweep cannot be pointed at an arbitrary mint. #[account(mut, token::mint = market.usdc_mint)] pub destination: Box>, pub token_program: Program<'info, Token>, } #[derive(Accounts)] pub struct MintPair<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, #[account(mut)] pub owner: Signer<'info>, #[account(mut, seeds = [b"yes_mint", market.key().as_ref()], bump = market.yes_mint_bump)] pub yes_mint: Box>, #[account(mut, seeds = [b"no_mint", market.key().as_ref()], bump = market.no_mint_bump)] pub no_mint: Box>, #[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)] pub vault: Box>, #[account(mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump)] pub fee_account: Box>, #[account(mut, token::mint = market.usdc_mint, token::authority = owner)] pub owner_usdc: Box>, #[account(mut, token::mint = market.yes_mint, token::authority = owner)] pub owner_yes: Box>, #[account(mut, token::mint = market.no_mint, token::authority = owner)] pub owner_no: Box>, pub token_program: Program<'info, Token>, } #[derive(Accounts)] pub struct RedeemPair<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, #[account(mut)] pub owner: Signer<'info>, #[account(mut, seeds = [b"yes_mint", market.key().as_ref()], bump = market.yes_mint_bump)] pub yes_mint: Box>, #[account(mut, seeds = [b"no_mint", market.key().as_ref()], bump = market.no_mint_bump)] pub no_mint: Box>, #[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)] pub vault: Box>, #[account(mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump)] pub fee_account: Box>, #[account(mut, token::mint = market.usdc_mint, token::authority = owner)] pub owner_usdc: Box>, #[account(mut, token::mint = market.yes_mint, token::authority = owner)] pub owner_yes: Box>, #[account(mut, token::mint = market.no_mint, token::authority = owner)] pub owner_no: Box>, pub token_program: Program<'info, Token>, } /// One claim account rather than two: `redeem_winning` takes whichever mint is /// winning and the instruction refuses the other. Requiring the call site to name /// the right side is clearer than accepting both and paying zero for one. #[derive(Accounts)] pub struct RedeemWinning<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, #[account(mut)] pub owner: Signer<'info>, #[account(mut)] pub claim_mint: Box>, #[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)] pub vault: Box>, #[account(mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump)] pub fee_account: Box>, #[account(mut, token::mint = market.usdc_mint, token::authority = owner)] pub owner_usdc: Box>, #[account(mut, token::mint = claim_mint, token::authority = owner)] pub owner_claim: Box>, pub token_program: Program<'info, Token>, } #[derive(Accounts)] pub struct RedeemCancelled<'info> { #[account( mut, seeds = [b"market", market.creator.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, )] pub market: Box>, #[account(mut)] pub owner: Signer<'info>, #[account(mut)] pub claim_mint: Box>, #[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)] pub vault: Box>, #[account(mut, token::mint = market.usdc_mint, token::authority = owner)] pub owner_usdc: Box>, #[account(mut, token::mint = claim_mint, token::authority = owner)] pub owner_claim: Box>, pub token_program: Program<'info, Token>, } // --------------------------------------------------------------------------- // Events. Every money movement and every state change emits one, so an audit // trail can be rebuilt from logs alone. // --------------------------------------------------------------------------- #[event] pub struct MarketCreated { pub market: Pubkey, pub admin: Pubkey, pub market_id: u64, pub usdc_mint: Pubkey, pub yes_mint: Pubkey, pub no_mint: Pubkey, pub mint_fee_bps: u16, pub redeem_fee_bps: u16, pub cancel_payout_bps: u16, pub challenge_window_secs: i64, } #[event] pub struct ReserveCreated { pub market: Pubkey, pub vault: Pubkey, pub fee_account: Pubkey, } #[event] pub struct PoolsRecorded { pub market: Pubkey, pub yes_pool: Pubkey, pub no_pool: Pubkey, } #[event] pub struct MarketActivated { pub market: Pubkey, } #[event] pub struct PauseChanged { pub market: Pubkey, pub paused: bool, } #[event] pub struct AdminTransferred { pub market: Pubkey, pub before: Pubkey, pub after: Pubkey, } #[event] pub struct AuthoritiesChanged { pub market: Pubkey, pub proposer: Pubkey, pub challenger: Pubkey, pub adjudicator: Pubkey, } #[event] pub struct FeesSwept { pub market: Pubkey, pub destination: Pubkey, pub amount: u64, } #[event] pub struct PairMinted { pub market: Pubkey, pub owner: Pubkey, pub pairs: u64, pub fee: u64, pub gross: u64, } #[event] pub struct PairMerged { pub market: Pubkey, pub owner: Pubkey, pub pairs: u64, pub fee: u64, pub net: u64, } #[event] pub struct WinningRedeemed { pub market: Pubkey, pub owner: Pubkey, pub claim_mint: Pubkey, pub units: u64, pub fee: u64, pub net: u64, } #[event] pub struct CancelledClaimRedeemed { pub market: Pubkey, pub owner: Pubkey, pub claim_mint: Pubkey, pub units: u64, pub payout: u64, } #[event] pub struct Proposed { pub market: Pubkey, pub outcome: u8, pub round_no: u64, pub proposed_at: i64, pub challenge_until: i64, pub evidence_hash: [u8; 32], pub proposal_key: [u8; 32], } #[event] pub struct ProposalCancelled { pub market: Pubkey, pub reason_hash: [u8; 32], } #[event] pub struct Challenged { pub market: Pubkey, pub reason_hash: [u8; 32], } #[event] pub struct Finalized { pub market: Pubkey, pub outcome: u8, pub round_no: u64, pub finalized_at: i64, pub adjudication_note_hash: [u8; 32], } #[event] pub struct MarketCancelled { pub market: Pubkey, pub cancel_payout_bps: u16, pub reason_hash: [u8; 32], } // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- #[error_code] pub enum BinaryError { #[msg("A required hash is the zero hash.")] EmptyMetadata, #[msg("A fee rate is above the program cap of 1000 bps (10%).")] FeeTooHigh, #[msg("The cancellation payout rule must be set to a non-zero value at creation.")] CancelRuleUnset, #[msg("The cancellation payout rule would pay more than 1 USDC per complete pair.")] CancelRuleBreaksTheSet, #[msg("The challenge window must be between one second and thirty days.")] BadChallengeWindow, #[msg("A required authority key is the default key.")] MissingAuthority, #[msg("A required pool address is missing.")] MissingPool, #[msg("The two pool addresses must be distinct, or both absent.")] PoolsNotDistinct, #[msg("The collateral mint must have 6 decimals, like USDC.")] WrongDecimals, #[msg("This market is not PENDING.")] MarketNotPending, #[msg("The reserve has already been created for this market.")] ReserveAlreadyCreated, #[msg("Create the market's reserve with `create_reserve` first.")] ReserveNotCreated, #[msg("A token account belongs to a different mint than this market expects.")] WrongMint, #[msg("Public issuance is off. Only the admin may mint while the market is PENDING.")] MarketNotActive, #[msg("This market is paused and is not issuing new pairs.")] MarketPaused, #[msg("New pair issuance has closed; the market is past OPEN.")] IssuanceClosed, #[msg("The amount must be greater than zero.")] ZeroAmount, #[msg("The result is worse than the minimum you set.")] SlippageExceeded, #[msg("Checked arithmetic overflowed.")] MathOverflow, #[msg("The supply the program tracks does not match the mint's own supply.")] SupplyMismatch, #[msg("The vault holds less than the collateral the outstanding claims require.")] VaultUnderfunded, #[msg("A state-machine transition that the rules do not allow.")] IllegalTransition, #[msg("The market's resolution state is not valid.")] BadState, #[msg("Only the designated signer may submit this instruction.")] Unauthorized, #[msg("A proposal must record a definite YES or NO.")] BadOutcome, #[msg("Only an OPEN market may be proposed.")] NotOpen, #[msg("This proposal key was already used.")] DuplicateProposal, #[msg("The round number is not newer than the last accepted proposal.")] StaleProposal, #[msg("The challenge window is still open; finalization is not yet permitted.")] ChallengeWindowOpen, #[msg("There is no challenge window on record, so the market is not proposed.")] NoChallengeWindow, #[msg("A disputed market is finalized only by the adjudicator, with a note on record.")] MissingAdjudicationNote, #[msg("This market cannot be finalized from its current state.")] NotFinalizable, #[msg("There is no proposed outcome to finalize.")] NoOutcome, #[msg("The market has not been finalized, so no winning claim can be redeemed yet.")] NotFinal, #[msg("This mint is not the winning side of the market.")] NotWinningMint, #[msg("This mint is not one of the market's two outcome mints.")] NotOutcomeMint, #[msg("The market has already been resolved, so a pair cannot be merged.")] AlreadyResolved, #[msg("The market has not been cancelled, so the cancellation payout is not available.")] NotCancelled, #[msg("The requested amount exceeds the outstanding supply.")] InsufficientSupply, #[msg("The terminal settlement policy is frozen or excludes this legacy action.")] TerminalPolicyFrozen, #[msg("Three nonzero distinct terminal attestor keys are required.")] BadTerminalAttestors, #[msg("Terminal reports are stale, future-dated, or mismatch the frozen market context.")] BadTerminalReport, #[msg("All three verdicts must explicitly agree on definitive YES or NO.")] NoTerminalAgreement, } // --------------------------------------------------------------------------- // Tests. The arithmetic and the state machine are tested exhaustively in // `science_binary_core`, where Miri and Kani can reach them. What is left for // here is the glue: the invariant as this program computes it, the outcome and // mint selection, and the refusals that are pure functions of account state. // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn market_fixture() -> Market { Market { admin: Pubkey::new_from_array([9u8; 32]), proposer: Pubkey::new_from_array([10u8; 32]), challenger: Pubkey::new_from_array([11u8; 32]), adjudicator: Pubkey::new_from_array([12u8; 32]), usdc_mint: Pubkey::new_from_array([3u8; 32]), yes_mint: Pubkey::new_from_array([1u8; 32]), no_mint: Pubkey::new_from_array([2u8; 32]), vault: Pubkey::new_from_array([4u8; 32]), fee_account: Pubkey::new_from_array([5u8; 32]), question_hash: [7u8; 32], criteria_hash: [8u8; 32], methodology_hash: [6u8; 32], yes_pool: Pubkey::new_from_array([13u8; 32]), no_pool: Pubkey::new_from_array([14u8; 32]), market_id: 1, mint_fee_bps: 200, redeem_fee_bps: 200, cancel_payout_bps: 5_000, challenge_window_secs: 72 * 60 * 60, status: STATUS_ACTIVE, paused: false, reserve_created: true, resolution_state: bc::STATE_OPEN, proposed_outcome: bc::OUTCOME_NONE, final_outcome: bc::OUTCOME_NONE, yes_supply: 0, no_supply: 0, proposed_round: 0, last_proposal_key: [0u8; 32], proposed_at: 0, challenge_until: 0, finalized_at: 0, cancelled_at: 0, challenged_at: 0, evidence_hash: [0u8; 32], challenge_reason_hash: [0u8; 32], cancel_reason_hash: [0u8; 32], adjudication_note_hash: [0u8; 32], bump: 1, vault_bump: 2, fee_account_bump: 3, yes_mint_bump: 4, no_mint_bump: 5, creator: Pubkey::new_from_array([9u8; 32]), terminal_mode: false, issuance_started: false, terminal_attestors: [Pubkey::default(); 3], evidence_cutoff: 0, terminal_max_age: 0, } } #[test] fn pre_resolution_coverage_is_the_larger_supply() { let mut m = market_fixture(); m.yes_supply = 100; m.no_supply = 100; assert_eq!(required_coverage(&m), Some(100)); // Unequal supplies: the conservative form covers the larger. This shape // is not reachable through the program, which mints and burns both sides // together, and the check covers it anyway. m.yes_supply = 101; assert_eq!(required_coverage(&m), Some(101)); assert_eq!(required_coverage(&market_fixture()), Some(0)); } #[test] fn final_coverage_is_only_the_winning_supply() { let mut m = market_fixture(); m.resolution_state = bc::STATE_FINAL; m.final_outcome = bc::OUTCOME_YES; m.yes_supply = 40; m.no_supply = 900; // The losing 900 cannot redeem, so it is not required. assert_eq!(required_coverage(&m), Some(40)); m.final_outcome = bc::OUTCOME_NO; assert_eq!(required_coverage(&m), Some(900)); } #[test] fn cancelled_coverage_is_the_sum_of_both_sides_at_the_rule() { let mut m = market_fixture(); m.resolution_state = bc::STATE_CANCELLED; m.cancel_payout_bps = 5_000; m.yes_supply = 100; m.no_supply = 100; assert_eq!(required_coverage(&m), Some(100)); // Half of an odd supply rounds down, so the odd unit is not owed. m.yes_supply = 101; m.no_supply = 101; assert_eq!(required_coverage(&m), Some(100)); // A quarter rate needs a quarter of the supply. m.cancel_payout_bps = 2_500; assert_eq!(required_coverage(&m), Some(50)); } #[test] fn an_invalid_state_has_no_coverage_definition() { let mut m = market_fixture(); m.resolution_state = 200; assert_eq!(required_coverage(&m), None); } #[test] fn the_winning_mint_follows_the_final_outcome() { let mut m = market_fixture(); // Before an outcome is recorded there is no winning mint, so a zeroed // account can never be mistaken for a resolved side. assert_eq!(winning_mint(&m), Pubkey::default()); m.final_outcome = bc::OUTCOME_YES; assert_eq!(winning_mint(&m), m.yes_mint); m.final_outcome = bc::OUTCOME_NO; assert_eq!(winning_mint(&m), m.no_mint); } #[test] fn cover_holds_through_a_mint_then_a_full_merge() { let mut m = market_fixture(); let (vault, pairs) = (500u64, 500u64); m.yes_supply += pairs; m.no_supply += pairs; assert!(vault >= required_coverage(&m).unwrap(), "after a mint"); let (_fee, _net) = bc::redeem_net(pairs, m.redeem_fee_bps).unwrap(); m.yes_supply -= pairs; m.no_supply -= pairs; let vault_after = vault - pairs; assert!( vault_after >= required_coverage(&m).unwrap(), "after a full merge" ); } #[test] fn the_transition_helper_refuses_what_it_should_and_records_what_it_allows() { let mut m = market_fixture(); assert!(transition(&mut m, bc::STATE_FINAL).is_err()); assert_eq!( m.resolution_state, bc::STATE_OPEN, "a refusal changes nothing" ); assert!(transition(&mut m, bc::STATE_PROPOSED).is_ok()); assert_eq!(m.resolution_state, bc::STATE_PROPOSED); assert!(transition(&mut m, bc::STATE_FINAL).is_ok()); assert_eq!(m.resolution_state, bc::STATE_FINAL); // And FINAL is a latch: nothing leaves it. assert!(transition(&mut m, bc::STATE_OPEN).is_err()); assert!(transition(&mut m, bc::STATE_CANCELLED).is_err()); assert_eq!(m.resolution_state, bc::STATE_FINAL); } #[test] fn a_market_whose_reserve_is_not_created_reads_as_uncreated() { // The flag the money paths gate on. A market straight out of // `create_market` has `vault_bump == 0` and a default vault, and every // money instruction refuses it; `create_reserve` is what flips it. let mut m = market_fixture(); m.reserve_created = false; m.vault = Pubkey::default(); m.fee_account = Pubkey::default(); assert!(!m.reserve_is_created()); m.reserve_created = true; m.vault = Pubkey::new_from_array([4u8; 32]); m.fee_account = Pubkey::new_from_array([5u8; 32]); // A canonical bump of zero is normal and must still read as created. m.vault_bump = 0; m.fee_account_bump = 0; assert!(m.reserve_is_created()); // A half-written state still reads as uncreated, so the gate cannot be // flipped by half a write. m.vault = Pubkey::default(); assert!(!m.reserve_is_created()); } #[test] fn the_market_pda_signer_seeds_are_the_ones_the_account_uses() { let mut m = market_fixture(); // Use the canonical bump, not the fixture's placeholder, so this is a // statement about the seeds and not about a hand-chosen byte. let (expected, canonical_bump) = Pubkey::find_program_address( &[b"market", m.creator.as_ref(), &m.market_id.to_le_bytes()], &crate::ID, ); m.bump = canonical_bump; let seeds = market_signer_seeds(&m); assert_eq!(seeds.len(), 4); assert_eq!(seeds[0], b"market"); assert_eq!(seeds[1], m.creator.to_bytes()); m.admin = Pubkey::new_unique(); assert_eq!( market_signer_seeds(&m), seeds, "admin rotation must preserve CPI signing" ); assert_eq!(seeds[2], m.market_id.to_le_bytes()); assert_eq!(seeds[3], vec![m.bump]); // The seeds the vault transfer signs with must derive the same address // as the `seeds = [...]` constraint on the account. `create_program_address` // is the check, because the signer slice carries the bump explicitly. let refs = seeds_ref(&seeds); let derived = Pubkey::create_program_address(&refs, &crate::ID).unwrap(); assert_eq!(expected, derived); // A wrong bump does not derive it, which is what makes the vault seed a // real constraint rather than a formality. m.bump = canonical_bump.wrapping_add(1); let wrong = market_signer_seeds(&m); let wrong_refs = seeds_ref(&wrong); assert_ne!( Pubkey::create_program_address(&wrong_refs, &crate::ID), Ok(expected) ); } #[test] fn external_burn_reconciliation_only_accepts_decreases() { let mut m = market_fixture(); m.yes_supply = 100; m.no_supply = 100; assert!(reconcile_pair_supplies(&mut m, 99, 80).is_ok()); assert_eq!((m.yes_supply, m.no_supply), (99, 80)); assert!(reconcile_pair_supplies(&mut m, 100, 80).is_err()); assert_eq!((m.yes_supply, m.no_supply), (99, 80)); let yes = m.yes_mint; assert!(reconcile_claim_supply(&mut m, yes, 70).is_ok()); assert!(reconcile_claim_supply(&mut m, yes, 71).is_err()); assert!(reconcile_claim_supply(&mut m, Pubkey::new_unique(), 0).is_err()); assert_eq!(m.yes_supply, 70); } #[test] fn the_market_space_is_a_fixed_footprint() { // A regression pin. The account is deliberately fixed-size with no // variable-length data, so a field change has to move this number on // purpose. Includes immutable creator and opt-in terminal policy. assert_eq!(Market::INIT_SPACE, 851); assert_eq!(Market::SPACE, 859); } }