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.
| Metric | Value |
|---|---|
| Source | ~8,081 lines of Rust across 41 files |
| Compiled binary | ~964 KB (deployed .so 986,944 B) |
| Instructions | 38 base instructions (41 with devnet migration feature) |
| PDA account types | 8 (incl. singleton LiquidityPool and InsuranceFund) |
| Error codes | 59 |
#[event] structs | 23 (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 version | 0.32.1 |
| Solana CLI | 2.2.12 |
Program instructions
Grouped by role:
Trading (user-invoked)
| Instruction | Purpose |
|---|---|
open_position | Create a new long or short position |
close_position | Close an existing position and settle PnL |
partial_close_position | Close a fraction (close_bps ∈ [1, 9999]) and proportionally release collateral |
add_collateral | Deposit more USDC into a position |
withdraw_collateral | Pull USDC out (subject to margin check) |
Trigger orders
| Instruction | Caller | Purpose |
|---|---|---|
place_trigger_order | User | Place SL, TP, or Limit Open |
cancel_trigger_order | User | Cancel a pending order; refund any escrow |
execute_trigger_close | Permissionless | Keeper fires SL/TP when mark crosses |
execute_trigger_open | Permissionless | Keeper fires Limit Open when mark reaches target |
Oracle & funding
| Instruction | Caller | Purpose |
|---|---|---|
update_oracle | Oracle authority (2-of-N consensus live on devnet; independent 2nd feed = mainnet gate D2) | Push a new oracle price + confidence + liveness + TWAP sample |
apply_funding | Permissionless | Settle the 8-hour funding cycle from TWAP samples |
Liquidation (all permissionless)
| Instruction | Layer | Purpose |
|---|---|---|
partial_liquidate | 1 | Close 20% of underwater position, 5% reward, 30s cooldown |
backstop_liquidate | 2 | Insurance fund absorbs position at mark price |
unwind_backstop | 2 | Gradual unwind of absorbed position (10% max per call) |
auto_deleverage | 3 | Force-close opposing profitable position (caller picks target) |
liquidate | Legacy | Pre-Spec-1 full-close liquidation, preserved for backwards compatibility |
Market lifecycle
| Instruction | Caller | Purpose |
|---|---|---|
initialize_market | Admin | Create a new team or player market |
pause_market / unpause_market | Oracle authority or admin (pause) / Admin (unpause) | Emergency halt / resume per-market trading |
update_market_params | Admin | Adjust tunable parameters (oracle weights, vAMM impact) |
sunset_market | Permissionless | Trigger settlement if oracle stale > 72h |
force_settle_market | Admin | Skip the 72h wait and settle immediately |
settle_position | Permissionless | Close any position in a settled market at sunset price |
Insurance fund
| Instruction | Caller | Purpose |
|---|---|---|
initialize_insurance | Admin | One-time fund creation |
deposit_insurance | Admin | Top up the fund |
withdraw_insurance | Admin | Pull funds above target balance (hard-capped) |
configure_insurance | Admin | Adjust max_backstop_exposure and target_balance |
Liquidity pool administration
| Instruction | Caller | Purpose |
|---|---|---|
initialize_pool | Admin | One-time creation of the singleton LiquidityPool PDA + pool_token SPL account. Sets risk_floor, target_balance, and obligations_snapshot_authority |
seed_pool | Admin | Deposit LP USDC into the global pool |
withdraw_pool_lp | Admin | Withdraw LP USDC. Gated: pool_token.amount − amount ≥ pool.risk_floor |
update_obligations_snapshot | Snapshot authority | Push exact global obligations for the public LP withdrawal gate |
Liquidity pool (public LP)
| Instruction | Caller | Purpose |
|---|---|---|
deposit_pool_lp | Permissionless | Deposit USDC and mint flat 1:1 LP shares into the caller’s LpAccount (subject to the $500k aggregate beta cap) |
withdraw_pool_lp_user | Permissionless | Burn 1:1 shares and withdraw USDC while pool_token.amount − amount ≥ max(total_collateral_locked, fresh_obligations_snapshot) + risk_floor |
Authority rotation (admin)
| Instruction | Purpose |
|---|---|
set_oracle_authority | Rotate a market’s dedicated oracle hot key (B5) |
set_market_admin | Rotate a market’s admin key |
set_pool_admin | Rotate the LiquidityPool admin (rejects an admin equal to the obligations-snapshot authority) |
set_insurance_admin | Rotate the InsuranceFund admin |
set_obligations_snapshot_authority | Rotate 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:
| Account | Seeds | Count | Purpose |
|---|---|---|---|
MarketConfig | ["market", market_id: u16] | 68 | 238 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"] | 1 | Singleton. 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 LP | Per-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] | N | Per-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"] | 1 | Singleton. Global insurance fund: balance, exposure, targets. Separate from LiquidityPool — different invariants, different admin policies |
BackstopPosition | ["backstop", market_id: u16] | up to 1 per market | Position 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)] | variable | Pending 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 obligationsThe full Σ is enforced off-chain by a solvency-probe service (60s loop, operator alerts on drift). On-chain instructions do cheap local checks only:
| Path | On-chain check |
|---|---|
| User exits (close, SL/TP execute, cancel, settlement payout, ADL target) | pool_token.amount ≥ payout |
add_collateral / withdraw_collateral | Existing 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 paths | pool_token.amount ≥ payout only |
withdraw_pool_lp | pool_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 atpool < 2× target_balance,[error]atpool < 1.25× target_balance. Designed to surface low-pool conditions beforerisk_floorengages.
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 devnetAfter 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(andpause_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.tsfiles — 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
- Off-Chain Services — the services that invoke the permissionless instructions.
- Oracle Design — detail on
update_oracle+ TWAP sampling. - Program Instructions Reference — per-instruction argument and account tables.
- Account Structure (PDAs) — field-by-field breakdown of each PDA.