//! Science Markets — core market and position accounts. //! //! Draft example program. Not deployed to any cluster, and not audited. //! //! One responsibility: hold a frozen scientific proposition, open a fully //! funded bilateral position against it, and settle that position at a score. //! Quotes, fees, and the dealer's reserve accounting are the other program's //! job (`science_dealer`), because one small readable program beats one big one. //! //! Economics, taken from the published terms (see [internal location withheld]): //! //! N maximum gross payout of the position //! p entry score in (0,1) //! q settlement score in (0,1) //! f fee rate per leg, charged on dollars, never on N //! //! UP trader posts p*N, dealer reserves (1-p)*N //! DOWN trader posts (1-p)*N, dealer reserves p*N //! settle trader gross = q*N (UP) or (1-q)*N (DOWN); dealer gets the rest //! fees entry fee on the trader's contribution, close fee on the gross //! proceeds, both to the fee vault; the dealer's reserve is untouched //! //! Two invariants carry the design: //! //! I1 For every position, trader_contribution + dealer_contribution equals //! max_payout (N). The dealer's reserve is part of that committed capital, //! and the market's `reserve_locked` is checked against the vault before //! any settlement pays out, so reserved collateral cannot be spent. //! I2 A position settles once. `settle_position` requires status Open and //! sets it Closed in the same instruction, so a second call fails. //! //! What this program deliberately does not do: identity, sanctions screening, //! disputes, or record keeping. The on-chain eligibility check below is the //! minimal on-chain half of a control whose policy lives off chain. The legal //! brief (§7) is explicit that a rule existing only on the website is not an //! effective program-level restriction. //! //! On-chain authorization, not location //! ------------------------------------ //! This program checks **authorization**, not **location**. It cannot see where //! a person is, and it never tries to. What it can do is refuse to open or //! enlarge exposure without a live, revocable, unexpired credential that the //! market's own authority wrote for this exact (market, trader). That refusal //! holds for a direct RPC call, a CPI from another program, an aggregator, and //! a delegated or session signer alike, because the requirement is an account //! constraint on the instruction rather than a check in a web view. //! //! The authorization design is deliberately auditable and reversible: //! //! * `set_eligibility` writes one boolean and one expiry for one trader. It //! is the switch a real credential would drive; the policy that decides the //! switch lives off chain, and the program says so rather than pretending //! the switch is the policy. //! * Revocation is immediate: `allowed = false` stops every later opening and //! every later increase, because opening and enlarging are the same //! instruction (`open_position`). It does not touch an open position, which //! can still settle — a revocation must not strand a funded exit. //! * Expiry is enforced in the instruction with the clock, so a credential //! that lapses off chain stops new exposure on chain without a second call. //! //! The access-policy signer (`market.authority`) is deliberately separate from //! ownership of collateral: it may pause a market and restrict an owner, and it //! has no instruction that moves a vault, a position's notional, or a payout. //! Collateral can only move through `open_position`, `settle_position`, and //! `restrict_owner`, each of which pays a fixed, precomputed amount. There is no //! position-transfer instruction and no bearer permission, so nothing can carry //! an entitlement past a revocation. //! //! Unsolicited token transfers are not onboarding. A transfer into a vault does //! not create a position, does not credit spendable collateral, and is not //! auto-refunded: `reserve_locked` is written from `open_position` alone, so a //! stray deposit is inert until a funded position is opened properly. use anchor_lang::prelude::*; use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; declare_id!("HywubrvNwhWjyZC3G1emofFpnABAb6kFE7KWghAA288v"); /// Scores are basis points of probability: 1..=9999 means 0.0001..=0.9999. /// Both endpoints are excluded: a score of 0 or 1 has no two-sided price and /// would settle the whole pot to one side. pub const SCORE_MIN: u16 = 1; pub const SCORE_MAX: u16 = 9999; pub const SCORE_DENOM: u16 = 10_000; pub const SIDE_UP: u8 = 0; pub const SIDE_DOWN: u8 = 1; pub const POSITION_OPEN: u8 = 0; pub const POSITION_CLOSED: u8 = 1; /// 10% per leg. The published default is 2%; the cap exists so a fat-fingered /// value cannot make the entry fee larger than the position itself. pub const MAX_FEE_BPS: u16 = 1_000; /// Maximum notional of one position. A pilot bound, not an economic rule. pub const MAX_NOTIONAL: u64 = 1_000_000_000_000; // 1,000,000 USDC at 6 decimals #[program] pub mod science_market { use super::*; /// Create the market and its collateral vault, and pin the proposition. /// /// The claim is written once here and no instruction in this program edits /// it. Changing the wording has to be a new market with a new account. pub fn initialize_market( ctx: Context, market_id: u64, claim: String, score_millis: u16, ) -> Result<()> { require!(!claim.is_empty(), MarketError::ClaimEmpty); require!( claim.len() <= Market::MAX_CLAIM_LEN, MarketError::ClaimTooLong ); require!(valid_score(score_millis), MarketError::BadScore); let market = &mut ctx.accounts.market; market.authority = ctx.accounts.authority.key(); market.market_id = market_id; market.claim = claim; market.settlement_mint = ctx.accounts.settlement_mint.key(); market.fee_vault = ctx.accounts.fee_vault.key(); market.initial_score_millis = score_millis; market.open_positions = 0; market.reserve_locked = 0; market.paused = false; market.bump = ctx.bumps.market; market.vault_bump = ctx.bumps.vault; Ok(()) } /// Open a fully funded position. Both legs move in one instruction. /// /// The caller gives the trader's contribution, the side, the entry score /// `p`, and the fee rate. The program derives N and the dealer's reserve /// from those, so the amounts are not taken on trust. The trader also posts /// the entry fee, which goes straight to the fee vault. /// /// After this instruction the vault holds N for this position, and I1 holds /// exactly: trader_contribution + dealer_contribution == max_payout. /// /// `entry_score_millis` is the dealer's quote for this fill. /// `min_contribution` is caller-supplied slippage protection: if the /// recomputed trader leg is lower, the transaction fails instead of /// opening a smaller position than the caller intended. pub fn open_position( ctx: Context, side: u8, entry_score_millis: u16, trader_contribution: u64, fee_bps: u16, min_contribution: u64, ) -> Result<()> { require!(side == SIDE_UP || side == SIDE_DOWN, MarketError::BadSide); require!(valid_score(entry_score_millis), MarketError::BadScore); require!(fee_bps <= MAX_FEE_BPS, MarketError::FeeTooHigh); require!(trader_contribution > 0, MarketError::ZeroAmount); require!(!ctx.accounts.market.paused, MarketError::MarketPaused); // The on-chain authorization check (policy §3). This is authorization, // not location: the program cannot see where the trader is, and the // check is an account requirement rather than a web view, so it holds // for a direct RPC call, a CPI, an aggregator, and a delegated or // session signer. `eligibility` is a PDA for this exact (market, // trader), and the `OpenPosition` struct already requires // `eligibility.allowed`. The two checks here are the ones a constraint // cannot make: the credential must name this signer, and it must not be // expired. Opening and enlarging are the same instruction, so a // revocation stops new exposure without a second call. require!( ctx.accounts.eligibility.trader == ctx.accounts.trader.key(), MarketError::NotEligible ); require!( ctx.accounts.eligibility.expires_at == 0 || ctx.accounts.eligibility.expires_at > Clock::get()?.unix_timestamp, MarketError::EligibilityExpired ); let fraction_bps = trader_fraction_bps(side, entry_score_millis)?; // N = contribution * 10000 / fraction, rounded down. Rounding down // keeps the trader's contribution covering its share of N, so I1 can // never be short because of rounding. let max_payout = mul_div( trader_contribution as u128, SCORE_DENOM as u128, fraction_bps as u128, )?; require!(max_payout > 0, MarketError::ZeroAmount); require!(max_payout <= MAX_NOTIONAL, MarketError::NotionalTooLarge); // The trader leg is recomputed from N so the three numbers reconcile by // construction. It is at most the contribution the caller passed. let trader_leg = mul_div( max_payout as u128, fraction_bps as u128, SCORE_DENOM as u128, )?; // The recomputed leg can round to zero on a small contribution at an // extreme score: contribution 1 at 0.9999 gives N = 1 and a trader leg // of 0. Without this check the position would open with the trader // having posted nothing and the dealer funding the whole notional, so // every settlement pays the trader out of the dealer's money. Require // both legs to be positive, which is the fully funded shape. require!(trader_leg > 0, MarketError::ZeroAmount); require!(trader_leg >= min_contribution, MarketError::SlippageExceeded); let dealer_leg = max_payout .checked_sub(trader_leg) .ok_or(MarketError::MathOverflow)?; require!(dealer_leg > 0, MarketError::ZeroAmount); // I1, checked before a signature is used. require!( trader_leg .checked_add(dealer_leg) .ok_or(MarketError::MathOverflow)? == max_payout, MarketError::ReserveMismatch ); let entry_fee = fee_on(trader_leg, fee_bps)?; // The two legs land in the vault and together they are exactly N, so // the vault's balance for this position equals its `max_payout`. That // makes the settle-time check (`vault.amount >= reserve_locked`) line // up with `reserve_locked` being the sum of open positions' notionals. // The entry fee is charged on dollars and moves separately to the fee // vault; it is never mixed into the locked collateral. token::transfer( CpiContext::new( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.trader_token.to_account_info(), to: ctx.accounts.vault.to_account_info(), authority: ctx.accounts.trader.to_account_info(), }, ), trader_leg, )?; token::transfer( CpiContext::new( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.dealer_token.to_account_info(), to: ctx.accounts.vault.to_account_info(), authority: ctx.accounts.dealer.to_account_info(), }, ), dealer_leg, )?; if entry_fee > 0 { token::transfer( CpiContext::new( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.trader_token.to_account_info(), to: ctx.accounts.fee_vault.to_account_info(), authority: ctx.accounts.trader.to_account_info(), }, ), entry_fee, )?; } let position = &mut ctx.accounts.position; position.market = ctx.accounts.market.key(); position.owner = ctx.accounts.trader.key(); position.dealer = ctx.accounts.dealer.key(); position.side = side; position.entry_score_millis = entry_score_millis; position.trader_contribution = trader_leg; position.dealer_contribution = dealer_leg; position.max_payout = max_payout; position.entry_fee = entry_fee; position.fee_bps = fee_bps; position.status = POSITION_OPEN; position.settled_score_millis = 0; position.trader_gross = 0; position.close_fee = 0; position.trader_payout = 0; position.dealer_payout = 0; position.bump = ctx.bumps.position; let market = &mut ctx.accounts.market; market.open_positions = market .open_positions .checked_add(1) .ok_or(MarketError::MathOverflow)?; market.reserve_locked = market .reserve_locked .checked_add(max_payout) .ok_or(MarketError::MathOverflow)?; emit!(PositionOpened { position: position.key(), max_payout, trader_contribution: trader_leg, dealer_contribution: dealer_leg, entry_fee, }); Ok(()) } /// Split the position's reserve at a score and close it. /// /// The partitioning is arithmetic — the payout follows entirely from the /// account data and the score. Who may assert the score is not: it must be /// this market's authority. In this draft that signature is the only thing /// asserting the score, so it is checked against `market.authority`. Do not /// ship it that way: the score has to be fixed off chain before submission, /// and late or disputed submissions need a rule (legal brief §9). /// /// The trader receives gross minus the close fee. The dealer receives /// max_payout minus gross, which returns its reserve and its trading result. /// The close fee goes to the fee vault, never to the dealer, so the explicit /// customer fee and the maker's result stay separate (brief §8). pub fn settle_position(ctx: Context, score_millis: u16) -> Result<()> { require!(valid_score(score_millis), MarketError::BadScore); let market = &ctx.accounts.market; // The reporting authority's signature is what asserts the score. It must // be this market's authority: without this check any signer could settle // any position at a score of their choosing. require_keys_eq!( ctx.accounts.authority.key(), market.authority, MarketError::Unauthorized ); let position = &mut ctx.accounts.position; require!( position.status == POSITION_OPEN, MarketError::PositionNotOpen ); require_keys_eq!( position.market, market.key(), MarketError::WrongMarket ); let fraction_bps = trader_fraction_bps(position.side, score_millis)?; let gross = mul_div( position.max_payout as u128, fraction_bps as u128, SCORE_DENOM as u128, )?; let dealer_payout = position .max_payout .checked_sub(gross) .ok_or(MarketError::MathOverflow)?; let close_fee = fee_on(gross, position.fee_bps)?; let trader_payout = gross .checked_sub(close_fee) .ok_or(MarketError::MathOverflow)?; // I2: the split pays out exactly N once. This is an identity given the // lines above, and it is asserted anyway so a future edit cannot break // it silently. let split = trader_payout .checked_add(close_fee) .and_then(|v| v.checked_add(dealer_payout)) .ok_or(MarketError::MathOverflow)?; require!(split == position.max_payout, MarketError::ReserveMismatch); // I1 before money moves: the vault must still hold at least the reserve // committed to every open position. That figure includes this one, so // paying out this position cannot touch another position's reserve. require!( ctx.accounts.vault.amount >= market.reserve_locked, MarketError::VaultUnderfunded ); // Pay the trader, the dealer, and the fee vault, all signed by the // market PDA that owns the vault. if trader_payout > 0 { pay_from_vault( &ctx.accounts.token_program, &ctx.accounts.vault, &ctx.accounts.trader_token, &ctx.accounts.market, trader_payout, )?; } if dealer_payout > 0 { pay_from_vault( &ctx.accounts.token_program, &ctx.accounts.vault, &ctx.accounts.dealer_token, &ctx.accounts.market, dealer_payout, )?; } if close_fee > 0 { pay_from_vault( &ctx.accounts.token_program, &ctx.accounts.vault, &ctx.accounts.fee_vault, &ctx.accounts.market, close_fee, )?; } position.status = POSITION_CLOSED; position.settled_score_millis = score_millis; position.trader_gross = gross; position.close_fee = close_fee; position.trader_payout = trader_payout; position.dealer_payout = dealer_payout; let market = &mut ctx.accounts.market; market.open_positions = market.open_positions.saturating_sub(1); market.reserve_locked = market .reserve_locked .checked_sub(position.max_payout) .ok_or(MarketError::MathOverflow)?; emit!(PositionSettled { position: position.key(), score_millis, gross, close_fee, trader_payout, dealer_payout, }); Ok(()) } /// Return a restricted owner's own contribution and freeze the payout. /// /// This is the escape hatch the legal brief asks for and nothing more. A /// payout may be held, but it must not be confiscated and must not become /// maker profit, so the trader gets its contribution back and the dealer's /// reserve stays in the vault (brief §7). Marking the position Closed stops /// a later settlement from paying twice. /// /// A real deployment would put this behind a logged multisig with a /// published trigger and review. It is one function here so the shape of /// the control is visible. pub fn restrict_owner(ctx: Context) -> Result<()> { let position = &mut ctx.accounts.position; require!( position.status == POSITION_OPEN, MarketError::PositionNotOpen ); let refund = position.trader_contribution; if refund > 0 { pay_from_vault( &ctx.accounts.token_program, &ctx.accounts.vault, &ctx.accounts.trader_token, &ctx.accounts.market, refund, )?; } position.status = POSITION_CLOSED; position.trader_payout = refund; let market = &mut ctx.accounts.market; market.open_positions = market.open_positions.saturating_sub(1); market.reserve_locked = market .reserve_locked .checked_sub(position.max_payout) .ok_or(MarketError::MathOverflow)?; emit!(OwnerRestricted { position: position.key(), refunded: refund, }); Ok(()) } /// Close a market to new positions without changing any funded close right. /// /// Pausing stops new openings. It must never cancel an open position's /// ability to settle, which is why `settle_position` never reads `paused`. pub fn set_paused(ctx: Context, paused: bool) -> Result<()> { ctx.accounts.market.paused = paused; Ok(()) } /// Create or update one trader's on-chain eligibility credential. /// /// This is the minimal on-chain half of the access control the legal brief /// describes (§7). The real policy is off chain: jurisdiction, sanctions, /// terms acceptance, age, and product permissions are decided by the /// operator and by counsel, then written here as a yes/no with an expiry. /// Nothing about a person's identity is stored, and no data leaves the /// operator's own systems. /// /// What this does: it makes a direct program call insufficient on its own. /// `open_position` requires this account, so a caller cannot open by /// talking to the RPC and skipping the website. What it does not do: it is /// not a sanctions program, not KYC, and not a legal determination. Revoking /// a credential stops new openings; it does not touch an open position, /// which can still settle or be restricted through `restrict_owner`. pub fn set_eligibility( ctx: Context, allowed: bool, expires_at: i64, ) -> Result<()> { require!(expires_at >= 0, MarketError::BadExpiry); let eligibility = &mut ctx.accounts.eligibility; eligibility.trader = ctx.accounts.trader.key(); eligibility.allowed = allowed; eligibility.expires_at = expires_at; eligibility.bump = ctx.bumps.eligibility; Ok(()) } } // --------------------------------------------------------------------------- // Accounts // --------------------------------------------------------------------------- /// One continuing market per scientific claim. No expiry. #[account] pub struct Market { /// Who may pause and restrict. Not able to move collateral. pub authority: Pubkey, pub market_id: u64, /// The frozen proposition: set at initialization, never written again. pub claim: String, /// The collateral mint. USDC in the intended deployment; see the /// denomination note in the README. pub settlement_mint: Pubkey, /// Where entry and close fees accumulate. Separate from the vault so fee /// revenue never mixes with reserved collateral. pub fee_vault: Pubkey, pub initial_score_millis: u16, pub open_positions: u32, /// Collateral locked for open positions on this market: the sum of every /// open position's `max_payout`. The vault must always hold at least this /// much, so no settlement or restriction can dip into another position. pub reserve_locked: u64, pub paused: bool, pub bump: u8, pub vault_bump: u8, } impl Market { pub const MAX_CLAIM_LEN: usize = 512; // 8 discriminator + 32 + 8 + (4 + 512) + 32 + 32 + 2 + 4 + 8 + 1 + 1 + 1 pub const SPACE: usize = 8 + 32 + 8 + (4 + Self::MAX_CLAIM_LEN) + 32 + 32 + 2 + 4 + 8 + 1 + 1 + 1; } /// A fully funded bilateral position. Settles once. #[account] pub struct Position { pub market: Pubkey, pub owner: Pubkey, pub dealer: Pubkey, /// 0 = UP, 1 = DOWN. pub side: u8, pub entry_score_millis: u16, /// The trader's share of N, posted at open. pub trader_contribution: u64, /// The dealer's share of N, posted at open. This is the reserve. pub dealer_contribution: u64, /// N: the maximum gross payout, fixed at open. pub max_payout: u64, pub entry_fee: u64, /// Copied from the market at open, so a later fee change cannot reprice an /// existing position. pub fee_bps: u16, /// 0 = open, 1 = closed. pub status: u8, pub settled_score_millis: u16, pub trader_gross: u64, pub close_fee: u64, pub trader_payout: u64, pub dealer_payout: u64, pub bump: u8, } impl Position { // 8 + 32*3 + 1 + 2 + 8*4 (trader/dealer/max/fee) + 2 + 1 + 2 + 8*4 + 1 pub const SPACE: usize = 8 + 96 + 1 + 2 + 32 + 2 + 1 + 2 + 32 + 1; } #[derive(Accounts)] #[instruction(market_id: u64)] pub struct InitializeMarket<'info> { #[account(mut)] pub authority: Signer<'info>, #[account( init, payer = authority, space = Market::SPACE, seeds = [b"market", authority.key().as_ref(), &market_id.to_le_bytes()], bump )] pub market: Account<'info, Market>, /// Not validated against a mint registry: a draft has no registry. A /// deployment would pin this to the reviewed USDC mint. pub settlement_mint: Account<'info, Mint>, /// The fee destination. Its key is stored on the market and every fee /// transfer is checked against it. pub fee_vault: Account<'info, TokenAccount>, #[account( init, payer = authority, seeds = [b"vault", market.key().as_ref()], bump, token::mint = settlement_mint, token::authority = market )] pub vault: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, pub system_program: Program<'info, System>, } #[derive(Accounts)] #[instruction(side: u8, entry_score_millis: u16, trader_contribution: u64, fee_bps: u16, min_contribution: u64)] pub struct OpenPosition<'info> { #[account( mut, seeds = [b"market", market.authority.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump )] pub market: Account<'info, Market>, #[account(mut)] pub trader: Signer<'info>, /// CHECK: a fresh position PDA. All seeds are fixed width, so the encoding /// is injective. `side` and `fee_bps` are included deliberately: without /// them, an UP and a DOWN at the same score and contribution (or two opens /// at a different fee rate) map to the same account and the second `init` /// fails. A trader could not then hold both legs. #[account( init, payer = trader, space = Position::SPACE, seeds = [ b"position", market.key().as_ref(), trader.key().as_ref(), &[side], &entry_score_millis.to_le_bytes(), &fee_bps.to_le_bytes(), &trader_contribution.to_le_bytes() ], bump )] pub position: Account<'info, Position>, /// The dealer signs both as the counterparty and as the payer of its leg. /// The reserve accounting that funds this signer lives in `science_dealer`. #[account(mut)] pub dealer: Signer<'info>, /// The trader's eligibility credential. A PDA for this exact (market, /// trader), so it cannot be borrowed from another account nor written by a /// different market's authority. `allowed` must be true; expiry is checked /// in the instruction because it needs the clock. #[account( seeds = [b"eligibility", market.key().as_ref(), trader.key().as_ref()], bump = eligibility.bump, constraint = eligibility.allowed @ MarketError::NotEligible )] pub eligibility: Account<'info, Eligibility>, #[account(mut, token::mint = market.settlement_mint, token::authority = trader)] pub trader_token: Account<'info, TokenAccount>, #[account(mut, token::mint = market.settlement_mint, token::authority = dealer)] pub dealer_token: Account<'info, TokenAccount>, /// The market's single collateral vault. The seed pins it to this exact /// market's PDA, so a settlement cannot be pointed at another market's vault /// (the review added a test for that). #[account( mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump )] pub vault: Account<'info, TokenAccount>, #[account(mut, address = market.fee_vault, token::mint = market.settlement_mint)] pub fee_vault: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, pub system_program: Program<'info, System>, } #[derive(Accounts)] #[instruction(score_millis: u16)] pub struct SettlePosition<'info> { #[account( mut, seeds = [b"market", market.authority.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump )] pub market: Account<'info, Market>, /// The report authority. In this draft its signature is the only thing /// asserting the score; see the README for what a deployment needs instead. pub authority: Signer<'info>, #[account(mut, has_one = market, has_one = owner)] pub position: Account<'info, Position>, /// CHECK: constrained by `has_one = owner` on the position. pub owner: UncheckedAccount<'info>, #[account( mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump )] pub vault: Account<'info, TokenAccount>, #[account(mut, token::mint = market.settlement_mint, token::authority = owner)] pub trader_token: Account<'info, TokenAccount>, #[account(mut, token::mint = market.settlement_mint, token::authority = position.dealer)] pub dealer_token: Account<'info, TokenAccount>, #[account(mut, address = market.fee_vault, token::mint = market.settlement_mint)] pub fee_vault: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, } #[derive(Accounts)] pub struct RestrictOwner<'info> { #[account( mut, seeds = [b"market", market.authority.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = authority )] pub market: Account<'info, Market>, pub authority: Signer<'info>, #[account(mut, has_one = market, has_one = owner)] pub position: Account<'info, Position>, /// CHECK: constrained by `has_one = owner` on the position. pub owner: UncheckedAccount<'info>, #[account( mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump )] pub vault: Account<'info, TokenAccount>, #[account(mut, token::mint = market.settlement_mint, token::authority = owner)] pub trader_token: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, } #[derive(Accounts)] pub struct SetPaused<'info> { #[account( mut, seeds = [b"market", market.authority.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = authority )] pub market: Account<'info, Market>, pub authority: Signer<'info>, } /// One trader's on-chain eligibility, written by the market authority. /// /// Deliberately minimal: a boolean, an expiry, and nothing about who the person /// is. It is the switch a real credential would drive, and the program says so /// rather than pretending the switch is the policy. #[account] pub struct Eligibility { pub trader: Pubkey, /// Whether this trader may open positions. False is a hard stop. pub allowed: bool, /// Unix timestamp after which the credential is stale; 0 means no expiry. pub expires_at: i64, pub bump: u8, } impl Eligibility { // 8 discriminator + pubkey + bool + i64 + bump pub const SPACE: usize = 8 + 32 + 1 + 8 + 1; } #[derive(Accounts)] pub struct SetEligibility<'info> { #[account( seeds = [b"market", market.authority.as_ref(), &market.market_id.to_le_bytes()], bump = market.bump, has_one = authority )] pub market: Account<'info, Market>, #[account(mut)] pub authority: Signer<'info>, /// CHECK: only used as a seed; the credential is the PDA of this key. pub trader: UncheckedAccount<'info>, /// The credential is scoped to (market, trader), so one market's authority /// cannot grant or revoke eligibility on a market it does not control. #[account( init_if_needed, payer = authority, space = Eligibility::SPACE, seeds = [b"eligibility", market.key().as_ref(), trader.key().as_ref()], bump )] pub eligibility: Account<'info, Eligibility>, pub system_program: Program<'info, System>, } // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- #[event] pub struct PositionOpened { pub position: Pubkey, pub max_payout: u64, pub trader_contribution: u64, pub dealer_contribution: u64, pub entry_fee: u64, } #[event] pub struct PositionSettled { pub position: Pubkey, pub score_millis: u16, pub gross: u64, pub close_fee: u64, pub trader_payout: u64, pub dealer_payout: u64, } #[event] pub struct OwnerRestricted { pub position: Pubkey, pub refunded: u64, } // --------------------------------------------------------------------------- // Pure math. Free of accounts so it can be tested on its own. // --------------------------------------------------------------------------- pub fn valid_score(bps: u16) -> bool { (SCORE_MIN..=SCORE_MAX).contains(&bps) } /// The trader's share of N at a score, in basis points. UP is q; DOWN is /// 1 - q. Both are strictly inside (0, 10000) because valid scores are. pub fn trader_fraction_bps(side: u8, score_millis: u16) -> Result { match side { SIDE_UP => Ok(score_millis), SIDE_DOWN => Ok(SCORE_DENOM - score_millis), _ => err!(MarketError::BadSide), } } /// (a * b) / c with checked widening, rounded down. pub fn mul_div(a: u128, b: u128, c: u128) -> Result { let product = a.checked_mul(b).ok_or(MarketError::MathOverflow)?; let quotient = product.checked_div(c).ok_or(MarketError::MathOverflow)?; Ok(u64::try_from(quotient).map_err(|_| MarketError::MathOverflow)?) } /// Fee on an amount of dollars: amount * fee_bps / 10000, rounded down. pub fn fee_on(amount: u64, fee_bps: u16) -> Result { mul_div(amount as u128, fee_bps as u128, SCORE_DENOM as u128) } /// Pay out of the market's vault. /// /// The vault token account is owned by the `market` PDA (see `InitializeMarket`, /// `token::authority = market`), so the transfer must be signed by that PDA. An /// earlier draft signed with `[b"vault", market, bump]`, which is not the /// vault's owner and not an account's seed at all: every payout would have /// failed at the token program with "owner does not match" (0x4). The seeds /// here are the market's own. /// /// A free function rather than a method so it works with either instruction's /// account list. The caller has already validated `to`. fn pay_from_vault<'info>( token_program: &Program<'info, Token>, vault: &Account<'info, TokenAccount>, to: &Account<'info, TokenAccount>, market: &Account<'info, Market>, amount: u64, ) -> Result<()> { let seeds: &[&[u8]] = &[ b"market", market.authority.as_ref(), &market.market_id.to_le_bytes(), &[market.bump], ]; token::transfer( CpiContext::new_with_signer( token_program.to_account_info(), Transfer { from: vault.to_account_info(), to: to.to_account_info(), authority: market.to_account_info(), }, &[seeds], ), amount, ) } #[error_code] pub enum MarketError { #[msg("The claim must be non-empty.")] ClaimEmpty, #[msg("The claim is longer than the account allows.")] ClaimTooLong, #[msg("Score must be in 1..=9999 basis points.")] BadScore, #[msg("Side must be UP (0) or DOWN (1).")] BadSide, #[msg("Amount must be greater than zero.")] ZeroAmount, #[msg("Fee rate is above the program cap.")] FeeTooHigh, #[msg("Position notional is above the pilot maximum.")] NotionalTooLarge, #[msg("Recomputed contribution is below the minimum you set.")] SlippageExceeded, #[msg("This position is already closed.")] PositionNotOpen, #[msg("The market account does not match this position.")] WrongMarket, #[msg("The payout split does not equal the position notional.")] ReserveMismatch, #[msg("The vault holds less than the reserve committed to open positions.")] VaultUnderfunded, #[msg("This market is paused and is not opening new positions.")] MarketPaused, #[msg("This account has no valid eligibility credential for opening positions.")] NotEligible, #[msg("The eligibility credential has expired.")] EligibilityExpired, #[msg("The expiry timestamp is invalid.")] BadExpiry, #[msg("Checked arithmetic overflowed.")] MathOverflow, #[msg("Only this market's authority may sign this instruction.")] Unauthorized, } // --------------------------------------------------------------------------- // Unit tests. These have no accounts and no cluster, so `cargo test` runs them // with the base toolchain. They pin the arithmetic and the invariant that the // integration tests in `[internal location withheld]` exercise against a validator. // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn score_bounds_exclude_the_endpoints() { assert!(!valid_score(0)); assert!(valid_score(1)); assert!(valid_score(9999)); assert!(!valid_score(10_000)); } #[test] fn up_and_down_fractions_sum_to_one() { for score in [1u16, 4_000, 5_000, 7_200, 9_999] { let up = trader_fraction_bps(SIDE_UP, score).unwrap(); let down = trader_fraction_bps(SIDE_DOWN, score).unwrap(); assert_eq!(up as u32 + down as u32, SCORE_DENOM as u32); } } #[test] fn open_position_legs_sum_to_the_notional() { // The brief's own illustration: a 100-USDC UP position entered at 0.40. // N = 100, trader posts 40, dealer reserves 60. let contribution: u64 = 40_000_000; // 40 USDC let score: u16 = 4_000; // 0.40 let fraction = trader_fraction_bps(SIDE_UP, score).unwrap(); let max_payout = mul_div(contribution as u128, SCORE_DENOM as u128, fraction as u128).unwrap(); let trader_leg = mul_div(max_payout as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); let dealer_leg = max_payout.checked_sub(trader_leg).unwrap(); assert_eq!(max_payout, 100_000_000, "N is 100 USDC"); assert_eq!(trader_leg, 40_000_000, "trader leg is 40 USDC"); assert_eq!(dealer_leg, 60_000_000, "dealer reserve is 60 USDC"); assert_eq!(trader_leg + dealer_leg, max_payout, "I1 holds at open"); } #[test] fn settlement_split_equals_the_notional() { // The brief's worked example at 0.72, with a 2% fee per leg: // entry 0.40 on a 100-USDC UP position, close at 0.72. let max_payout: u64 = 100_000_000; let fee_bps: u16 = 200; // 2% let score: u16 = 7_200; // 0.72 let fraction = trader_fraction_bps(SIDE_UP, score).unwrap(); let gross = mul_div(max_payout as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); let dealer_payout = max_payout.checked_sub(gross).unwrap(); let close_fee = fee_on(gross, fee_bps).unwrap(); let trader_payout = gross.checked_sub(close_fee).unwrap(); assert_eq!(gross, 72_000_000, "gross is 72 USDC"); assert_eq!(dealer_payout, 28_000_000, "dealer gets 28 USDC"); assert_eq!(close_fee, 1_440_000, "close fee is 1.44 USDC (2% of 72)"); assert_eq!(trader_payout, 70_560_000, "trader net is 70.56 USDC"); assert_eq!( trader_payout + close_fee + dealer_payout, max_payout, "I2: the split consumes N exactly, once" ); } #[test] fn down_position_is_the_mirror_of_up() { let max_payout: u64 = 100_000_000; let score: u16 = 7_200; let down_fraction = trader_fraction_bps(SIDE_DOWN, score).unwrap(); let gross = mul_div(max_payout as u128, down_fraction as u128, SCORE_DENOM as u128).unwrap(); let dealer_payout = max_payout.checked_sub(gross).unwrap(); assert_eq!(down_fraction, 2_800, "DOWN at 0.72 is worth 0.28"); assert_eq!(gross, 28_000_000); assert_eq!(dealer_payout, 72_000_000); } /// Every valid score splits into two positive halves that sum to the /// denominator. Exhaustive, not sampled: it is 9,999 cheap iterations. #[test] fn every_score_is_two_sided() { for score in SCORE_MIN..=SCORE_MAX { let up = trader_fraction_bps(SIDE_UP, score).unwrap(); let down = trader_fraction_bps(SIDE_DOWN, score).unwrap(); assert!(up > 0 && down > 0, "score {score} has a one-sided price"); assert_eq!(up as u32 + down as u32, SCORE_DENOM as u32); } } /// A reproducible xorshift, so the randomized checks are deterministic and /// need no dependency. This is the offline stand-in for a fuzzer: it drives /// the same arithmetic the instructions use over a wide input space. fn xorshift(state: &mut u64) -> u64 { let mut x = *state; x ^= x << 13; x ^= x >> 7; x ^= x << 17; *state = x; x } /// Property sweep over the open and settle arithmetic. For any positive /// contribution, valid score, side, and fee within the cap, it must hold /// that: /// - the two legs sum to N exactly (I1); /// - the trader leg never exceeds the contribution (the vault is fully /// funded: N is covered by trader_leg + dealer_leg); /// - trader payout + close fee + dealer payout equals N exactly (I2); /// - nothing panics or overflows. #[test] fn randomized_open_and_settle_conserve_the_notional() { let mut state: u64 = 0x9E3779B97F4A7C15; for _ in 0..50_000 { let contribution = 1 + (xorshift(&mut state) % 100_000_000_000); let score = SCORE_MIN + (xorshift(&mut state) as u16 % (SCORE_MAX - SCORE_MIN + 1)); let side = if xorshift(&mut state) & 1 == 0 { SIDE_UP } else { SIDE_DOWN }; let fee_bps = (xorshift(&mut state) as u16) % (MAX_FEE_BPS + 1); let fraction = trader_fraction_bps(side, score).unwrap(); let max_payout = mul_div(contribution as u128, SCORE_DENOM as u128, fraction as u128).unwrap(); if max_payout == 0 || max_payout > MAX_NOTIONAL { continue; } let trader_leg = mul_div(max_payout as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); let dealer_leg = max_payout.checked_sub(trader_leg).unwrap(); assert_eq!( trader_leg + dealer_leg, max_payout, "I1 failed at contribution={contribution} score={score} side={side}" ); assert!( trader_leg <= contribution, "trader leg exceeds the contribution: not fully funded" ); assert!(trader_leg > 0 && dealer_leg > 0, "a leg is zero"); // Settle at an independent score. let settle = SCORE_MIN + (xorshift(&mut state) as u16 % (SCORE_MAX - SCORE_MIN + 1)); let s_fraction = trader_fraction_bps(side, settle).unwrap(); let gross = mul_div(max_payout as u128, s_fraction as u128, SCORE_DENOM as u128).unwrap(); let dealer_payout = max_payout.checked_sub(gross).unwrap(); let close_fee = fee_on(gross, fee_bps).unwrap(); let trader_payout = gross.checked_sub(close_fee).unwrap(); assert_eq!( trader_payout + close_fee + dealer_payout, max_payout, "I2 failed at N={max_payout} settle={settle} fee={fee_bps}" ); } } /// The fee is never larger than 10% of the dollars it is charged on, at the /// cap, and it rounds down (so it can only ever favor the trader by a /// sub-unit, never the house). #[test] fn fee_never_exceeds_the_cap_and_rounds_down() { let mut state: u64 = 0xDEADBEEFCAFEF00D; for _ in 0..20_000 { let amount = xorshift(&mut state) % 1_000_000_000_000; let fee = fee_on(amount, MAX_FEE_BPS).unwrap(); assert!(fee <= amount / 10 + 1); // f*amount/10000 truncated equals the exact product divided by 10000. assert_eq!(fee, (amount as u128 * MAX_FEE_BPS as u128 / SCORE_DENOM as u128) as u64); } } /// Regression for a rounding hole found by the Sealevel/review pass: a /// one-base-unit contribution at an extreme score gives N = 1 and a trader /// leg of 0 after the recompute, so the position would open with the dealer /// funding the entire notional. `open_position` now requires both legs to be /// positive; this test asserts the arithmetic that made it necessary and /// that the guard condition is real. #[test] fn a_trader_leg_that_rounds_to_zero_is_impossible() { // The smallest contribution at the most extreme score on each side. for (side, score) in [(SIDE_UP, 9_999u16), (SIDE_UP, 9_998), (SIDE_DOWN, 1)] { let fraction = trader_fraction_bps(side, score).unwrap(); let max_payout = mul_div(1u128, SCORE_DENOM as u128, fraction as u128).unwrap(); let trader_leg = mul_div(max_payout as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); // The old code would have opened here; the guard is what stops it. assert_eq!( trader_leg, 0, "side {side} score {score}: the leg rounds to zero, so the guard must reject it" ); } // A contribution one unit larger at the same score has a positive leg, // so the guard only rejects the genuinely degenerate shape. let fraction = trader_fraction_bps(SIDE_UP, 9_999).unwrap(); let max_payout = mul_div(2u128, SCORE_DENOM as u128, fraction as u128).unwrap(); let trader_leg = mul_div(max_payout as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); assert!(trader_leg > 0, "a two-unit contribution funds both legs"); assert!(max_payout - trader_leg > 0); } }