Contract transparency
Read the rules, inspect the code, and check what still depends on people and software outside Solana.
Source available here. Production deployment unverified.
This site lists 16 paused markets and 0 archived markets. No terminal on-chain market bindings are registered in this site's database. A pool link, program address or successful local test does not establish that trading is live.
No independently audited deployment, verified build, production evidence-adapter service or production upgrade-authority check is established by this page. Do not treat the whole system as trustless.
Deployment and interface records
The program address in the source and Anchor configuration is a localnet build identifier, not a verified mainnet deployment. No current binary-program IDL is published here; legacy interface files are not substitutes. A public source-host link is not configured. The downloads below are site-served presentation copies, not proof that a public repository exists or that deployed bytes match them.
Deployed program, market accounts, YES/NO mints, collateral mint and reserve addresses: not verified or published for an active terminal binding. No explorer link is invented.
The YES/NO contract, in plain language
These are rules in the current science_binary source. They describe what the code is intended to enforce when deployed, not a claim about funds held on a live network.
- Issue a pair. Deposit 1 unit of the configured collateral token for one YES and one NO, plus a separate mint fee. The program requires six-decimal collateral; it does not by itself prove the mint is genuine USDC. Check the exact collateral mint before funding.
- Keep the backing separate. Each market has a reserve vault. Money operations check reserve coverage against outstanding claims. The program has no reserve-withdrawal instruction and does not use the reserve as pool liquidity.
- Merge a pair. Before a final payout is fixed, burn equal amounts of YES and NO to release their backing, less the redemption fee. Buying the missing side may cost more than merging returns.
- Resolve, then redeem. After a final YES or NO outcome, burn the winning token to receive 1 collateral unit gross, less the redemption fee. Losing tokens have no payout. Resolution does not send an automatic payment.
- Fees are fixed per market. Mint and redemption rates are stored at creation, in basis points. Integer rounding applies. Swap fees, network fees and price impact are separate. No rate shown in an illustration is a live fee quote.
Reserve math and frozen terms
Before resolution, the vault must cover the larger outstanding YES or NO supply; after final resolution, the winning supply. Cancellation uses the frozen cancellation payout rule. Direct SPL burns can reduce outstanding supply; unexpected increases are rejected. These checks concern token units, not dollar value: depegs, issuer freezes and program bugs remain risks.
Question, criteria and methodology commitments, fee rates and the cancellation rule are written at creation. Terminal policies must be configured before issuance. A hash only identifies supplied bytes; it cannot prove the wording is complete, the evidence is sound or a deployed program cannot be upgraded.
What the chain cannot decide
Solana can check authorized signatures and contract rules. It cannot decide whether a scientific paper is reliable or whether an adapter truthfully reported a model's response.
Scientific evidence and settlement authority
Terminal mode requires matching explicit YES/NO reports from three frozen trusted adapter keys. Signatures bind the market, round and evidence commitments. They do not prove that three independent models ran, that all relevant evidence was collected, or that publication dates are correct. The evidence collector and adapters remain trusted. Missing, invalid, disagreeing or UNDECIDED reports do not settle.
Terminal settlement has no appeal or reopening in the current program. Legacy markets retain their proposal, challenge and finalization process, with designated authorities rather than a permissionless challenge. A stored website outcome is not, by itself, proof of Solana finality. Check each market's Resolution detail for its mode, transaction, finalized slot and linked evidence bundle.
Administrators, upgrades and access
Administrators configure markets, record pool addresses and control issuance activation and pausing. Frozen terms are protections in the current code; a live upgradeable program could change behavior. The production upgrade authority, its keys or multisig, and any upgrade delay have not been verified here. Local tests configured as non-upgradeable do not establish production immutability.
Website eligibility, jurisdiction and sanctions checks govern this interface. The current binary program does not restrict who can hold or transfer its ordinary SPL outcome tokens. A website restriction does not globally block direct chain transfers or outside pools.
External pools and liquidity
Raydium pools are separate from the collateral vault. The program records pool addresses but does not create liquidity or establish that a pool is authentic. A recorded spot price is not an executable quote. Selling depends on available liquidity, swap fees and slippage; an exit at your preferred price is not guaranteed.
Inspect the current source
These files are read from disk on each request and escaped as text, not executed by this page. Internal locations and private publishing references are withheld. SHA-256 identifies the downloadable presentation bytes only; it is not an audit, a signed release or a match to an on-chain executable. A last commit may predate uncommitted edits.
science_binary — current YES/NO program
Issuance, reserve checks, fees, settlement and redemption. Source is not proof of a deployment.
2460 lines · SHA-256 c4cf5afd2c5e1cae197d8c301a5a3eb0203136420dc99838ddf49e9d6619015f
Last recorded commit: unknown, version metadata unavailable
Download raw presentation copy
//! 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<CreateMarket>,
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<AdminOnly>,
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<ConfigureTerminalV2>,
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<TerminalConsensusV2>,
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<TerminalConsensus>,
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<CreateReserve>) -> 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<AdminOnly>, 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<AdminOnly>) -> 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<AdminOnly>, 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<AdminOnly>, 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<AdminOnly>,
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<SweepFees>) -> 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<MintPair>, 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<RedeemPair>, 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<RedeemWinning>, 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<RedeemCancelled>) -> 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<Propose>,
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<AdjudicatorOnly>, 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<ChallengerOnly>, 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<Finalize>, 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<AdminOnly>, 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<'info, Market>>,
#[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<'info, Market>>,
#[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<Account<'info, Market>>,
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<u64> {
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<u8>> {
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<u8>]) -> 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<Account<..>>` 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<u8> {
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<TokenAccount>) -> 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<Account<'info, Market>>,
/// 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<'info, Mint>>,
#[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<'info, Mint>>,
#[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<Account<'info, Mint>>,
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<Account<'info, Market>>,
pub usdc_mint: Box<Account<'info, Mint>>,
#[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<'info, Market>>,
#[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<Account<'info, Market>>,
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<Account<'info, Market>>,
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<Account<'info, Market>>,
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<Account<'info, Market>>,
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<'info, Market>>,
#[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<Account<'info, TokenAccount>>,
/// 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<Account<'info, TokenAccount>>,
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<'info, Market>>,
#[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<'info, Mint>>,
#[account(mut, seeds = [b"no_mint", market.key().as_ref()], bump = market.no_mint_bump)]
pub no_mint: Box<Account<'info, Mint>>,
#[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)]
pub vault: Box<Account<'info, TokenAccount>>,
#[account(mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump)]
pub fee_account: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.usdc_mint, token::authority = owner)]
pub owner_usdc: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.yes_mint, token::authority = owner)]
pub owner_yes: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.no_mint, token::authority = owner)]
pub owner_no: Box<Account<'info, TokenAccount>>,
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<'info, Market>>,
#[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<'info, Mint>>,
#[account(mut, seeds = [b"no_mint", market.key().as_ref()], bump = market.no_mint_bump)]
pub no_mint: Box<Account<'info, Mint>>,
#[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)]
pub vault: Box<Account<'info, TokenAccount>>,
#[account(mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump)]
pub fee_account: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.usdc_mint, token::authority = owner)]
pub owner_usdc: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.yes_mint, token::authority = owner)]
pub owner_yes: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.no_mint, token::authority = owner)]
pub owner_no: Box<Account<'info, TokenAccount>>,
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<'info, Market>>,
#[account(mut)]
pub owner: Signer<'info>,
#[account(mut)]
pub claim_mint: Box<Account<'info, Mint>>,
#[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)]
pub vault: Box<Account<'info, TokenAccount>>,
#[account(mut, seeds = [b"fee_account", market.key().as_ref()], bump = market.fee_account_bump)]
pub fee_account: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.usdc_mint, token::authority = owner)]
pub owner_usdc: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = claim_mint, token::authority = owner)]
pub owner_claim: Box<Account<'info, TokenAccount>>,
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<'info, Market>>,
#[account(mut)]
pub owner: Signer<'info>,
#[account(mut)]
pub claim_mint: Box<Account<'info, Mint>>,
#[account(mut, seeds = [b"vault", market.key().as_ref()], bump = market.vault_bump)]
pub vault: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = market.usdc_mint, token::authority = owner)]
pub owner_usdc: Box<Account<'info, TokenAccount>>,
#[account(mut, token::mint = claim_mint, token::authority = owner)]
pub owner_claim: Box<Account<'info, TokenAccount>>,
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);
}
}
science_binary_core — arithmetic and state rules
Shared integer arithmetic and lifecycle rules used by the binary program.
686 lines · SHA-256 d41ef00fb91c59e29460aacdc7be886dbd8571ac5573a4c4e711c40caeba08cd
Last recorded commit: unknown, version metadata unavailable
Download raw presentation copy
//! Pure arithmetic and the resolution state machine for the `science_binary`
//! Anchor program: fully collateralized USDC YES/NO binary-outcome markets.
//!
//! Nothing here touches Solana types. That is the point. The same functions the
//! program calls are exercised by three different tools, so a result from any of
//! them is about shipped code rather than a re-implementation:
//!
//! * `cargo test` runs the unit and randomized property tests;
//! * `cargo +nightly miri test` runs those tests under the interpreter, which
//! catches undefined behaviour;
//! * `cargo kani` proves bounded properties of the money paths
//! (see `src/kani_proofs.rs`).
//!
//! Money is `u64` base units of a token that the market mints one-for-one
//! against USDC. `BPS_DENOM` is the basis-point denominator: a rate of `n` basis
//! points is `n / 10_000`.
//!
//! Rounding is always down, and that is the safe direction. A fee rounded down
//! is never larger than the exact fee, so a mint overcharges nobody by more than
//! a base unit; a payout rounded down never exceeds the collateral behind it, so
//! the reserve cannot be drained by a stream of dust redemptions.
//!
//! The economics, from `[internal location withheld]` section 1:
//!
//! mint N pairs user pays N + fee(N*m) exactly N enters the reserve
//! merge N pairs user gets N - fee(N*r) exactly N leaves the reserve
//! redeem N winning user gets N - fee(N*r) exactly N leaves the reserve
//! losing token user gets 0, no fee
//!
//! The reserve invariant is checked in both directions:
//!
//! pre-resolution vault >= max(yes_supply, no_supply)
//! post-resolution vault >= winning_supply
//! cancelled vault >= payout(yes_supply) + payout(no_supply)
#![no_std]
#[cfg(test)]
extern crate std;
#[cfg(kani)]
mod kani_proofs;
/// Property-based model tests. Compiled only under `cargo test`, so the
/// `proptest` dependency is a dev-dependency and never enters the deployed
/// artifact.
#[cfg(test)]
mod proptest_model;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Basis-point denominator. A rate of `n` bps is `n / 10_000`.
pub const BPS_DENOM: u64 = 10_000;
/// Program cap on the mint and redemption fee rates: 1_000 bps, i.e. 10%. A cap
/// is not a policy rate. It exists so a mistyped bps value cannot turn a fee into
/// a confiscation. The published rates are a separate decision, fixed per market
/// at creation.
pub const FEE_BPS_CAP: u16 = 1_000;
/// Program cap on the cancellation payout: 5_000 bps, i.e. 0.5 USDC per token.
/// At the cap a complete pair redeems for exactly 1 USDC, so cancellation
/// preserves the complete set. Anything at or below the cap stays solvent, which
/// is why the cap is written as `2 * bps <= BPS_DENOM` in the check.
pub const CANCEL_BPS_CAP: u16 = 5_000;
/// Accepted challenge-window bounds, in seconds. One second is the floor so a
/// test can exercise the timelock; the product default proposed in the policy is
/// 72 hours, and nothing in the program treats the window as a legal safe
/// harbour. Thirty days is a ceiling so a market cannot be parked in PROPOSED by
/// an absurd parameter.
pub const MIN_CHALLENGE_WINDOW: i64 = 1;
pub const MAX_CHALLENGE_WINDOW: i64 = 30 * 24 * 60 * 60;
// ---------------------------------------------------------------------------
// Outcome encoding
// ---------------------------------------------------------------------------
/// No outcome recorded. Distinct from YES so a zeroed account is never mistaken
/// for a resolved market.
pub const OUTCOME_NONE: u8 = 0;
pub const OUTCOME_YES: u8 = 1;
pub const OUTCOME_NO: u8 = 2;
pub fn outcome_valid(outcome: u8) -> bool {
outcome == OUTCOME_YES || outcome == OUTCOME_NO
}
// ---------------------------------------------------------------------------
// Resolution states. The encoding matches `[internal location withheld]`.
//
// OPEN -> PROPOSED -> CHALLENGE -> DISPUTED -> FINAL, plus CANCELLED.
//
// CHALLENGE is carried and enforced but nothing in the program enters it, and
// nothing in `[internal location withheld]` enters it either: a proposal lands in
// PROPOSED with the clock already running, and a challenge goes straight to
// DISPUTED. The state is kept so the two implementations use the same alphabet
// and so a later instruction can use it without a migration. This is recorded
// as a discrepancy in SECURITY.md rather than quietly dropped.
// ---------------------------------------------------------------------------
pub const STATE_OPEN: u8 = 0;
pub const STATE_PROPOSED: u8 = 1;
pub const STATE_CHALLENGE: u8 = 2;
pub const STATE_DISPUTED: u8 = 3;
pub const STATE_FINAL: u8 = 4;
pub const STATE_CANCELLED: u8 = 5;
pub const STATE_COUNT: u8 = 6;
pub fn state_valid(state: u8) -> bool {
state < STATE_COUNT
}
/// True once the market is irreversibly finished, one way or the other. Neither
/// state admits an outgoing transition, which is what makes FINAL mean FINAL.
pub fn is_resolved(state: u8) -> bool {
state == STATE_FINAL || state == STATE_CANCELLED
}
/// True while claims can still be merged. Merging is an exit, so it stays
/// available in every state where a payout has not already been fixed.
pub fn is_pre_resolution(state: u8) -> bool {
state_valid(state) && !is_resolved(state)
}
/// The transition map. This is the same table as `ALLOWED_TRANSITIONS` in
/// `[internal location withheld]`, transcribed one row at a time. The test
/// `transition_map_matches_the_off_chain_table` asserts the two agree on all 36
/// ordered pairs, and the Kani harness `transition_map_is_exact` proves the same
/// table exhaustively over the whole state space.
pub fn transition_allowed(from: u8, to: u8) -> bool {
if !state_valid(from) || !state_valid(to) {
return false;
}
match from {
STATE_OPEN => matches!(to, STATE_PROPOSED | STATE_CANCELLED),
STATE_PROPOSED => matches!(
to,
STATE_CHALLENGE | STATE_FINAL | STATE_OPEN | STATE_DISPUTED | STATE_CANCELLED
),
STATE_CHALLENGE => matches!(
to,
STATE_FINAL | STATE_DISPUTED | STATE_OPEN | STATE_CANCELLED
),
STATE_DISPUTED => matches!(to, STATE_FINAL | STATE_OPEN | STATE_CANCELLED),
STATE_FINAL | STATE_CANCELLED => false,
_ => false,
}
}
/// The challenge window, as a stored timestamp. A zero timestamp is "no window
/// on record" and is never treated as elapsed, so a market that somehow reaches
/// FINAL-eligibility without a proposal cannot be finalized by a clock of zero.
pub fn challenge_window_elapsed(now: i64, challenge_until: i64) -> bool {
challenge_until > 0 && now >= challenge_until
}
pub fn challenge_window_open(now: i64, challenge_until: i64) -> bool {
challenge_until > 0 && now < challenge_until
}
// ---------------------------------------------------------------------------
// Checked arithmetic
// ---------------------------------------------------------------------------
/// `(a * b) / c`, floored, refusing a zero divisor and refusing a result that
/// does not fit back into a `u64`. The intermediate is `u128`, so no `u64`
/// product can truncate, but the conversion back is checked because a large
/// `a * b / c` can still exceed `u64::MAX` when `c` is small.
pub fn mul_div(a: u64, b: u64, c: u64) -> Option<u64> {
if c == 0 {
return None;
}
let product = (a as u128).checked_mul(b as u128)?;
let quotient = product / (c as u128);
u64::try_from(quotient).ok()
}
/// The fee on an amount of dollars: `floor(amount * bps / 10_000)`.
///
/// This is the only fee function. It is always called on dollars — a pair count
/// at mint, a gross payout at merge or redemption — never on a notional or on a
/// maximum payout. Charging on anything else would need a new line, not a flag.
pub fn fee_on(amount: u64, bps: u16) -> Option<u64> {
mul_div(amount, bps as u64, BPS_DENOM)
}
/// What a mint of `pairs` complete pairs costs, fee included.
///
/// `gross = pairs + fee(pairs*m)`. Exactly `pairs` of that is collateral and
/// goes into the reserve; `fee` goes to the disclosed fee account. `gross` is
/// therefore always at least `pairs`, which is the property that keeps a mint
/// from under-funding the pair it issues.
pub fn mint_gross(pairs: u64, mint_fee_bps: u16) -> Option<u64> {
let fee = fee_on(pairs, mint_fee_bps)?;
pairs.checked_add(fee)
}
/// What a merge or a winning redemption of `gross` tokens returns.
///
/// Returns `(fee, net)` with `fee + net == gross` and `fee <= gross`. The fee is
/// charged on the gross that the reserve actually releases, so the reserve is
/// drawn down by exactly `gross` and never by more.
pub fn redeem_net(gross: u64, redeem_fee_bps: u16) -> Option<(u64, u64)> {
let fee = fee_on(gross, redeem_fee_bps)?;
let net = gross.checked_sub(fee)?;
Some((fee, net))
}
/// What one token pays under the cancellation rule: `floor(amount * bps / 10_000)`.
/// The same rate applies to both sides, so the rule is one number, fixed at
/// creation, and the complete-set property falls out of `2 * bps <= 10_000`.
pub fn cancel_payout(amount: u64, cancel_payout_bps: u16) -> Option<u64> {
mul_div(amount, cancel_payout_bps as u64, BPS_DENOM)
}
/// True when `cancel_payout_bps` keeps a complete set inside the ceiling: at or
/// below 0.5 per token, so a pair pays at most 1 USDC in total.
pub fn cancel_rule_preserves_the_complete_set(cancel_payout_bps: u16) -> bool {
(cancel_payout_bps as u64) * 2 <= BPS_DENOM
}
// ---------------------------------------------------------------------------
// Solvency predicates. These are the invariant, written once so the program and
// every test read the same definition.
// ---------------------------------------------------------------------------
pub fn max_u64(a: u64, b: u64) -> u64 {
if a > b {
a
} else {
b
}
}
/// Pre-resolution: the reserve covers the larger of the two outstanding token
/// supplies. Paired issuance and merging normally keep the two equal; the
/// conservative form covers the unequal case, which is what the policy requires.
pub fn pre_resolution_covered(vault: u64, yes_supply: u64, no_supply: u64) -> bool {
vault >= max_u64(yes_supply, no_supply)
}
/// Post-resolution: the reserve covers every unredeemed winning claim. The
/// losing side cannot redeem, so it is not counted.
pub fn post_resolution_covered(vault: u64, winning_supply: u64) -> bool {
vault >= winning_supply
}
/// Cancelled: the reserve covers the sum of what the two sides may still
/// redeem under the cancellation rule, both rounded down per payout.
pub fn cancellation_covered(
vault: u64,
yes_supply: u64,
no_supply: u64,
cancel_payout_bps: u16,
) -> bool {
let yes = match cancel_payout(yes_supply, cancel_payout_bps) {
Some(v) => v,
None => return false,
};
let no = match cancel_payout(no_supply, cancel_payout_bps) {
Some(v) => v,
None => return false,
};
match yes.checked_add(no) {
Some(total) => vault >= total,
None => false,
}
}
// ---------------------------------------------------------------------------
// Tests
//
// No accounts, no cluster, no dependencies. `cargo test` runs them on the base
// toolchain and `cargo +nightly miri test` runs the same set under the
// interpreter.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// 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 functions the instructions call over a wide input space. The
/// coverage-guided version of this is the Trident harness against the real
/// `.so`.
fn xorshift(state: &mut u64) -> u64 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
x
}
// -- fee arithmetic ----------------------------------------------------
#[test]
fn fee_is_floor_division_of_the_dollars_it_is_charged_on() {
// The published illustration: 100 USDC at 2% is exactly 2.00 USDC.
assert_eq!(fee_on(100_000_000, 200).unwrap(), 2_000_000);
// And it rounds down, never up.
assert_eq!(fee_on(1, 200).unwrap(), 0);
assert_eq!(fee_on(49, 200).unwrap(), 0);
assert_eq!(fee_on(50, 200).unwrap(), 1);
assert_eq!(fee_on(10_000, 1).unwrap(), 1);
assert_eq!(fee_on(9_999, 1).unwrap(), 0);
}
#[test]
fn a_zero_rate_charges_nothing() {
for amount in [0u64, 1, 1_000_000, u64::MAX] {
assert_eq!(fee_on(amount, 0).unwrap(), 0);
}
}
#[test]
fn fee_never_exceeds_the_amount_at_the_cap() {
let mut state = 0xDEAD_BEEF_CAFE_F00Du64;
for _ in 0..200_000 {
let amount = xorshift(&mut state);
let fee = fee_on(amount, FEE_BPS_CAP).unwrap();
assert!(
fee <= amount,
"a capped fee took more than the amount: {fee} of {amount}"
);
// And it is exactly the truncated product.
assert_eq!(
fee as u128,
(amount as u128 * FEE_BPS_CAP as u128) / BPS_DENOM as u128
);
}
}
#[test]
fn mul_div_refuses_a_zero_divisor() {
assert_eq!(mul_div(1, 1, 0), None);
}
// -- checked math at the u64 limits ------------------------------------
#[test]
fn overflow_and_underflow_are_refused_at_the_u64_limits() {
// The product fits in the u128 intermediate, so a full-range amount at a
// rate of exactly the denominator comes back unchanged rather than
// overflowing.
assert_eq!(fee_on(u64::MAX, BPS_DENOM as u16), Some(u64::MAX));
assert_eq!(mul_div(u64::MAX, 1, 1), Some(u64::MAX));
// (2^64-1) * 2 / 1 cannot fit back into a u64.
assert_eq!(mul_div(u64::MAX, 2, 1), None);
// A merge of everything at a zero fee returns everything.
assert_eq!(redeem_net(u64::MAX, 0).unwrap(), (0, u64::MAX));
// A merge whose fee cannot be represented is refused rather than wrapping.
assert!(redeem_net(u64::MAX, FEE_BPS_CAP).is_some());
// The mint gross of the largest pair count overflows and is refused.
assert_eq!(mint_gross(u64::MAX, 1), None);
assert_eq!(mint_gross(u64::MAX, 0), Some(u64::MAX));
// Cancellation of the largest supply at a bps rate stays inside u64.
assert_eq!(
cancel_payout(u64::MAX, CANCEL_BPS_CAP).unwrap(),
u64::MAX / 2
);
}
// -- mint / merge conservation -----------------------------------------
#[test]
fn a_mint_pays_the_pair_plus_the_fee_and_the_reserve_gets_exactly_the_pair() {
let pairs = 166_666_667u64; // the seeding example's N at p = 0.70, rounded down
let gross = mint_gross(pairs, 200).unwrap();
let fee = gross - pairs;
assert_eq!(fee, fee_on(pairs, 200).unwrap());
assert_eq!(pairs + fee, gross);
// The reserve receives the pair count and nothing less.
assert!(gross >= pairs);
}
#[test]
fn a_merge_returns_the_gross_less_the_fee_and_the_reserve_releases_exactly_the_gross() {
for gross in [1u64, 2, 999, 1_000_000, 166_666_667, u64::MAX / 2] {
let (fee, net) = redeem_net(gross, 200).unwrap();
assert_eq!(fee + net, gross, "the split must consume the gross exactly");
assert!(net <= gross);
assert!(fee <= gross);
}
}
#[test]
fn merge_and_redeem_charge_the_same_rate() {
// The policy applies `r` to pre-resolution merges and post-resolution
// winning redemption alike. One function serves both, so this is a
// restatement rather than a second implementation, but it pins intent.
let amount = 12_345_678u64;
assert_eq!(redeem_net(amount, 350), redeem_net(amount, 350));
assert_eq!(redeem_net(amount, 350).unwrap().0, 432_098);
}
// -- solvency at the boundaries ----------------------------------------
#[test]
fn the_pre_resolution_invariant_is_two_sided_after_a_mint() {
// Start from an empty market, mint, and check the reserve covers both
// supplies. Then mint again.
let (mut vault, mut yes, mut no) = (0u64, 0u64, 0u64);
assert!(pre_resolution_covered(vault, yes, no));
for amount in [1u64, 1_000_000, 166_666_667] {
vault += amount;
yes += amount;
no += amount;
assert!(pre_resolution_covered(vault, yes, no));
}
}
#[test]
fn the_invariant_catches_a_one_sided_supply_and_a_drained_vault() {
// A market whose YES supply is larger than the reserve is not covered.
assert!(!pre_resolution_covered(100, 101, 100));
assert!(pre_resolution_covered(100, 100, 100));
assert!(pre_resolution_covered(100, 100, 99));
// Post-resolution only the winning side is counted.
assert!(post_resolution_covered(50, 50));
assert!(!post_resolution_covered(49, 50));
// An empty market is trivially covered, and a vault of zero with no
// claims is covered too.
assert!(pre_resolution_covered(0, 0, 0));
assert!(post_resolution_covered(0, 0));
}
#[test]
fn the_cancellation_rule_preserves_the_complete_set_at_the_cap() {
assert!(cancel_rule_preserves_the_complete_set(CANCEL_BPS_CAP));
assert!(cancel_rule_preserves_the_complete_set(0));
assert!(!cancel_rule_preserves_the_complete_set(CANCEL_BPS_CAP + 1));
// At the cap a pair of equal supplies pays out at most the reserve.
for supply in [0u64, 1, 7, 1_000_000, u64::MAX / 2] {
let paid = 2 * cancel_payout(supply, CANCEL_BPS_CAP).unwrap();
assert!(paid <= supply, "cancellation overpaid: {paid} of {supply}");
}
}
#[test]
fn cancellation_coverage_is_exact_at_the_boundary() {
// Two equal supplies at the cap need exactly the reserve they have.
assert!(cancellation_covered(1_000, 1_000, 1_000, CANCEL_BPS_CAP));
assert!(!cancellation_covered(999, 1_000, 1_000, CANCEL_BPS_CAP));
// Odd supplies round down, so the last unit is not owed.
assert!(cancellation_covered(1, 1, 1, CANCEL_BPS_CAP));
assert!(cancellation_covered(0, 1, 1, CANCEL_BPS_CAP));
// A lower rate needs less.
assert!(cancellation_covered(1, 1_000, 1_000, 1));
}
// -- randomized property sweeps ----------------------------------------
/// Random sequences of mint and merge. The invariant must hold after every
/// step, from an empty market. This is the offline half of the Trident run:
/// it drives the same arithmetic over 20_000 sequences of 64 operations.
#[test]
fn random_mint_and_merge_sequences_keep_the_reserve_covering_both_supplies() {
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut checked = 0u64;
for _ in 0..20_000 {
let (mut vault, mut yes, mut no) = (0u64, 0u64, 0u64);
assert!(pre_resolution_covered(vault, yes, no));
for _ in 0..64 {
let amount = 1 + xorshift(&mut state) % 1_000_000_000;
if xorshift(&mut state) & 1 == 0 {
let fee = fee_on(amount, FEE_BPS_CAP).unwrap();
let gross = mint_gross(amount, FEE_BPS_CAP).unwrap();
// The user pays the gross; the reserve receives `amount`.
assert_eq!(gross, amount + fee);
vault += amount;
yes += amount;
no += amount;
} else if yes >= amount && no >= amount && vault >= amount {
let (_fee, _net) = redeem_net(amount, FEE_BPS_CAP).unwrap();
vault -= amount;
yes -= amount;
no -= amount;
}
assert!(
pre_resolution_covered(vault, yes, no),
"invariant broke: vault={vault} yes={yes} no={no}"
);
checked += 1;
}
}
assert_eq!(checked, 1_280_000);
}
/// Random winning redemptions after FINAL. The supply and the vault must
/// fall together, so coverage is preserved by construction and the check
/// never fires.
#[test]
fn random_winning_redemptions_never_overdraw_the_reserve() {
let mut state = 0x1234_5678_9ABC_DEF0u64;
for _ in 0..20_000 {
let supply = 1 + xorshift(&mut state) % 1_000_000_000;
let (mut vault, mut winning) = (supply, supply);
assert!(post_resolution_covered(vault, winning));
for _ in 0..32 {
let amount = 1 + xorshift(&mut state) % (winning.max(1));
if amount > winning {
continue;
}
let (_fee, net) = redeem_net(amount, FEE_BPS_CAP).unwrap();
assert!(net <= amount);
vault -= amount;
winning -= amount;
assert!(
post_resolution_covered(vault, winning),
"post-resolution coverage broke: vault={vault} winning={winning}"
);
}
}
}
/// Random cancellation payouts. Both sides draw from the same reserve at
/// the same rate, and the total must never exceed it.
#[test]
fn random_cancellation_payouts_never_exceed_the_reserve() {
let mut state = 0x0FED_CBA9_8765_4321u64;
for _ in 0..20_000 {
let supply = xorshift(&mut state) % 1_000_000_000;
let bps = (xorshift(&mut state) % (CANCEL_BPS_CAP as u64 + 1)) as u16;
let (mut vault, mut yes, mut no) = (supply, supply, supply);
assert!(cancellation_covered(vault, yes, no, bps));
let mut paid_total = 0u64;
for _ in 0..64 {
let side = xorshift(&mut state) & 1 == 0;
let available = if side { yes } else { no };
if available == 0 {
continue;
}
let amount = 1 + xorshift(&mut state) % available;
let payout = cancel_payout(amount, bps).unwrap();
if payout > vault {
// The invariant check upstream should have prevented this.
panic!("cancellation payout {payout} exceeded the reserve {vault}");
}
vault -= payout;
paid_total = paid_total.saturating_add(payout);
if side {
yes -= amount;
} else {
no -= amount;
}
assert!(cancellation_covered(vault, yes, no, bps));
}
assert!(paid_total <= supply);
}
}
/// A mint never mints claims the reserve cannot back, at any fee rate up to
/// the cap. This is the "no unbacked claims" property, stated as an identity
/// on the arithmetic rather than as a run over a ledger.
#[test]
fn no_mint_can_create_an_unbacked_claim() {
let mut state = 0xBEEF_0BAD_F00D_1234u64;
for _ in 0..200_000 {
let pairs = xorshift(&mut state) % 1_000_000_000_000;
let bps = (xorshift(&mut state) as u16) % (FEE_BPS_CAP + 1);
let gross = mint_gross(pairs, bps).unwrap();
// The reserve receives `pairs`, which is what the minted claims need.
assert!(gross >= pairs);
// And after the mint the vault holds exactly the pairs it issued.
assert!(pre_resolution_covered(pairs, pairs, pairs));
}
}
// -- the state machine -------------------------------------------------
/// The table from `[internal location withheld]`, transcribed by hand. If the
/// program's map and the off-chain map ever diverge, this test is where it
/// shows up.
const OFF_CHAIN_MAP: [[bool; STATE_COUNT as usize]; STATE_COUNT as usize] = [
// to: OPEN PROPOSED CHALLENGE DISPUTED FINAL CANCELLED
/* OPEN */
[false, true, false, false, false, true],
/* PROPOSED */ [true, false, true, true, true, true],
/* CHALLENGE */ [true, false, false, true, true, true],
/* DISPUTED */ [true, false, false, false, true, true],
/* FINAL */ [false, false, false, false, false, false],
/* CANCELLED */ [false, false, false, false, false, false],
];
#[test]
fn transition_map_matches_the_off_chain_table() {
for from in 0..STATE_COUNT {
for to in 0..STATE_COUNT {
assert_eq!(
transition_allowed(from, to),
OFF_CHAIN_MAP[from as usize][to as usize],
"transition {from} -> {to} disagrees with resolution.py"
);
}
}
}
#[test]
fn final_and_cancelled_are_absorbing() {
for to in 0..STATE_COUNT {
assert!(!transition_allowed(STATE_FINAL, to), "FINAL accepts {to}");
assert!(
!transition_allowed(STATE_CANCELLED, to),
"CANCELLED accepts {to}"
);
}
}
#[test]
fn every_transition_changes_the_state() {
// A settlement instruction that leaves the market in the state it found
// it is not a transition, and the map refuses it. That is what stops a
// re-propose from being recorded as a new proposal.
for state in 0..STATE_COUNT {
assert!(!transition_allowed(state, state));
}
}
#[test]
fn illegal_transitions_are_rejected() {
// The ones the money paths depend on.
assert!(!transition_allowed(STATE_OPEN, STATE_FINAL));
assert!(!transition_allowed(STATE_OPEN, STATE_DISPUTED));
assert!(!transition_allowed(STATE_OPEN, STATE_CHALLENGE));
assert!(!transition_allowed(STATE_FINAL, STATE_OPEN));
assert!(!transition_allowed(STATE_CANCELLED, STATE_OPEN));
assert!(!transition_allowed(STATE_PROPOSED, STATE_PROPOSED));
// An unset or out-of-range state is never a legal source or target.
assert!(!transition_allowed(STATE_COUNT, STATE_OPEN));
assert!(!transition_allowed(255, STATE_OPEN));
assert!(!transition_allowed(STATE_OPEN, 255));
}
#[test]
fn the_legal_paths_are_the_ones_the_process_uses() {
assert!(transition_allowed(STATE_OPEN, STATE_PROPOSED));
assert!(transition_allowed(STATE_PROPOSED, STATE_CHALLENGE));
assert!(transition_allowed(STATE_CHALLENGE, STATE_FINAL));
assert!(transition_allowed(STATE_PROPOSED, STATE_FINAL));
assert!(transition_allowed(STATE_PROPOSED, STATE_OPEN));
assert!(transition_allowed(STATE_PROPOSED, STATE_DISPUTED));
assert!(transition_allowed(STATE_DISPUTED, STATE_FINAL));
assert!(transition_allowed(STATE_DISPUTED, STATE_OPEN));
assert!(transition_allowed(STATE_OPEN, STATE_CANCELLED));
assert!(transition_allowed(STATE_DISPUTED, STATE_CANCELLED));
}
#[test]
fn is_pre_resolution_covers_exactly_the_unfixed_states() {
assert!(is_pre_resolution(STATE_OPEN));
assert!(is_pre_resolution(STATE_PROPOSED));
assert!(is_pre_resolution(STATE_CHALLENGE));
assert!(is_pre_resolution(STATE_DISPUTED));
assert!(!is_pre_resolution(STATE_FINAL));
assert!(!is_pre_resolution(STATE_CANCELLED));
assert!(!is_pre_resolution(200));
assert!(is_resolved(STATE_FINAL));
assert!(is_resolved(STATE_CANCELLED));
assert!(!state_valid(200));
}
#[test]
fn outcomes_are_never_a_zeroed_account() {
assert!(!outcome_valid(OUTCOME_NONE));
assert!(outcome_valid(OUTCOME_YES));
assert!(outcome_valid(OUTCOME_NO));
assert!(!outcome_valid(3));
}
#[test]
fn the_challenge_window_is_only_elapsed_when_one_was_recorded() {
// A zero timestamp is not a window, so it is never elapsed and never open.
assert!(!challenge_window_elapsed(1_000_000, 0));
assert!(!challenge_window_open(1_000_000, 0));
// Before, at, and after the deadline.
assert!(!challenge_window_elapsed(100, 200));
assert!(challenge_window_elapsed(200, 200));
assert!(challenge_window_elapsed(201, 200));
assert!(challenge_window_open(199, 200));
assert!(!challenge_window_open(200, 200));
}
}
Binary program package manifest
Dependencies and build features for the current program.
27 lines · SHA-256 4759da11ac86505bb39deba4da4c04ea329d018325ab6c7b1ba8d825240b12d4
Last recorded commit: unknown, version metadata unavailable
Download raw presentation copy
[package]
name = "science_binary"
version = "0.1.0"
description = "Fully collateralized USDC YES/NO binary-outcome markets on Solana."
edition = "2021"
rust-version = "1.75"
# Private draft: never published to a registry, so it carries no license field.
publish = false
[lib]
crate-type = ["cdylib", "lib"]
name = "science_binary"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
# The arithmetic and the state machine live in a dependency-free crate so Miri
# and Kani can run the same functions this program calls. See SECURITY.md.
science_binary_core = { path = "../../libs/science_binary_core" }
Legacy code — retired simulator architecture
These science_market and science_dealer files are not the current YES/NO token implementation and are not live binary trading. Their inclusion is provenance, not a deployment or audit claim.
science_market — markets, positions, settlement
SHA-256 9f34c3095ccfe51c785d514c67aebf2ddeb7832ab2f7ff47baa177752de28a88
Download raw presentation copy
//! 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<InitializeMarket>,
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<OpenPosition>,
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<SettlePosition>, 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<RestrictOwner>) -> 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<SetPaused>, 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<SetEligibility>,
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<u16> {
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<u64> {
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<u64> {
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);
}
}
science_dealer — quote, fees, dealer reserve
SHA-256 91cb7553f423f25e6ebb3949c4ef98a2db0b16658c4e6fca91ff07024fb8aeba
Download raw presentation copy
//! 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<InitializeDealer>,
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<DepositReserve>, 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<WithdrawFree>, 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<MarketReserve>, 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<MarketReserve>, 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<Quote>,
side: u8,
published_score_millis: u16,
notional: u64,
) -> Result<QuoteOutput> {
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<Quote>,
side: u8,
entry_score_millis: u16,
contribution: u64,
gross_proceeds: u64,
) -> Result<FeeOutput> {
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<u64> {
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<u16> {
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<u16> {
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<u64> {
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<u64> {
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<u16> {
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);
}
}
}
}
}
Legacy integration tests
SHA-256 e132f9b94834af3deae12c582408de37ebe1f2c608fc400691db3548ed7e7e6b
Download raw presentation copy
/**
* Anchor integration tests for the draft Science Markets programs.
*
* Requires a validator and the Anchor CLI (`anchor test`). Two groups:
*
* "science markets draft programs" — the positive path. It asserts the two
* things the design rests on, on the published illustration (a 100-unit UP
* position entered at 0.40 and closed at 0.72):
*
* 1. Conservation: opening locks exactly N; settling splits exactly N into
* trader payout + close fee + dealer payout.
* 2. The fee basis: a fee is a percentage of dollars per leg — the trader's
* contribution and the gross proceeds — never a percentage of N.
*
* "adversarial cases" — the negative path. Each case is an attack or a way to
* use the programs that must be refused. They exist because the first run of
* this suite caught real bugs (a missing authority check on settlement, a
* vault that under-funded the reserve, a break-even score that was wrong for
* DOWN positions, and a dropped mint check on the fee vault), and the seeds
* did not include the side, so two positions could collide.
*/
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ScienceMarket } from "../target/types/science_market";
import { ScienceDealer } from "../target/types/science_dealer";
import {
createMint,
getOrCreateAssociatedTokenAccount,
mintTo,
getAccount,
} from "@solana/spl-token";
import { assert } from "chai";
const USDC = (whole: number) => new anchor.BN(Math.round(whole * 1_000_000));
/** 8-byte little-endian encoding of a u64 (Anchor's integer seed width). */
function u64le(n: anchor.BN): Buffer {
return n.toArrayLike(Buffer, "le", 8);
}
function u16le(n: number): Buffer {
const b = Buffer.alloc(2);
b.writeUInt16LE(n, 0);
return b;
}
/**
* Seeds for a Position PDA, matching `OpenPosition` in science_market. Every
* component is fixed width and `side`/`fee_bps` are included, so two distinct
* opens never map to the same account.
*/
function positionSeeds(
market: anchor.web3.PublicKey,
trader: anchor.web3.PublicKey,
side: number,
entryScore: number,
contribution: anchor.BN,
feeBps: number
): Buffer[] {
return [
Buffer.from("position"),
market.toBuffer(),
trader.toBuffer(),
Buffer.from([side]),
u16le(entryScore),
u16le(feeBps),
u64le(contribution),
];
}
function derivePosition(
programId: anchor.web3.PublicKey,
market: anchor.web3.PublicKey,
trader: anchor.web3.PublicKey,
side: number,
entryScore: number,
contribution: anchor.BN,
feeBps: number
): anchor.web3.PublicKey {
return anchor.web3.PublicKey.findProgramAddressSync(
positionSeeds(market, trader, side, entryScore, contribution, feeBps),
programId
)[0];
}
describe("science markets draft programs", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const marketProgram = anchor.workspace.ScienceMarket as Program<ScienceMarket>;
const dealerProgram = anchor.workspace.ScienceDealer as Program<ScienceDealer>;
const conn = provider.connection;
const authority = (provider.wallet as anchor.Wallet).payer;
const dealerKey = anchor.web3.Keypair.generate();
const MARKET_ID = new anchor.BN(1);
const FEE_BPS = 200; // 2% per leg
const SCORE_40 = 4_000; // 0.40
const SCORE_72 = 7_200; // 0.72
const SIDE_UP = 0;
const SIDE_DOWN = 1;
let mint: anchor.web3.PublicKey;
let authorityToken: anchor.web3.PublicKey;
let feeVault: anchor.web3.PublicKey;
let market: anchor.web3.PublicKey;
let vault: anchor.web3.PublicKey;
let dealerToken: anchor.web3.PublicKey;
let dealerPda: anchor.web3.PublicKey;
let reserve: anchor.web3.PublicKey;
async function fund(kp: anchor.web3.Keypair, sol = 2) {
const sig = await conn.requestAirdrop(kp.publicKey, sol * anchor.web3.LAMPORTS_PER_SOL);
await conn.confirmTransaction(sig);
}
async function ata(owner: anchor.web3.PublicKey): Promise<anchor.web3.PublicKey> {
return (
await getOrCreateAssociatedTokenAccount(conn, authority, mint, owner)
).address;
}
async function tokenAmount(account: anchor.web3.PublicKey): Promise<anchor.BN> {
const { amount } = await getAccount(conn, account);
return new anchor.BN(amount.toString());
}
/**
* A fresh trader with its own token account, funds, and eligibility
* credential. Fresh traders keep position PDAs distinct between tests, which
* is what lets several tests use the same simple parameters.
*/
async function makeTrader(tokens = 1_000): Promise<{
kp: anchor.web3.Keypair;
token: anchor.web3.PublicKey;
eligibility: anchor.web3.PublicKey;
}> {
const kp = anchor.web3.Keypair.generate();
await fund(kp);
const token = await ata(kp.publicKey);
await mintTo(conn, authority, mint, token, authority, USDC(tokens).toNumber());
const [eligibility] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("eligibility"), market.toBuffer(), kp.publicKey.toBuffer()],
marketProgram.programId
);
await marketProgram.methods
.setEligibility(true, new anchor.BN(0))
.accountsPartial({ authority: authority.publicKey, market, trader: kp.publicKey, eligibility })
.rpc();
return { kp, token, eligibility };
}
async function openPosition(params: {
trader: anchor.web3.Keypair;
traderToken: anchor.web3.PublicKey;
eligibility: anchor.web3.PublicKey;
side: number;
score: number;
contribution: anchor.BN;
feeBps?: number;
minContribution?: anchor.BN;
position?: anchor.web3.PublicKey;
marketAccount?: anchor.web3.PublicKey;
vaultAccount?: anchor.web3.PublicKey;
feeVaultAccount?: anchor.web3.PublicKey;
dealerAccount?: anchor.web3.Keypair;
}) {
const feeBps = params.feeBps ?? FEE_BPS;
const minContribution = params.minContribution ?? params.contribution;
const position =
params.position ??
derivePosition(
marketProgram.programId,
params.marketAccount ?? market,
params.trader.publicKey,
params.side,
params.score,
params.contribution,
feeBps
);
const dealerAccount = params.dealerAccount ?? dealerKey;
return marketProgram.methods
.openPosition(params.side, params.score, params.contribution, feeBps, minContribution)
.accountsPartial({
market: params.marketAccount ?? market,
trader: params.trader.publicKey,
position,
dealer: dealerAccount.publicKey,
eligibility: params.eligibility,
traderToken: params.traderToken,
dealerToken: await ata(dealerAccount.publicKey),
vault: params.vaultAccount ?? vault,
feeVault: params.feeVaultAccount ?? feeVault,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
})
.signers([params.trader, dealerAccount])
.rpc();
}
before(async () => {
await fund(dealerKey);
mint = await createMint(conn, authority, authority.publicKey, null, 6);
authorityToken = await ata(authority.publicKey);
feeVault = (
await getOrCreateAssociatedTokenAccount(conn, authority, mint, authority.publicKey, true)
).address;
[market] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("market"), authority.publicKey.toBuffer(), u64le(MARKET_ID)],
marketProgram.programId
);
[vault] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), market.toBuffer()],
marketProgram.programId
);
await marketProgram.methods
.initializeMarket(MARKET_ID, "Draft proposition for the test fixture.", 5_000)
.accountsPartial({ authority: authority.publicKey, market, settlementMint: mint, feeVault, vault })
.rpc();
// Dealer program: one dealer owned by the provider wallet, plus its reserve.
[dealerPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("dealer"), authority.publicKey.toBuffer()],
dealerProgram.programId
);
[reserve] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("dealer_reserve"), authority.publicKey.toBuffer()],
dealerProgram.programId
);
await dealerProgram.methods
.initializeDealer(FEE_BPS, 100)
.accountsPartial({
authority: authority.publicKey,
feeAuthority: authority.publicKey,
settlementMint: mint,
dealer: dealerPda,
reserve,
})
.rpc();
await mintTo(conn, authority, mint, authorityToken, authority, USDC(10_000).toNumber());
await dealerProgram.methods
.deposit(USDC(1_000))
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda, authorityToken, reserve })
.rpc();
dealerToken = await ata(dealerKey.publicKey);
await mintTo(conn, authority, mint, dealerToken, authority, USDC(5_000).toNumber());
});
it("opens a fully funded position and locks exactly N", async () => {
const { kp, token, eligibility } = await makeTrader();
const contribution = USDC(40);
const position = derivePosition(
marketProgram.programId,
market,
kp.publicKey,
SIDE_UP,
SCORE_40,
contribution,
FEE_BPS
);
const vaultBefore = await tokenAmount(vault);
const feeBefore = await tokenAmount(feeVault);
const reserveBefore = (await marketProgram.account.market.fetch(market)).reserveLocked;
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution });
const account = await marketProgram.account.position.fetch(position);
assert.equal(account.maxPayout.toString(), USDC(100).toString(), "N is 100");
assert.equal(account.traderContribution.toString(), USDC(40).toString(), "trader posts 40");
assert.equal(account.dealerContribution.toString(), USDC(60).toString(), "dealer reserves 60");
assert.equal(
account.traderContribution.add(account.dealerContribution).toString(),
account.maxPayout.toString(),
"invariant: the two legs equal N"
);
// The vault holds exactly N more than before. The entry fee is charged on
// dollars (2% of the 40 contribution) and goes to the fee vault, so the
// vault's delta isolates the locked collateral.
const vaultAccount = await tokenAmount(vault);
assert.equal(
vaultAccount.sub(vaultBefore).toString(),
USDC(100).toString(),
"vault gains exactly N"
);
assert.equal(account.entryFee.toString(), USDC(0.8).toString(), "entry fee is 2% of 40");
assert.equal(
(await tokenAmount(feeVault)).sub(feeBefore).toString(),
USDC(0.8).toString(),
"entry fee lands in the fee vault, not the collateral vault"
);
const marketState = await marketProgram.account.market.fetch(market);
assert.equal(
marketState.reserveLocked.sub(reserveBefore).toString(),
USDC(100).toString(),
"reserve_locked rises by N"
);
});
it("settles at a score and pays out exactly N, with fees on dollars", async () => {
const { kp, token, eligibility } = await makeTrader();
const contribution = USDC(40);
const position = derivePosition(
marketProgram.programId,
market,
kp.publicKey,
SIDE_UP,
SCORE_40,
contribution,
FEE_BPS
);
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution });
const traderBefore = await tokenAmount(token);
const dealerBefore = await tokenAmount(dealerToken);
const feeBefore = await tokenAmount(feeVault);
const reserveBefore = (await marketProgram.account.market.fetch(market)).reserveLocked;
await marketProgram.methods
.settlePosition(SCORE_72)
.accountsPartial({
market,
authority: authority.publicKey,
position,
owner: kp.publicKey,
vault,
traderToken: token,
dealerToken,
feeVault,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
})
.rpc();
const account = await marketProgram.account.position.fetch(position);
assert.equal(account.traderGross.toString(), USDC(72).toString(), "gross is 72");
assert.equal(account.closeFee.toString(), USDC(1.44).toString(), "close fee is 2% of 72");
assert.equal(account.traderPayout.toString(), USDC(70.56).toString(), "trader nets 70.56");
assert.equal(account.dealerPayout.toString(), USDC(28).toString(), "dealer receives 28");
assert.equal(
account.traderPayout.add(account.closeFee).add(account.dealerPayout).toString(),
account.maxPayout.toString(),
"invariant: the split consumes N exactly"
);
// Money actually moved, in the right direction.
assert.equal((await tokenAmount(token)).sub(traderBefore).toString(), USDC(70.56).toString());
assert.equal((await tokenAmount(dealerToken)).sub(dealerBefore).toString(), USDC(28).toString());
assert.equal((await tokenAmount(feeVault)).sub(feeBefore).toString(), USDC(1.44).toString());
const marketState = await marketProgram.account.market.fetch(market);
assert.equal(
reserveBefore.sub(marketState.reserveLocked).toString(),
USDC(100).toString(),
"settling releases exactly this position's reserve"
);
});
it("refuses a second settlement of the same position", async () => {
const { kp, token, eligibility } = await makeTrader();
const contribution = USDC(40);
const position = derivePosition(
marketProgram.programId,
market,
kp.publicKey,
SIDE_UP,
SCORE_40,
contribution,
FEE_BPS
);
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution });
const settle = () =>
marketProgram.methods
.settlePosition(SCORE_72)
.accountsPartial({
market,
authority: authority.publicKey,
position,
owner: kp.publicKey,
vault,
traderToken: token,
dealerToken,
feeVault,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
})
.rpc();
await settle(); // first settlement succeeds
let threw = false;
try {
await settle();
} catch (err) {
threw = true;
assert.match(String(err), /PositionNotOpen|already closed/i);
}
assert.isTrue(threw, "a closed position cannot pay out twice");
});
it("dealer: quotes the fee on dollars and reports the break-even score", async () => {
const fees = await dealerProgram.methods
.quoteFees(SIDE_UP, SCORE_40, USDC(40), USDC(72))
.accounts({ dealer: dealerPda })
.view();
assert.equal(fees.entryFee.toString(), USDC(0.8).toString(), "2% of the 40 contribution");
assert.equal(fees.closeFee.toString(), USDC(1.44).toString(), "2% of the 72 gross");
assert.equal(fees.traderNetOnClose.toString(), USDC(70.56).toString(), "trader nets 70.56");
// The published break-even identity is side-independent:
// p * (1 + f) / (1 - f). Both sides report the same score for the same
// entry, which is what the Django engine and the site disclose.
const upFees = await dealerProgram.methods
.quoteFees(SIDE_UP, 5_000, USDC(50), USDC(50))
.accounts({ dealer: dealerPda })
.view();
assert.equal(upFees.breakEvenScoreMillis, 5_204, "p=0.50 at 2% breaks even at 0.5204");
const downFees = await dealerProgram.methods
.quoteFees(SIDE_DOWN, 5_000, USDC(50), USDC(50))
.accounts({ dealer: dealerPda })
.view();
assert.equal(downFees.breakEvenScoreMillis, 5_204, "break-even is side-independent per the published terms");
const quote = await dealerProgram.methods
.quote(SIDE_UP, SCORE_40, USDC(100))
.accounts({ dealer: dealerPda })
.view();
assert.equal(quote.entryScoreMillis, SCORE_40);
// "One point worse for the trader": an UP position exits at a lower score
// (0.39), a DOWN position at a higher one (0.41). This matches the Django
// engine's close_with_quote, which fills a 0.70 quote at 0.69 for UP.
assert.equal(quote.exitScoreMillis, SCORE_40 - 100, "UP exit is one point lower for the trader");
assert.equal(quote.reserveRequired.toString(), USDC(60).toString(), "reserve is the complement");
const downQuote = await dealerProgram.methods
.quote(SIDE_DOWN, SCORE_40, USDC(100))
.accounts({ dealer: dealerPda })
.view();
assert.equal(downQuote.exitScoreMillis, SCORE_40 + 100, "DOWN exit is one point higher for the trader");
});
it("dealer: committed reserve cannot be withdrawn", async () => {
// Deposit is idempotent from the caller's view: reserve already holds 1000
// from the fixture, so top it up to a round figure and commit from there.
const free = USDC(1_000);
await dealerProgram.methods
.commitReserve(free)
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda })
.rpc();
// Now committed == reserve_balance, so nothing is withdrawable.
let threw = false;
try {
await dealerProgram.methods
.withdrawFree(USDC(1))
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda, authorityToken, reserve })
.rpc();
} catch (err) {
threw = true;
assert.match(String(err), /CommittedReserve|committed/i);
}
assert.isTrue(threw, "withdrawing past free reserve is refused");
await dealerProgram.methods
.releaseReserve(free)
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda })
.rpc();
});
it("dealer: withdraw_free cannot route tokens to a third party", async () => {
// Attack: sign `withdraw_free` but pass someone else's token account as
// `authority_token`, to pay yourself out of the dealer reserve. The
// `token::authority = authority` constraint must refuse it (Anchor
// ConstraintTokenOwner, 2015). Confirmed against a real validator.
const free = USDC(1_000);
await dealerProgram.methods
.commitReserve(free)
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda })
.rpc();
const stranger = anchor.web3.Keypair.generate();
const strangerToken = (
await getOrCreateAssociatedTokenAccount(conn, authority, mint, stranger.publicKey)
).address;
let threw = false;
try {
await dealerProgram.methods
.withdrawFree(USDC(500))
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda, authorityToken: strangerToken, reserve })
.rpc();
} catch (err) {
threw = true;
assert.match(String(err), /ConstraintTokenOwner|token owner/i);
}
assert.isTrue(threw, "reserve cannot be routed to a token account the signer does not own");
assert.equal((await tokenAmount(strangerToken)).toString(), "0", "nothing reached the stranger");
await dealerProgram.methods
.releaseReserve(free)
.accountsPartial({ authority: authority.publicKey, dealer: dealerPda })
.rpc();
});
// ------------------------------------------------------------------------
// Adversarial cases. Each must be refused.
// ------------------------------------------------------------------------
describe("adversarial cases", () => {
it("rejects settlement signed by anyone other than the market authority", async () => {
const { kp, token, eligibility } = await makeTrader();
const contribution = USDC(40);
const position = derivePosition(
marketProgram.programId,
market,
kp.publicKey,
SIDE_UP,
SCORE_40,
contribution,
FEE_BPS
);
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution });
const intruder = anchor.web3.Keypair.generate();
await fund(intruder);
let threw = false;
try {
await marketProgram.methods
.settlePosition(SCORE_72)
.accountsPartial({
market,
authority: intruder.publicKey, // not the market authority
position,
owner: kp.publicKey,
vault,
traderToken: token,
dealerToken,
feeVault,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
})
.signers([intruder])
.rpc();
} catch (err) {
threw = true;
assert.match(String(err), /Unauthorized|Authority/i);
}
assert.isTrue(threw, "a stranger cannot settle a position at a score of their choosing");
});
it("rejects spending another market's vault", async () => {
// A second market with its own vault. Settling market #1's position while
// passing market #2's vault must fail the vault seed constraint.
const otherMarketId = new anchor.BN(777);
const [market2] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("market"), authority.publicKey.toBuffer(), u64le(otherMarketId)],
marketProgram.programId
);
const [vault2] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), market2.toBuffer()],
marketProgram.programId
);
const feeVault2 = (
await getOrCreateAssociatedTokenAccount(conn, authority, mint, authority.publicKey, true)
).address;
await marketProgram.methods
.initializeMarket(otherMarketId, "A second market.", 5_000)
.accountsPartial({ authority: authority.publicKey, market: market2, settlementMint: mint, feeVault: feeVault2, vault: vault2 })
.rpc();
const { kp, token, eligibility } = await makeTrader();
const contribution = USDC(40);
const position = derivePosition(
marketProgram.programId,
market,
kp.publicKey,
SIDE_UP,
SCORE_40,
contribution,
FEE_BPS
);
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution });
let threw = false;
try {
await marketProgram.methods
.settlePosition(SCORE_72)
.accountsPartial({
market, // market #1
authority: authority.publicKey,
position,
owner: kp.publicKey,
vault: vault2, // but vault #2
traderToken: token,
dealerToken,
feeVault: feeVault2,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
})
.rpc();
} catch (err) {
threw = true;
}
assert.isTrue(threw, "the vault is bound to its market by seed");
});
it("rejects a fee rate above the cap", async () => {
const { kp, token, eligibility } = await makeTrader();
let threw = false;
try {
await openPosition({
trader: kp,
traderToken: token,
eligibility,
side: SIDE_UP,
score: SCORE_40,
contribution: USDC(40),
feeBps: 1_001, // one basis point over the 10% cap
});
} catch (err) {
threw = true;
assert.match(String(err), /FeeTooHigh/i);
}
assert.isTrue(threw, "a fat-fingered fee is refused");
});
it("rejects a score at either excluded endpoint", async () => {
const { kp, token, eligibility } = await makeTrader();
for (const badScore of [0, 10_000]) {
let threw = false;
try {
await openPosition({
trader: kp,
traderToken: token,
eligibility,
side: SIDE_UP,
score: badScore,
contribution: USDC(40),
});
} catch (err) {
threw = true;
}
assert.isTrue(threw, `score ${badScore} should be refused`);
}
});
it("rejects an opening below the caller's slippage floor", async () => {
const { kp, token, eligibility } = await makeTrader();
let threw = false;
try {
await openPosition({
trader: kp,
traderToken: token,
eligibility,
side: SIDE_UP,
score: SCORE_40,
contribution: USDC(40),
minContribution: USDC(41), // floor above the computed 40 leg
});
} catch (err) {
threw = true;
assert.match(String(err), /SlippageExceeded/i);
}
assert.isTrue(threw, "a worse-than-requested fill is refused");
});
it("rejects an opening with no eligibility credential", async () => {
const stranger = anchor.web3.Keypair.generate();
await fund(stranger);
const token = await ata(stranger.publicKey);
await mintTo(conn, authority, mint, token, authority, USDC(100).toNumber());
// No set_eligibility call for this trader. The PDA does not exist.
const [strangerEligibility] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("eligibility"), market.toBuffer(), stranger.publicKey.toBuffer()],
marketProgram.programId
);
const contribution = USDC(40);
const position = derivePosition(
marketProgram.programId,
market,
stranger.publicKey,
SIDE_UP,
SCORE_40,
contribution,
FEE_BPS
);
let threw = false;
try {
await marketProgram.methods
.openPosition(SIDE_UP, SCORE_40, contribution, FEE_BPS, contribution)
.accountsPartial({
market,
trader: stranger.publicKey,
position,
dealer: dealerKey.publicKey,
eligibility: strangerEligibility,
traderToken: token,
dealerToken,
vault,
feeVault,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
})
.signers([stranger, dealerKey])
.rpc();
} catch (err) {
threw = true;
assert.match(String(err), /AccountNotInitialized|not exist|0xbc4/i);
}
assert.isTrue(threw, "a direct call with no credential is refused");
});
it("rejects a non-authority pausing the market", async () => {
const intruder = anchor.web3.Keypair.generate();
await fund(intruder);
let threw = false;
try {
await marketProgram.methods
.setPaused(true)
.accounts({ market, authority: intruder.publicKey })
.signers([intruder])
.rpc();
} catch (err) {
threw = true;
}
assert.isTrue(threw, "only the market authority may pause");
});
it("lets one trader hold both sides at the same score and stake (no PDA collision)", async () => {
// Regression: before `side` was a seed, an UP and a DOWN at the same score
// and contribution derived the same PDA, so the second open failed.
const { kp, token, eligibility } = await makeTrader();
// UP posts 0.40 * N and DOWN posts 0.60 * N; pick contributions that make
// each leg exact so the default slippage floor (contribution) is met.
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution: USDC(40) });
await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_DOWN, score: SCORE_40, contribution: USDC(60) });
const up = derivePosition(marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, USDC(40), FEE_BPS);
const down = derivePosition(marketProgram.programId, market, kp.publicKey, SIDE_DOWN, SCORE_40, USDC(60), FEE_BPS);
assert.notEqual(up.toString(), down.toString(), "UP and DOWN derive distinct accounts");
const upAccount = await marketProgram.account.position.fetch(up);
const downAccount = await marketProgram.account.position.fetch(down);
assert.equal(upAccount.side, SIDE_UP);
assert.equal(downAccount.side, SIDE_DOWN);
// DOWN posts the complement: (1 - 0.40) * 100 = 60.
assert.equal(downAccount.traderContribution.toString(), USDC(60).toString());
assert.equal(downAccount.dealerContribution.toString(), USDC(40).toString());
});
it("rejects an open whose trader leg rounds to zero", async () => {
// Regression: a one-base-unit contribution at the extreme score 0.9999
// gives N = 1 and a recomputed trader leg of 0, so the dealer would fund
// the whole notional and every settlement would pay the trader out of the
// dealer's money. `open_position` now requires both legs to be positive.
const { kp, token, eligibility } = await makeTrader();
const contribution = new anchor.BN(1); // one base unit
const position = derivePosition(
marketProgram.programId,
market,
kp.publicKey,
SIDE_UP,
9_999,
contribution,
FEE_BPS
);
let threw = false;
try {
await openPosition({
trader: kp,
traderToken: token,
eligibility,
side: SIDE_UP,
score: 9_999,
contribution,
minContribution: new anchor.BN(0),
position,
});
} catch (err) {
threw = true;
assert.match(String(err), /ZeroAmount|zero/i);
}
assert.isTrue(threw, "a position with a zero trader leg cannot open");
});
});
});
Market package manifest
SHA-256 1a0181708371c1e1115c8ee4e573a96e04a9ae215de1f9af6b2c69cf4643a46a
Download raw presentation copy
[package]
name = "science_market"
version = "0.1.0"
description = "Draft example program: markets, positions, and the fully funded reserve split."
edition = "2021"
rust-version = "1.75"
# Private draft: never published to a registry, so it carries no license field.
publish = false
[lib]
crate-type = ["cdylib", "lib"]
name = "science_market"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
# `init-if-needed` lets `set_eligibility` create the credential on first use and
# rewrite it on later calls. Every field is written on each call, so a repeat
# initialization cannot leave a stale `allowed` flag behind.
anchor-lang = { version = "0.30.1", features = ["init-if-needed"] }
anchor-spl = "0.30.1"
Dealer package manifest
SHA-256 85bc796cd3d5e100a0962c6e0df7662a02547b622010411d0e64e72eaba21ebb
Download raw presentation copy
[package]
name = "science_dealer"
version = "0.1.0"
description = "Draft example program: quote, per-leg fees, and dealer reserve accounting."
edition = "2021"
rust-version = "1.75"
# Private draft: never published to a registry, so it carries no license field.
publish = false
[lib]
crate-type = ["cdylib", "lib"]
name = "science_dealer"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
Cargo.toml — workspace and release profile
SHA-256 db1d403f654a9a46b1fb9bba83ffbb11da71428a26988ee95d4dad5cb311d624
Download raw presentation copy
# Anchor workspace for the draft Science Markets programs.
#
# Draft only. Nothing here is deployed, and no keypair, RPC secret, or funded
# account belongs in this tree.
[workspace]
members = ["[internal location withheld]", "[internal location withheld]"]
resolver = "2"
# Checked arithmetic in the deployed artifact, not only in debug builds. The
# instructions also use checked_* explicitly; this is the belt to that braces.
[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1
[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1
Anchor.toml — workspace and test config
SHA-256 4799ca7543133fa3a402bda66923ba4704f53a1faa4c9121850d25039a194f15
Download raw presentation copy
[toolchain]
anchor_version = "0.30.1"
[features]
resolution = true
skip-lint = false
[programs.localnet]
science_binary = "ApygmCdgkbXXzNMo1HJ13fPeqDwh5t15yfQoUjYf4SHP"
science_dealer = "Rcwwd81uB9YRZZTxEMgaXK5BBL3fmeGXPgsmcL7VmMZ"
science_market = "HywubrvNwhWjyZC3G1emofFpnABAb6kFE7KWghAA288v"
[registry]
url = "https://api.apr.dev"
[provider]
cluster = "Localnet"
wallet = "~/.config/solana/id.json"
[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 [internal location withheld] [internal location withheld]"
[test]
startup_wait = 5000
shutdown_wait = 2000
upgradeable = false
README.md — the programs, in words
SHA-256 0bcf4b6ea32fc400c3276dd3b493dc045e39309d3db830849ed8b310857a3ab6
Download raw presentation copy
# Draft Solana programs
These are example programs, written to be read. **Nothing here is deployed.** There is no
program ID on any cluster, no keypair, no RPC secret, and no funded account in this tree. The
live prototype at `http://localhost:8080` settles on simulated balances and does not touch a
chain. These drafts exist so the economics can be reviewed as code, and so a devnet test is a
short step rather than a rewrite.
Two small programs, each with one job:
| Program | Directory | What it does |
| --- | --- | --- |
| `science_market` | `[internal location withheld]` | Market and position accounts. Initialize a market with the proposition pinned, open a fully funded position, settle it at a score, and enforce the conservation invariant. |
| `science_dealer` | `[internal location withheld]` | The quote rule, the per-leg fee, and the dealer's reserve accounting. Committed reserve cannot be withdrawn. |
The split is deliberate. The core program is about the contract; the dealer program is about
the money the maker posts and the fee. Keeping them apart makes each one small enough to read,
and puts the one guardrail the legal brief names — a maker must not be able to withdraw
committed collateral — in a program that has nothing else to look at.
## Builder's note on the toolchain
`cargo`, `solana`, and `anchor` are all present, and every check below has been run in this
tree:
```
cargo check # both programs compile: no errors
cargo test # 20 unit tests pass
anchor build # builds both programs and the IDL
anchor test # 15 integration tests pass on a local validator
```
`anchor build` needed a lock-file pin (see `SECURITY.md`): `anchor-syn 0.30.1` calls
`Span::source_file()`, which `proc-macro2` >= 1.0.80 removed, so `Cargo.lock` is pinned to
older transitive crates for the IDL step. That pin does not change the program logic, but it
does mean the artifact hash depends on the lock file.
Beyond the tests, the drafts have been through Miri, a Trident fuzzing run, a Kani model-check
of the pure math, `cargo audit`, `cargo deny`, `cargo geiger`, and `cargo udeps`. Soteria was
attempted and could not compile the Agave line inside its container. Everything is local: no
devnet or testnet run and no third-party audit. See `SECURITY.md` for exactly what ran, what
each tool found, and what did not run.
## Build and test
```bash
cd programs
# Rust: compile-check and run the unit tests. No validator, no Anchor CLI needed.
cargo check
cargo test
# Anchor: build the programs and the IDL, then run the integration suite.
# Requires the Anchor CLI, a Solana keypair in the default path, and a local
# validator that `anchor test` starts for you.
anchor build
anchor test
# If you have never installed Anchor:
cargo install --git https://github.com/coral-xyz/anchor avm --locked
avm install 0.30.1 && avm use 0.30.1
```
`anchor test` runs `[internal location withheld]` against a local validator. The tests need the
IDL, so `anchor build` must run first.
The unit tests cover the arithmetic only: the leg split, the payout split, the fee on dollars,
the break-even identity, and the quote clamp. They have no accounts and need no cluster. The
integration tests cover the same numbers through real instructions, plus the refusals that
matter: a second settlement of a closed position, an opening with no eligibility account, and
an opening whose trader leg rounds to zero.
## The economics, as implemented
The published terms are the source of truth. This is the same thing in code:
```
N maximum gross payout of a position
p entry score, in (0,1)
q settlement score, in (0,1)
f fee rate per leg
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); the dealer gets the rest
fees entry fee on the trader's contribution
close fee on the trader's gross proceeds
both to the fee vault; the dealer's reserve is never charged
```
Scores are basis points: an integer in `1..=9999`, meaning `0.0001..=0.9999`. Both endpoints
are excluded. A score of 0 or 1 has no two-sided price and would hand the whole pot to one
side, so the program refuses it rather than pretending it is a probability.
Amounts are the collateral token's base units. At 6 decimals, `100_000_000` is 100 USDC.
## The account model
Five accounts. No global registry, no per-position vault, no metadata program.
| Account | Seeds | Holds |
| --- | --- | --- |
| `Market` | `["market", authority, market_id]` | The frozen claim, the settlement mint, the fee vault address, `reserve_locked`, `open_positions`, `paused`. |
| `Position` | `["position", market, trader, side, entry_score, fee_bps, contribution]` | One fully funded bilateral contract: both legs, N, the fee copied at open, and the outcome fields. |
| vault (token) | `["vault", market]` | All collateral for the market, held by the market PDA. One vault per market, not one per position. |
| `Eligibility` | `["eligibility", market, trader]` | A boolean, an expiry, and the trader's key, scoped to one market. Nothing else. |
| `Dealer` | `["dealer", authority]` | The fee rate, the quote parameters, `reserve_balance`, and `committed`. |
`Position` is seeded on the market, trader, side, entry score, fee basis points, and
contribution, so a trader can hold several positions on one market and each is a distinct
account. There is no instruction anywhere in
either program that rewrites `Market.claim`. Changing the wording means a new market, which is
what the published methodology promises.
The vault is owned by the market PDA. The dealer's reserve account is owned by the dealer PDA.
Neither is owned by a person, which is what makes "committed collateral cannot be withdrawn" a
property of the program rather than a promise about an operator's behaviour.
## The invariant
One invariant carries the contract: **for every position, `trader_contribution +
dealer_contribution == max_payout`**, and the collateral in the vault covers every open
position's payout.
It is enforced in three places:
1. `open_position` computes the trader leg from N, then subtracts to get the dealer leg, and
asserts the two sum to N before any signature is used.
2. `settle_position` asserts `trader_payout + close_fee + dealer_payout == max_payout` before
the money moves. This is an identity given the lines above it, and it is checked anyway so a
later edit cannot quietly break it.
3. `settle_position` requires `vault.amount >= market.reserve_locked` before paying anyone.
Since `reserve_locked` includes this position's own reserve, paying this position out cannot
dip into another one's.
The second invariant is about time: **a position settles once**. `settle_position` requires
`status == Open` and writes `Closed` in the same instruction. The test suite asserts that a
second call fails.
## How the fee basis is computed, and where it is set
A fee is a percentage of dollars, per leg: once on the consideration the trader pays to open,
and again on the gross proceeds the trader receives on close. It is never a percentage of the
maximum payout N.
`fee_on(amount, fee_bps)` is the only place either program computes a fee, and it is
`amount * fee_bps / 10_000`, rounded down. Every call site passes a dollar figure — a
contribution or a gross proceed. No call site passes `max_payout`. If someone later tries to
charge on N, they have to write a new line, not change a flag.
The rate itself is set in one of two places depending on the leg:
- **At open**, the trader passes `fee_bps` with the instruction, and the program caps it at
`MAX_FEE_BPS` (10%). The value is copied onto the position, so a later change cannot reprice
an existing contract.
- **At close**, the program reads `position.fee_bps`. There is no way to set it after the
position exists.
The published default is 2% per leg. On the brief's own illustration — a 100-USDC UP position
entered at 0.40 and closed at 0.72 — the trader posts 40.00 plus a 0.80 entry fee, receives
70.56 at close after a 1.44 close fee, and the dealer receives 28.00. Both unit and integration
tests pin those exact numbers, so the code and the disclosure page can be diffed against each
other by eye.
The break-even identity from the terms, `p * (1 + f) / (1 - f)`, is in
`break_even_score_millis`, with a unit test against the published example: 0.50 at 5% per leg
breaks even at 0.5526.
## The on-chain eligibility check
This is the minimal on-chain half of a control whose policy lives off chain. The legal brief
(§7) is blunt about why it has to exist: a rule that exists only on the website is not an
effective program-level restriction, because nothing stops someone calling the program through
their own RPC endpoint.
What it does:
- `open_position` requires an `Eligibility` account, seeded on the trader's key. The account
must have `allowed == true`, and its `expires_at` must be in the future (or zero for no
expiry).
- `set_eligibility` writes that account, and only the market authority can call it.
- Without the account the opening fails. With `allowed == false` it fails. A caller cannot
borrow someone else's credential, because the PDA includes their key.
What it deliberately does not do:
- **No identity is stored on chain.** The account holds a key, a boolean, and a timestamp. No
name, no address, no country, no document. The jurisdiction, sanctions, terms, and age checks
stay in the operator's own systems, where the data already is.
- **It is not a sanctions program, and it is not KYC.** It is a switch. A real deployment drives
it from a screening vendor and a counsel-approved matrix, and keeps a human in the loop.
- **It does not touch an open position.** Revoking a credential stops new openings. It does not
force a close, and it does not block settlement. The one thing that can hold a payout is
`restrict_owner`, and that returns the trader's own contribution and freezes the rest rather
than taking it.
- **It cannot make the policy correct.** A wrong allowlist entry written by the authority is a
wrong payment, and the program will not notice.
## What is not handled on chain
Said plainly, because the gap is the interesting part:
- **Identity and KYC.** Nothing verifies who a wallet belongs to.
- **Sanctions screening.** No vendor feed, no OFAC list, no escalation path. A boolean is not a
program.
- **Jurisdiction and product permissions.** Off chain, in the Django app's compliance layer.
- **Disputes.** No challenge window, no appeal, no independent decision-maker. The brief (§9)
requires all three before a real listing.
- **Oracle integrity.** The settlement authority's signature in this draft only says who
submitted the score. It does not say the score is right, well-sourced, or timely. A real
design publishes the score off chain first, commits it, and treats the on-chain submission as
one step in a documented sequence.
- **Records and reporting.** Events are emitted; the audit trail, reconciliation with the
legal books, and the reports a regulator would want are not here.
- **Upgrade authority.** These programs are upgradeable by their deployer as written. That is
fine for devnet and unacceptable for a public financial program without a stated policy.
- **Stablecoin risk.** See below.
## The denomination assumption
Collateral is a token, and the intended one is USDC. Full funding in USDC units is not full
funding in dollars. If USDC trades below a dollar, or its issuer freezes an account, or
redemption is slow, a position that was fully funded in units can fall short in value. The
program does not model any of that and does not convert anything. It holds one mint and pays
out in that mint. The published terms say the same thing (§6), and a deployment has to decide
whether obligations are USDC-denominated or dollar-denominated and disclose which. This draft
assumes the former. It does not pretend the two are the same.
## Before devnet
What has to happen before these could reasonably be tested on devnet, in order:
1. **Run the suite on devnet.** The integration suite passes on a local validator; it has not
run against a real cluster. Expect to fix seed encoding and account ordering on the first
pass.
2. **Write an IDL-level test for the invariant under adversarial ordering**: a settlement that
tries to use another market's vault, a payout routed to the wrong token account, a clamped
score at both ends, and a position opened by a dealer with an empty reserve account.
3. **Decide the oracle path.** A single authority signer is fine for a test and wrong for
anything else. The score has to be committed off chain, with the failure and stale-round
rules the methodology page describes, before the settlement instruction is trusted.
4. **Pin the mint.** `initialize_market` accepts any mint. A deployment pins the reviewed USDC
mint and refuses everything else.
5. **Test on devnet with worthless tokens first.** A locally minted mock with 6 decimals
behaves the same in the code and costs nothing when the arithmetic is wrong.
6. **Set the upgrade policy before the program ID is public.** An upgradeable program with a
single hot key is a different product from an immutable one, and the brief's first
recommendation is not to deploy the immutable version before classification is resolved.
7. **Keep the fee schedule and the site in step.** The rate lives in the instructions and the
disclosure lives on the site; a test that asserts the two agree is worth more than either.
None of this is a substitute for the legal work. The brief's first recommendation is to build
this as infrastructure for a regulated business and to resolve classification before an
immutable public deployment, not after. These drafts are the infrastructure half.
Market definitions and evidence · Fee policy · Risk disclosure