Security Model#
Scope: On-chain security model — the protections enforced by the Seesaw program itself. Intended for integrators and reviewers who want to understand the on-chain guarantees. For the security policy, vulnerability reporting, and off-chain/operational security (CORS, rate limiting, auth, dependency audits), see ../SECURITY.md.
Security architecture and protections for the Seesaw protocol.
The code samples below are simplified illustrations of the protocol's checks. The program is built on Pinocchio (not Anchor); a
require!-style line denotes an explicit check that returns the namedSeesawError.
Threat Model#
Adversary Capabilities#
| Capability | Description |
|---|---|
| Arbitrary Transactions | Can submit any valid Solana transaction |
| Multiple Accounts | Can create unlimited accounts (Sybil) |
| Flash Loans | Can access large capital temporarily |
| MEV Extraction | Can front-run or sandwich transactions |
| Market Manipulation | Can trade to move prices |
Adversary Limitations#
| Limitation | Assumption |
|---|---|
| Forge Signatures | Ed25519 is secure |
| Break Pyth | Oracle data is authentic |
| Control Solana | Network operates honestly |
| Infinite Capital | Capital is finite per tx |
| Time Travel | Clock moves forward only |
Security Layers#
Account Security#
Discriminator validation#
Each account type begins with a unique 8-byte discriminator, checked when the account is loaded:
// Read raw bytes from the AccountView and compare the leading 8 bytes.
let data = account.try_borrow_data()?;
if data[..8] != T::DISCRIMINATOR {
return Err(SeesawError::InvalidAccountType.into()); // 0x5001
}
Owner validation#
Program-state accounts must be owned by the Seesaw program, and token accounts by the SPL Token program. Ownership is checked explicitly on every account.
PDA validation#
Program-derived addresses are re-derived from their seeds and compared to the supplied account key:
let (expected, _bump) = find_program_address(seeds, &crate::ID);
if account.key() != &expected {
return Err(SeesawError::InvalidPDA.into()); // 0x5003
}
Always verify derivation#
Never trust a caller-supplied account address. Re-derive the expected PDA from its full seed set and the stored bump, then reject on mismatch:
// Re-derive market PDA from its full seed set + stored bump.
let expected = create_program_address(
&[
b"seesaw",
b"market",
&pyth_feed_id,
&duration_seconds.to_le_bytes(),
&market_id.to_le_bytes(),
creator.as_ref(),
&[market.bump],
],
program_id,
)?;
if market.key() != &expected {
return Err(SeesawError::InvalidPDA.into());
}
Store bumps#
Bumps are stored on the account at creation time to skip the (expensive)
find_program_address search on subsequent instructions:
// During creation
market.bump = bump;
// During use — the full seed set plus the stored bump signs/verifies the PDA
let seeds = &[
b"seesaw",
b"market",
&pyth_feed_id,
&duration_seconds.to_le_bytes(),
&market_id.to_le_bytes(),
creator.as_ref(),
&[market.bump],
];
Use find_program_address at creation time; it returns the canonical (highest valid) bump.
Arithmetic Security#
Checked Operations#
All arithmetic uses checked operations to prevent silent overflow:
// WRONG
let result = a + b; // Can overflow
// RIGHT
let result = a.checked_add(b).ok_or(Error::MathOverflow)?;
Protocol-Favorable Rounding#
// Taker fee uses capped-linear-decay: fee_bps(p) = min(fee_cap_bps, decay_rate_bps * (10_000 - p) / 10_000)
// Fees round UP (protocol receives more)
fn calculate_taker_fee(amount: u64, price_bps: u16, fee_cap_bps: u16, decay_rate_bps: u16) -> u64 {
let raw = (decay_rate_bps as u64)
.saturating_mul(10_000u64.saturating_sub(price_bps as u64))
/ 10_000u64;
let fee_bps = raw.min(fee_cap_bps as u64);
// Ceiling division
(amount.saturating_mul(fee_bps).saturating_add(9_999)) / 10_000
}
// Payouts round DOWN (protocol pays less)
fn calculate_payout(shares: u64, price_bps: u16) -> u64 {
shares * (price_bps as u64) / 10_000 // Floor division
}
Tick Rounding#
// Bids round DOWN (buyer pays less)
fn round_bid(price: u16, tick: u16) -> u16 {
(price / tick) * tick
}
// Asks round UP (seller receives more)
fn round_ask(price: u16, tick: u16) -> u16 {
let rem = price % tick;
if rem == 0 { price } else { price + tick - rem }
}
State Machine Security#
State Transition Enforcement#
fn validate_state_transition(
current: MarketState,
instruction: Instruction,
) -> Result<()> {
let valid = match (current, instruction) {
(Pending, CreateMarket) => true,
(Trading, PlaceOrder) => true,
(Trading, CancelOrder) => true,
(Trading, SnapshotEnd) => true,
(Settling, ResolveMarket) => true,
(Resolved, Redeem) => true,
(Resolved, CloseMarket) => true,
_ => false,
};
require!(valid, Error::InvalidStateTransition);
Ok(())
}
Timing Enforcement#
fn validate_timing(
market: &MarketAccount,
instruction: Instruction,
clock: &Clock,
) -> Result<()> {
match instruction {
SnapshotEnd => {
require!(clock.unix_timestamp >= market.t_end, Error::TooEarly);
}
PlaceOrder => {
require!(clock.unix_timestamp < market.t_end, Error::TradingEnded);
}
_ => {}
}
Ok(())
}
Oracle Security#
Seesaw reads the Pyth PriceUpdateV2 account directly (no Pyth SDK). On each
read it validates the account discriminator and that the update is fully
verified, then checks:
- the feed id matches the market's configured feed (
OracleMismatch, 0x2001); - the price is positive (
InvalidPrice, 0x2003); - the publish time is at or after the boundary, and not implausibly far in the
future (
StaleOracle, 0x2002).
let price = read_pyth_price(pyth_price_account)?; // validates discriminator + Full verification
if price.feed_id != market.pyth_feed_id { return Err(SeesawError::OracleMismatch.into()); }
if price.price <= 0 { return Err(SeesawError::InvalidPrice.into()); }
if price.publish_time < boundary { return Err(SeesawError::StaleOracle.into()); }
Push vs Pull Oracle Modes#
Each market carries an oracle_mode byte set at creation:
| Mode | Value | Feed Account | Owner Validated Against |
|---|---|---|---|
| Push | 0 | Address stored as market.pyth_feed | config.pyth_receiver_program_id |
| Pull | 1 | Ephemeral PriceUpdateV2 (any address) | config.pyth_receiver_program_id |
In pull mode market.pyth_feed = [0u8;32] (zero = pull indicator; no address
binding). The feed-id check (FeedIdMismatch, 0x2005) replaces the address check.
Pull-mode makes Sampling Rule A ("first price with publish_time >= boundary")
deterministic because the caller posts the exact price they want to use.
The Pyth program IDs stored in ConfigAccount can be updated via the two-step
ProposePythProgramId / ApplyPythProgramId governance with a 48-hour timelock
(ORACLE_TIMELOCK_SECONDS = 172,800 s). This allows a no-redeploy cutover for
the planned 2026-07-31 Pyth Core program-ID change.
Immutability#
Once captured, a snapshot cannot change:
fn capture_snapshot(market: &mut MarketAccount, price: i64) -> Result<()> {
// Idempotency check
if market.start_price != 0 {
return Ok(()); // Already captured, no-op
}
market.start_price = price; // Immutable after this
Ok(())
}
Solvency Security#
No Naked Shorts#
fn validate_sell_order(
position: &UserPositionAccount,
side: OrderSide,
quantity: u64,
) -> Result<()> {
let available = match side {
SellYes => position.yes_shares - position.locked_yes_shares,
SellNo => position.no_shares - position.locked_no_shares,
_ => return Ok(()), // Not a sell
};
require!(quantity <= available, Error::InsufficientShares);
Ok(())
}
Collateral Coverage#
fn validate_buy_order(
user_balance: u64,
locked_collateral: u64,
price_bps: u16,
quantity: u64,
) -> Result<()> {
let required = (price_bps as u64)
.checked_mul(quantity)
.ok_or(Error::Overflow)?
.checked_div(10_000)
.ok_or(Error::Overflow)?;
let available = user_balance - locked_collateral;
require!(required <= available, Error::InsufficientCollateral);
Ok(())
}
Vault Solvency Invariant#
vault.balance >= max(total_yes_shares, total_no_shares)
+ accumulated_creator_fees
Trader-ledger slot credits (*_free, quote_free) are enforced at the
instructions that mutate them; they are not separate market-level solvency
addends.
Reentrancy Protection#
Solana Runtime Protection#
- Account locking: Accounts are locked for the duration of the transaction
- No recursive CPI: Accounts cannot be re-borrowed mutably within the same transaction
- No token callbacks: SPL Token v1 has no transfer hooks
Settlement Idempotency#
fn redeem(position: &mut UserPositionAccount) -> Result<()> {
// Check BEFORE any state change
if position.settled {
return Ok(()); // Already settled, no-op
}
// Calculate and transfer payout...
position.settled = true; // Atomic with transfer
Ok(())
}
Protocol Invariants#
The full invariant catalog lives in ../security/invariants.md. Summary:
| ID | Invariant | Description |
|---|---|---|
| INV-X1 | Bounded Order-Placement Surface | PlaceOrder never touches arbitrary counterparty accounts |
| INV-X2 | No Crossed/Unsorted Book on Commit | Runtime recheck prevents crossed book on the mutation path |
| INV-X3 | Single Fill-Accounting Transition | One shared settlement path — no double-credit or missed unlock |
| INV-X4 | Emergency Status Fails Closed | Unrecognized emergency values rejected, not treated as no-op |
| INV-G1 | Account Ownership | All protocol accounts owned by the program |
| INV-G2 | PDA Authenticity | All PDAs derive correctly from seeds and stored bumps |
| INV-G3 | Discriminator Integrity | All accounts have correct 8-byte discriminators |
| INV-M2 | Snapshot Immutability | Once captured, start/end prices never change |
| INV-M3 | Resolution Determinism | Same snapshots → same outcome deterministically |
| INV-M4 | State Monotonicity | Market state transitions only forward |
| INV-O1 | No Crossed Book | best_bid < best_ask whenever both exist |
| INV-S1 | Vault Solvency | vault >= max(total_yes_shares, total_no_shares) |
| INV-S2 | Conservation | Trading does not create or destroy value (minus fees) |
| INV-S3 | Non-negative Shares | Share balances never go below zero |
How These Protections Are Verified#
The protections above are backed by a multi-layer test suite and formal methods:
| Verification Layer | Coverage |
|---|---|
| Unit tests (~1,628) | Function-level correctness |
| Integration tests | Multi-instruction flows, state transitions |
| Property-based tests (~49) | Invariant checking under generated inputs |
| Fuzz testing (36 targets) | Crash and undefined-behavior discovery via cargo-fuzz |
| Mutation testing | Test quality verification via cargo-mutants |
| Kani formal verification | Arithmetic overflow proofs on selected hot-path functions |
| Runtime invariant checks | Solvency, no-crossed-book, and conservation checked on every fill |
Audit status: The program has not yet undergone a third-party human security audit. See ../security/README.md for current status.
For emergencies, the protocol ships admin safety controls: trading can be paused
(Pause / Unpause), and a per-market emergency_status byte can place a market
into post-only or halt it so the order book can be frozen while an issue is
investigated (SetMarketEmergencyStatus, ForceCancelMarketOrders).
Next Steps#
- Threat Model — detailed analysis
- Invariants — formal invariant specifications