🚧 SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
Protocol ArchitectureOn-Chain Program

On-Chain Program

The on-chain program is written in Rust using the Anchor framework (v0.32.1), compiled to Solana BPF bytecode, and deployed to devnet at program ID 6d4fSCD7mNy7aDNS2mXUxYpZjFFQKBKwAsM5kojKQA6h.

MetricValue
Source~8,081 lines of Rust across 41 files
Compiled binary~964 KB (deployed .so 986,944 B)
Instructions38 base instructions (41 with devnet migration feature)
PDA account types8 (incl. singleton LiquidityPool and InsuranceFund)
Error codes59
#[event] structs23 (trade history, public LP, keeper reward, B5 governance, SP-013 pool snapshots, and ADL-blocked events consumed or ignored explicitly by the off-chain indexer)
Anchor version0.32.1
Solana CLI2.2.12

Program instructions

Grouped by role:

Trading (user-invoked)

InstructionPurpose
open_positionCreate a new long or short position
close_positionClose an existing position and settle PnL
partial_close_positionClose a fraction (close_bps ∈ [1, 9999]) and proportionally release collateral
add_collateralDeposit more USDC into a position
withdraw_collateralPull USDC out (subject to margin check)

Trigger orders

InstructionCallerPurpose
place_trigger_orderUserPlace SL, TP, or Limit Open
cancel_trigger_orderUserCancel a pending order; refund any escrow
execute_trigger_closePermissionlessKeeper fires SL/TP when mark crosses
execute_trigger_openPermissionlessKeeper fires Limit Open when mark reaches target

Oracle & funding

InstructionCallerPurpose
update_oracleOracle authority (2-of-N consensus live on devnet; independent 2nd feed = mainnet gate D2)Push a new oracle price + confidence + liveness + TWAP sample
apply_fundingPermissionlessSettle the 8-hour funding cycle from TWAP samples

Liquidation (all permissionless)

InstructionLayerPurpose
partial_liquidate1Close 20% of underwater position, 5% reward, 30s cooldown
backstop_liquidate2Insurance fund absorbs position at mark price
unwind_backstop2Gradual unwind of absorbed position (10% max per call)
auto_deleverage3Force-close opposing profitable position (caller picks target)
liquidateLegacyPre-Spec-1 full-close liquidation, preserved for backwards compatibility

Market lifecycle

InstructionCallerPurpose
initialize_marketAdminCreate a new team or player market
pause_market / unpause_marketOracle authority or admin (pause) / Admin (unpause)Emergency halt / resume per-market trading
update_market_paramsAdminAdjust tunable parameters (oracle weights, vAMM impact)
sunset_marketPermissionlessTrigger settlement if oracle stale > 72h
force_settle_marketAdminSkip the 72h wait and settle immediately
settle_positionPermissionlessClose any position in a settled market at sunset price

Insurance fund

InstructionCallerPurpose
initialize_insuranceAdminOne-time fund creation
deposit_insuranceAdminTop up the fund
withdraw_insuranceAdminPull funds above target balance (hard-capped)
configure_insuranceAdminAdjust max_backstop_exposure and target_balance

Liquidity pool administration

InstructionCallerPurpose
initialize_poolAdminOne-time creation of the singleton LiquidityPool PDA + pool_token SPL account. Sets risk_floor, target_balance, and obligations_snapshot_authority
seed_poolAdminDeposit LP USDC into the global pool
withdraw_pool_lpAdminWithdraw LP USDC. Gated: pool_token.amount − amount ≥ pool.risk_floor
update_obligations_snapshotSnapshot authorityPush exact global obligations for the public LP withdrawal gate

Liquidity pool (public LP)

InstructionCallerPurpose
deposit_pool_lpPermissionlessDeposit USDC and mint flat 1:1 LP shares into the caller’s LpAccount (subject to the $500k aggregate beta cap)
withdraw_pool_lp_userPermissionlessBurn 1:1 shares and withdraw USDC while pool_token.amount − amount ≥ max(total_collateral_locked, fresh_obligations_snapshot) + risk_floor

Authority rotation (admin)

