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 | ~6,354 lines of Rust across 31 files |
| Compiled binary | ~710 KB |
| Instructions | 30 |
| PDA account types | 7 (incl. singleton LiquidityPool and InsuranceFund) |
| Error codes | 47 (incl. PoolInsufficient, RiskFloorBreached, InvalidCloseBps; legacy VaultInsufficient retained as tombstone) |
#[event] structs | 13 (consumed by the off-chain trade-history indexer) |
| Anchor version | 0.32.1 |
| Solana CLI | 2.2.12 |
The 30 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 | Admin (multi-source pending) | 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 | Admin | 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 (hard on-chain gate) and target_balance (off-chain alert threshold) |
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 |
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:
| Account | Seeds | Count | Purpose |
|---|---|---|---|
MarketConfig | ["market", market_id: u16] | 68 | Per-market config + live state: oracle price, mark EMA, funding accumulators, OI, weights, settlement state, admin, initial_capacity (margin-tier denominator floor) |
LiquidityPool | ["liquidity_pool"] | 1 | Singleton. 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] | 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 scripts/probe-solvency.ts (60s loop, Telegram 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.monitoring/index.mjsfires[warning]Telegram 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.
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 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 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.tsfiles — 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
- 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.