🚧 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~6,354 lines of Rust across 31 files
Compiled binary~710 KB
Instructions30
PDA account types7 (incl. singleton LiquidityPool and InsuranceFund)
Error codes47 (incl. PoolInsufficient, RiskFloorBreached, InvalidCloseBps; legacy VaultInsufficient retained as tombstone)
#[event] structs13 (consumed by the off-chain trade-history indexer)
Anchor version0.32.1
Solana CLI2.2.12

The 30 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_oracleAdmin (multi-source pending)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_marketAdminEmergency 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 (hard on-chain gate) and target_balance (off-chain alert threshold)
seed_poolAdminDeposit LP USDC into the global pool
withdraw_pool_lpAdminWithdraw LP USDC. Gated: pool_token.amount − amount ≥ pool.risk_floor

The 7 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]68Per-market config + live state: oracle price, mark EMA, funding accumulators, OI, weights, settlement state, admin, initial_capacity (margin-tier denominator floor)
LiquidityPool["liquidity_pool"]1Singleton. Global cross-market USDC custody. Holds risk_floor, target_balance, total_lp_deposits, total_lp_withdrawals, admin, token_mint, token_account, bump. Backed by a pool_token SPL account at seeds ["pool_token"] shared by every market
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 scripts/probe-solvency.ts (60s loop, Telegram 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. monitoring/index.mjs fires [warning] Telegram 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.

Full plan: .agents/plans/cross-market-liquidity-pool.md.

The error model

47 custom error codes are declared in 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). The canonical build server is a Hetzner CPX22 at 178.104.120.151.

# on the build server
anchor build
# → target/deploy/obv_perps.so (~710 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 repo need updating. The full list is in DEPLOY.md → “Changing Program ID”. This is the kind of operation that gets a dedicated migration branch, never a one-shot edit.

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

Admin, upgrade authority, and multi-sig path

Today the admin is a single keypair: CcCxKNZaudbKh4TNNRFHoxsWRwmzHkeJVLVNixhTgovW. Admin holds authority over:

  • Per-market pause_market, unpause_market, update_market_params.
  • Insurance deposit_insurance, withdraw_insurance, configure_insurance.
  • force_settle_market.
  • Liquidity pool initialize_pool, seed_pool, withdraw_pool_lp.

The program’s upgrade authority (who can deploy a new binary to the same program ID) is the same keypair in devnet. For mainnet, both the admin and the upgrade authority are planned to migrate to a multi-signature setup with timelocked changes. See the Roadmap.

Testing

The program ships with:

  • ~30 Anchor integration tests (tests/obv-perps.ts) — covering trade lifecycles, oracle updates, liquidations, insurance flows.
  • 28 Phase 5 risk-engine tests (tests/phase5-integration.ts) — 9 risk scenarios including composite mark dislocations, confidence scaling, margin tiers, three-layer cascade.
  • 72 E2E tests against live devnet (tests/e2e/) across 12 .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.
  • ~73 SDK unit tests (sdk/tests/) — math parity, PDA derivation, devnet integration, E2E trading, liquidation builders, update_market_params.
  • ~258 engine Vitest tests across 30+ 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. See package.json for the test script list.

Further reading