InstructionPurpose
set_oracle_authorityRotate a market’s dedicated oracle hot key (B5)
set_market_adminRotate a market’s admin key
set_pool_adminRotate the LiquidityPool admin (rejects an admin equal to the obligations-snapshot authority)
set_insurance_adminRotate the InsuranceFund admin
set_obligations_snapshot_authorityRotate the SP-013 obligations-snapshot hot key (new authority can never equal the admin)

The 8 PDA account types

Every stateful object in the protocol is a Program Derived Address. All seeds listed below are literal byte strings + little-endian serialized primitives:

AccountSeedsCountPurpose
MarketConfig["market", market_id: u16]68238 bytes. Per-market config + live state: oracle price, mark EMA, funding accumulators, OI, weights, settlement state, admin, oracle_authority, initial_capacity (margin-tier denominator floor)
LiquidityPool["liquidity_pool"]1Singleton. 201 bytes. Global cross-market USDC custody. Holds risk_floor, target_balance, total_lp_deposits, total_lp_withdrawals, total_lp_shares, total_collateral_locked, admin, token_mint, token_account, bump, plus the SP-013 snapshot fields obligations_snapshot_authority / last_obligations / obligations_ts. Backed by a pool_token SPL account at seeds ["pool_token"] shared by every market
LpAccount["lp_account", user: Pubkey]per LPPer-user public LP account: tracks 1:1 USDC shares plus lifetime deposits/withdrawals. Stays open after a full withdrawal
UserPosition["position", market_config: Pubkey, user: Pubkey]NPer-trader-per-market position: direction, size, entry price, collateral, cumulative-funding snapshot. The first key is the MarketConfig PDA, not the market id
InsuranceFund["insurance"]1Singleton. Global insurance fund: balance, exposure, targets. Separate from LiquidityPool — different invariants, different admin policies
BackstopPosition["backstop", market_id: u16]up to 1 per marketPosition absorbed at Layer 2, awaiting gradual unwind. One outstanding backstop per market at a time
TriggerOrder["trigger", market_config: Pubkey, user: Pubkey, order_id: u64 (LE)]variablePending SL/TP/Limit Open. LimitOpen escrow is held in the global pool, earmarked to the trigger PDA
UserOrderCounter["order_counter", user: Pubkey, market_config: Pubkey]per (user, market)Monotonic counter for trigger-order indexing within one market (never reuses freed slots)

Capital model — single global pool, no per-market vaults

Earlier versions of SportsPerp used one VaultAccount per market (68 PDAs + 68 SPL token accounts). That created structural illiquidity in any one-sided market — losses on Arsenal could not fund profits on Manchester City, and a fresh market with thin LP could block a profitable close. The 2026-04-25 NFO incident (VaultInsufficient on a profitable +$20 close) made the defect concrete in production.

The design was replaced before mainnet by a single global pool. Per-market accounting still lives on MarketConfig; only the capital custody moved.

Solvency invariant

pool_token.amount  ≥  Σ open positions [max(0, collateral + pnl − funding_owed − close_fee)]
                    + Σ pending LimitOpen escrows
                    + Σ open backstop obligations

The full Σ is enforced off-chain by a solvency-probe service (60s loop, operator alerts on drift). On-chain instructions do cheap local checks only:

PathOn-chain check
User exits (close, SL/TP execute, cancel, settlement payout, ADL target)pool_token.amount ≥ payout
add_collateral / withdraw_collateralExisting margin-ratio check + pool balance check on withdraw. No risk_floor gate
open_position, place_trigger_order(LimitOpen)pool_token.amount ≥ pool.risk_floor
place_trigger_order(StopLoss / TakeProfit)No risk_floor gate (reduce/exit-only)
Liquidator pathspool_token.amount ≥ payout only
withdraw_pool_lppool_token.amount − amount ≥ pool.risk_floor

Two thresholds

  • risk_floor — admin-set, hard on-chain gate. Below it, opens and LimitOpens are blocked; closes / SL / TP / cancels / margin-passing collateral withdrawals all keep working. Surfaced in the trader UI as “Opening paused: pool liquidity below risk floor.”
  • target_balance — admin-set, off-chain only. The monitor service fires [warning] operator alerts at pool < 2× target_balance, [error] at pool < 1.25× target_balance. Designed to surface low-pool conditions before risk_floor engages.

