//! Science Markets — quote, per-leg fees, and dealer reserve accounting. //! //! Draft example program. Not deployed to any cluster, and not audited. //! //! One responsibility: price the dealer's side of a position, charge the //! published per-leg fee, and keep the dealer's capital separated into //! committed and free. It deliberately does not build a curve. The published //! rule is one transparent line — entries fill at the panel's score, immediate //! exits fill one point worse for the trader — and it is the only quoting rule //! here. A more sophisticated price is a research question (legal brief §5, //! plan §5), not a thing to guess at in a draft. //! //! The fee basis is the part worth being exact about. A fee is a percentage of //! dollars, per leg: once on the consideration paid to open, and again on the //! gross proceeds received on close. It is never a percentage of the maximum //! payout N. `fee_on` is the one place that computes it, and it is called with //! a dollar amount, never with `max_payout`. //! //! Why the basis matters: charging 2% of N while describing it as 2% of money //! spent is the sleight of hand this prototype exists to avoid. On a position //! entered at 0.40 with N = 100, 2% of the trader's 40 contribution is 0.80, //! while 2% of N would be 2.00. //! //! Reserve accounting: the dealer's token account is owned by this program's //! dealer PDA, so the only way tokens leave is an instruction that signs for //! it. `committed` is the sum of dealer legs on open positions; committed //! capital cannot be withdrawn, which is the on-chain half of the brief's //! "prevent maker withdrawals from touching committed collateral" (§8). use anchor_lang::prelude::*; use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; declare_id!("Rcwwd81uB9YRZZTxEMgaXK5BBL3fmeGXPgsmcL7VmMZ"); /// Same score convention as the core program: basis points in 1..=9999. 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; /// How far inside the endpoints the quote is clamped. A score of exactly 0 or 1 /// leaves no two-sided market, so the quote never reaches an endpoint. pub const SCORE_FLOOR: u16 = 100; // 0.01 pub const SCORE_CEIL: u16 = 9900; // 0.99 /// The trader's half of the published exit rule: an immediate exit fills one /// point worse than the score for the trader. Expressed in basis points so the /// rule is a number, not a sentence. pub const EXIT_SLIP_BPS: u16 = 100; pub const MAX_FEE_BPS: u16 = 1_000; #[program] pub mod science_dealer { use super::*; /// Create the dealer account and its reserve token account. /// /// The reserve token account is owned by the dealer PDA, not by the /// authority. That is the whole safety property: the operator funds it and /// the program moves it, so no single key can walk off with committed /// collateral. pub fn initialize_dealer( ctx: Context, fee_bps: u16, exit_slip_bps: u16, ) -> Result<()> { require!(fee_bps <= MAX_FEE_BPS, DealerError::FeeTooHigh); let dealer = &mut ctx.accounts.dealer; dealer.authority = ctx.accounts.authority.key(); dealer.fee_authority = ctx.accounts.fee_authority.key(); dealer.settlement_mint = ctx.accounts.settlement_mint.key(); dealer.reserve = ctx.accounts.reserve.key(); dealer.reserve_balance = 0; dealer.committed = 0; dealer.fee_bps = fee_bps; dealer.exit_slip_bps = exit_slip_bps; dealer.bump = ctx.bumps.dealer; dealer.reserve_bump = ctx.bumps.reserve; Ok(()) } /// Fund the reserve from the authority's own token account. pub fn deposit(ctx: Context, amount: u64) -> Result<()> { require!(amount > 0, DealerError::ZeroAmount); token::transfer( CpiContext::new( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.authority_token.to_account_info(), to: ctx.accounts.reserve.to_account_info(), authority: ctx.accounts.authority.to_account_info(), }, ), amount, )?; let dealer = &mut ctx.accounts.dealer; dealer.reserve_balance = dealer .reserve_balance .checked_add(amount) .ok_or(DealerError::MathOverflow)?; Ok(()) } /// Withdraw only capital that is not committed to an open position. /// /// This is the check the brief asks for by name (§8). It holds even when /// the authority is the person withdrawing, because the constraint is on the /// accounting, not on who is asking. pub fn withdraw_free(ctx: Context, amount: u64) -> Result<()> { require!(amount > 0, DealerError::ZeroAmount); let dealer = &ctx.accounts.dealer; let free = dealer.free()?; require!(amount <= free, DealerError::CommittedReserve); let reserve_bump = dealer.reserve_bump; let seeds: &[&[u8]] = &[b"dealer_reserve", dealer.authority.as_ref(), &[reserve_bump]]; token::transfer( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.reserve.to_account_info(), to: ctx.accounts.authority_token.to_account_info(), authority: ctx.accounts.reserve.to_account_info(), }, &[seeds], ), amount, )?; let dealer = &mut ctx.accounts.dealer; dealer.reserve_balance = dealer .reserve_balance .checked_sub(amount) .ok_or(DealerError::MathOverflow)?; Ok(()) } /// Record that `amount` of reserve is now backing an open position. /// /// In the two-program deployment this is the seam the core program calls /// when it opens or settles a position, with the market authority signing. /// It is kept as a separate instruction so the accounting can be tested /// without a token transfer, and so a reviewer can see exactly where the /// committed figure moves. pub fn commit_reserve(ctx: Context, amount: u64) -> Result<()> { require!(amount > 0, DealerError::ZeroAmount); let dealer = &mut ctx.accounts.dealer; require!( amount <= dealer.free()?, DealerError::InsufficientFreeReserve ); dealer.committed = dealer .committed .checked_add(amount) .ok_or(DealerError::MathOverflow)?; Ok(()) } /// Release reserve when a position closes. Returns capital to the free pool. pub fn release_reserve(ctx: Context, amount: u64) -> Result<()> { require!(amount > 0, DealerError::ZeroAmount); let dealer = &mut ctx.accounts.dealer; dealer.committed = dealer .committed .checked_sub(amount) .ok_or(DealerError::MathOverflow)?; Ok(()) } /// The entry and immediate-exit quote for one side, at one published score. /// /// This is the transparent rule and nothing more: a new position opens at /// the panel's published score, and an immediate exit is one point worse for /// the trader. Both are clamped inside the endpoints. It is a pure function /// of the inputs, so the same numbers can be reproduced off chain and /// checked against the published market page. pub fn quote( ctx: Context, side: u8, published_score_millis: u16, notional: u64, ) -> Result { require!( side == SIDE_UP || side == SIDE_DOWN, DealerError::BadSide ); require!( valid_score(published_score_millis), DealerError::BadScore ); require!(notional > 0, DealerError::ZeroAmount); let entry = clamp_score(published_score_millis)?; let exit = if side == SIDE_UP { clamp_score(entry.saturating_sub(EXIT_SLIP_BPS))? } else { clamp_score(entry.saturating_add(EXIT_SLIP_BPS))? }; let entry_fraction = trader_fraction_bps(side, entry)?; let exit_fraction = trader_fraction_bps(side, exit)?; // The dealer's leg is the complement of the trader's, computed the same // way the core program computes it: trader leg first, then subtract. // Doing it in one formula instead would round differently by a unit. let trader_leg = mul_div( notional as u128, entry_fraction as u128, SCORE_DENOM as u128, )?; let reserve_required = notional .checked_sub(trader_leg) .ok_or(DealerError::MathOverflow)?; let dealer = &ctx.accounts.dealer; Ok(QuoteOutput { entry_score_millis: entry, exit_score_millis: exit, trader_fraction_bps: entry_fraction, exit_fraction_bps: exit_fraction, reserve_required, free_reserve: dealer.free()?, }) } /// The fees for one position, on both legs, computed from dollar amounts. /// /// `contribution` is what the trader pays to open. `gross_proceeds` is what /// the position is worth at the close score, before the close fee. The /// returned `break_even_score_millis` is where the trader would need to /// close to be flat, which is p*(1+f)/(1-f) from the published terms. pub fn quote_fees( ctx: Context, side: u8, entry_score_millis: u16, contribution: u64, gross_proceeds: u64, ) -> Result { require!( side == SIDE_UP || side == SIDE_DOWN, DealerError::BadSide ); require!(valid_score(entry_score_millis), DealerError::BadScore); let f = ctx.accounts.dealer.fee_bps; let entry_fee = fee_on(contribution, f)?; let close_fee = fee_on(gross_proceeds, f)?; let trader_net = gross_proceeds .checked_sub(close_fee) .ok_or(DealerError::MathOverflow)?; let trader_cost = contribution .checked_add(entry_fee) .ok_or(DealerError::MathOverflow)?; let break_even = break_even_score_millis(entry_score_millis, f)?; Ok(FeeOutput { entry_fee, close_fee, trader_cost, trader_net_on_close: trader_net, fee_bps: f, break_even_score_millis: break_even, }) } } // --------------------------------------------------------------------------- // Accounts // --------------------------------------------------------------------------- /// The dealer's capital and policy, one per dealer. #[account] pub struct Dealer { pub authority: Pubkey, pub fee_authority: Pubkey, pub settlement_mint: Pubkey, /// The reserve token account, owned by this program's dealer PDA. pub reserve: Pubkey, /// Tokens in the reserve account. pub reserve_balance: u64, /// Dealer legs on open positions. Cannot be withdrawn. pub committed: u64, /// Published fee per leg, in basis points of dollars. pub fee_bps: u16, /// The immediate-exit concession in basis points. pub exit_slip_bps: u16, pub bump: u8, pub reserve_bump: u8, } impl Dealer { pub const SPACE: usize = 8 + 32 + 32 + 32 + 32 + 8 + 8 + 2 + 2 + 1 + 1; /// Reserve not backing a position. This is the only withdrawable figure. pub fn free(&self) -> Result { self.reserve_balance .checked_sub(self.committed) .ok_or(error!(DealerError::CommittedReserve)) } } #[derive(Accounts)] pub struct InitializeDealer<'info> { #[account(mut)] pub authority: Signer<'info>, /// CHECK: stored only. The fee destination for the whole dealer. pub fee_authority: UncheckedAccount<'info>, pub settlement_mint: Account<'info, Mint>, #[account( init, payer = authority, space = Dealer::SPACE, seeds = [b"dealer", authority.key().as_ref()], bump )] pub dealer: Account<'info, Dealer>, #[account( init, payer = authority, seeds = [b"dealer_reserve", authority.key().as_ref()], bump, token::mint = settlement_mint, token::authority = dealer )] pub reserve: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct DepositReserve<'info> { pub authority: Signer<'info>, #[account(mut, has_one = authority)] pub dealer: Account<'info, Dealer>, #[account(mut, token::mint = dealer.settlement_mint, token::authority = authority)] pub authority_token: Account<'info, TokenAccount>, #[account(mut, address = dealer.reserve, token::authority = dealer)] pub reserve: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, } #[derive(Accounts)] pub struct WithdrawFree<'info> { pub authority: Signer<'info>, #[account(mut, has_one = authority)] pub dealer: Account<'info, Dealer>, #[account(mut, token::mint = dealer.settlement_mint, token::authority = authority)] pub authority_token: Account<'info, TokenAccount>, #[account(mut, address = dealer.reserve, token::authority = dealer)] pub reserve: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, } /// Accounts for the committed-reserve updates. In the deployed shape the market /// program signs here by CPI; in this draft the dealer authority signs, which is /// the same operator. The constraint is on the accounting either way. #[derive(Accounts)] pub struct MarketReserve<'info> { pub authority: Signer<'info>, #[account(mut, has_one = authority)] pub dealer: Account<'info, Dealer>, } /// Read-only context for the quote instructions. #[derive(Accounts)] pub struct Quote<'info> { pub dealer: Account<'info, Dealer>, } // --------------------------------------------------------------------------- // Return data. Anchor serializes these with Borsh and the client decodes them, // which keeps the quote arithmetic in one place instead of two. // --------------------------------------------------------------------------- #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] pub struct QuoteOutput { pub entry_score_millis: u16, pub exit_score_millis: u16, pub trader_fraction_bps: u16, pub exit_fraction_bps: u16, pub reserve_required: u64, pub free_reserve: u64, } #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] pub struct FeeOutput { pub entry_fee: u64, pub close_fee: u64, pub trader_cost: u64, pub trader_net_on_close: u64, pub fee_bps: u16, pub break_even_score_millis: u16, } // --------------------------------------------------------------------------- // Pure math, mirrored from the core program. Two small copies of four functions // beat a shared crate that adds a dependency and a version to keep in step. // --------------------------------------------------------------------------- pub fn valid_score(bps: u16) -> bool { (SCORE_MIN..=SCORE_MAX).contains(&bps) } /// Keep a quote strictly inside the two-sided range. pub fn clamp_score(score_millis: u16) -> Result { Ok(score_millis.clamp(SCORE_FLOOR, SCORE_CEIL)) } /// The trader's share of N at a score, in basis points. UP is q, DOWN is 1 - q. 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!(DealerError::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(DealerError::MathOverflow)?; let quotient = product.checked_div(c).ok_or(DealerError::MathOverflow)?; Ok(u64::try_from(quotient).map_err(|_| DealerError::MathOverflow)?) } /// The per-leg fee on a dollar amount: amount * fee_bps / 10000, rounded down. /// /// Every call site passes dollars — a contribution or a gross proceed. Nothing /// passes `max_payout`. That is the published basis in one line of code. pub fn fee_on(amount: u64, fee_bps: u16) -> Result { mul_div(amount as u128, fee_bps as u128, SCORE_DENOM as u128) } /// The score at which a position entered at `entry` is flat, given per-leg fee /// `f`. The identity is p * (1 + f) / (1 - f) from the published terms. /// /// Derivation, from the cash flows: the trader pays c*(1+f) to open and receives /// g*(1-f) to close, where g is the gross value at the close score. Setting them /// equal gives g = c*(1+f)/(1-f). At entry, c = p*N and g = q*N, so the N drops /// out and the break-even score is the same for UP and DOWN. /// /// It is deliberately side-independent and matches `breakeven_score` in the /// Django engine and the published formula on the site. An earlier pass in this /// review briefly made it side-aware for DOWN; that was reverted, because the /// published identity is what the product discloses and what the client must /// reproduce. pub fn break_even_score_millis(entry_score_millis: u16, fee_bps: u16) -> Result { let f = fee_bps as u128; if f >= SCORE_DENOM as u128 { return Ok(SCORE_MAX); } let numerator = (entry_score_millis as u128) .checked_mul((SCORE_DENOM as u128).checked_add(f).ok_or(DealerError::MathOverflow)?) .ok_or(DealerError::MathOverflow)?; let denominator = (SCORE_DENOM as u128) .checked_sub(f) .ok_or(DealerError::MathOverflow)?; let value = numerator .checked_div(denominator) .ok_or(DealerError::MathOverflow)?; Ok(value.min(SCORE_MAX as u128) as u16) } #[error_code] pub enum DealerError { #[msg("Side must be UP (0) or DOWN (1).")] BadSide, #[msg("Score must be in 1..=9999 basis points.")] BadScore, #[msg("Amount must be greater than zero.")] ZeroAmount, #[msg("Fee rate is above the program cap.")] FeeTooHigh, #[msg("That amount is committed to an open position and cannot be withdrawn.")] CommittedReserve, #[msg("Not enough free reserve to commit.")] InsufficientFreeReserve, #[msg("Checked arithmetic overflowed.")] MathOverflow, } // --------------------------------------------------------------------------- // Unit tests. No accounts and no cluster, so `cargo test` runs them with the // base toolchain. The numbers are the ones from the legal brief's illustration // so the code and the prose can be checked against each other by hand. // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn fee_is_a_percentage_of_dollars_not_of_the_payout() { // 2% of the trader's 40-USDC contribution is 0.80, not 2% of N = 2.00. let contribution: u64 = 40_000_000; assert_eq!(fee_on(contribution, 200).unwrap(), 800_000); // The same function, called on the 72-USDC gross at close. assert_eq!(fee_on(72_000_000, 200).unwrap(), 1_440_000); // Called on N it would return 2.00. Nothing in this program does that. assert_eq!(fee_on(100_000_000, 200).unwrap(), 2_000_000); } #[test] fn fee_rounds_down_and_never_overflows() { assert_eq!(fee_on(1, 100).unwrap(), 0); assert_eq!(fee_on(u64::MAX, 0).unwrap(), 0); } #[test] fn break_even_matches_the_published_identity() { // p = 0.50, f = 5% -> 0.50 * 1.05 / 0.95 = 0.5526... assert_eq!(break_even_score_millis(5_000, 500).unwrap(), 5_526); // At 2%, the same entry breaks even at 0.5204. assert_eq!(break_even_score_millis(5_000, 200).unwrap(), 5_204); } #[test] fn quote_clamps_inside_the_two_sided_range() { assert_eq!(clamp_score(0).unwrap(), SCORE_FLOOR); assert_eq!(clamp_score(10_000).unwrap(), SCORE_CEIL); assert_eq!(clamp_score(4_000).unwrap(), 4_000); } #[test] fn exit_is_one_point_worse_for_the_trader() { let entry = clamp_score(4_000).unwrap(); let exit_up = clamp_score(entry - EXIT_SLIP_BPS).unwrap(); let exit_down = clamp_score(entry + EXIT_SLIP_BPS).unwrap(); assert_eq!(exit_up, 3_900); assert_eq!(exit_down, 4_100); // Holds at the floor and the ceiling too. assert_eq!(clamp_score(SCORE_FLOOR - EXIT_SLIP_BPS).unwrap(), SCORE_FLOOR); assert_eq!(clamp_score(SCORE_CEIL + EXIT_SLIP_BPS).unwrap(), SCORE_CEIL); } #[test] fn reserve_required_is_the_complement_of_the_trader_leg() { // N = 100 at p = 0.40 needs 60 of dealer reserve, matching the core. let notional: u64 = 100_000_000; let fraction = trader_fraction_bps(SIDE_UP, 4_000).unwrap(); let trader_leg = mul_div(notional as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); assert_eq!(notional - trader_leg, 60_000_000); } #[test] fn free_reserve_is_balance_minus_committed() { let dealer = Dealer { authority: Pubkey::default(), fee_authority: Pubkey::default(), settlement_mint: Pubkey::default(), reserve: Pubkey::default(), reserve_balance: 1_000_000_000, committed: 400_000_000, fee_bps: 200, exit_slip_bps: 100, bump: 0, reserve_bump: 0, }; assert_eq!(dealer.free().unwrap(), 600_000_000); let over_committed = Dealer { committed: 1_200_000_000, ..dealer }; assert!(over_committed.free().is_err(), "committed cannot exceed the balance"); } #[test] fn quote_legs_sum_to_the_notional() { // The quote must reconcile the way the core program does: the trader leg // plus the reserve requirement equals the notional exactly, and neither // side is zero at a clamped score. for score in [SCORE_FLOOR, 4_000, 5_000, 7_200, SCORE_CEIL] { for side in [SIDE_UP, SIDE_DOWN] { let fraction = trader_fraction_bps(side, score).unwrap(); let notional: u64 = 100_000_000; let trader_leg = mul_div(notional as u128, fraction as u128, SCORE_DENOM as u128).unwrap(); let reserve_required = notional - trader_leg; assert_eq!(trader_leg + reserve_required, notional); assert!(trader_leg > 0 && reserve_required > 0, "a leg is zero at score {score}"); } } } /// The exact-match principle, checked over a wide random sample: the quote /// is clamped inside the two-sided range and is the complement of the /// trader's leg. A quote that reached an endpoint would hand the whole pot /// to one side, and the program must never produce one. #[test] fn quote_stays_inside_the_two_sided_range() { let mut state: u64 = 0x1234_5678_9ABC_DEF0; let mut next = || { state ^= state << 13; state ^= state >> 7; state ^= state << 17; state }; for _ in 0..20_000 { let raw = (next() as u16) % 10_000; // includes the excluded endpoints let entry = clamp_score(raw).unwrap(); assert!((SCORE_FLOOR..=SCORE_CEIL).contains(&entry)); for side in [SIDE_UP, SIDE_DOWN] { let exit = if side == SIDE_UP { clamp_score(entry.saturating_sub(EXIT_SLIP_BPS)).unwrap() } else { clamp_score(entry.saturating_add(EXIT_SLIP_BPS)).unwrap() }; assert!((SCORE_FLOOR..=SCORE_CEIL).contains(&exit)); // "One point worse for the trader": UP exits lower, DOWN higher. if side == SIDE_UP { assert!(exit <= entry); } else { assert!(exit >= entry); } } } } }