The InsuranceFund stays separate; pool capital and insurance capital have different invariants and different admin policies.

The cross-market liquidity-pool design is documented in the project’s internal design plans.

The error model

59 custom error codes are declared in the program’s errors.rs. Error code discipline:

  • Codes are never reused. Even when a check is removed, its code is retired, not repurposed. Downstream consumers parsing logs won’t get silently misled.
  • Every condition has a dedicated code. OracleStale, PositionAlreadyExists, InvalidLeverage, LiquidationCooldownActive, ProfitableLiquidation, NoOpposingPositionsForADL, etc. — the cause is always specific.
  • Anchor error numbers start at 6000 (offset 0) and increment. SDK consumers can pattern-match numerically or by name.

Compilation and deployment

Builds require Linux (the BPF toolchain doesn’t compile on Windows or macOS ARM). Builds and deploys are performed from a controlled, access-restricted build environment.

# on the build server
anchor build
# → target/deploy/obv_perps.so (~940 KB)
 
solana program deploy \
  --program-id target/deploy/obv_perps-keypair.json \
  target/deploy/obv_perps.so \
  --url devnet

After deployment, the IDL is copied to two places:

  • sdk/idl/obv_perps.json — consumed by the SDK.
  • app/idl/obv_perps.json — consumed by the frontend.

Both are committed to the repo. A contributor fetching the repo at any commit can reproduce a working stack without running anchor build themselves.

Program ID changes

When the program ID changes (rare — typically only on major re-architectures), ~23 files across the codebase need updating. The full procedure is captured in the project’s internal deployment runbook. This is the kind of operation that gets a dedicated migration branch, never a one-shot edit.

Historical dead program IDs are preserved in that runbook so no one accidentally reuses them. Devnet rent on each dead ID is still recoverable but has not been reclaimed.

Admin, oracle authority, upgrade authority, and multi-sig path

On devnet, price-pushing and oracle-update authority is separated from the general admin authority. A dedicated oracle_authority key gates update_oracle and pause_market (pause_market also accepts the admin), so the hot key that publishes prices and can emergency-halt a market is distinct from the key that controls capital and configuration. Admin authority covers:

  • Per-market unpause_market, update_market_params (and pause_market, shared with the oracle authority).
  • Insurance deposit_insurance, withdraw_insurance, configure_insurance.
  • force_settle_market.
  • Liquidity pool initialize_pool, seed_pool, withdraw_pool_lp.
  • Authority rotation: set_oracle_authority, set_market_admin, set_pool_admin, set_insurance_admin, set_obligations_snapshot_authority (a hot authority can never rotate the admin, and a rotated hot authority can never equal the admin).

The oracle-authority separation was deployed to devnet (program upgraded; all 68 markets migrated to the 238-byte layout and verified), with the oracle running on a dedicated hot key distinct from the admin. The single-key admin (and the program’s upgrade authority) remains the protocol’s primary centralization vector on devnet, and removing it is a hard mainnet gate: both move to a multi-signature setup with a timelock on parameter-changing instructions before mainnet. See the Roadmap.

Testing

The program ships with:

  • ~30 Anchor integration tests — covering trade lifecycles, oracle updates, liquidations, insurance flows.
  • 28 Phase 5 risk-engine tests — 9 risk scenarios including composite mark dislocations, confidence scaling, margin tiers, three-layer cascade.
  • 109 E2E tests against live devnet across 18 .test.ts files — open/close, partial-close, oracle, funding, liquidation, admin, trigger orders, sunset, sunset-extended, pool-solvency, risk-floor-gate, S4-01 expiry regression.
  • 151 SDK unit tests — math parity, PDA derivation, devnet integration, E2E trading, liquidation builders, update_market_params.
  • ~823 engine Vitest tests across 55 suites — composite math, EMA blender, live-processor replay, ID bridge, shadow pipeline, candle store, WS server.

E2E tests are the last gate before any devnet deploy.

Further reading