đźš§ SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
SecuritySelf-Audit Summary

Self-Audit Summary

An internal self-audit of the SportsPerp on-chain program + off-chain services was completed April 17, 2026. The full report is archived at docs/SELF-AUDIT-REPORT.md in the repository. This page summarizes the results.

Headline numbers

Audit dateApril 17, 2026
AuditorInternal (Klaw)
Scope27 Anchor instructions + 7 account types + 6 math modules + 6 off-chain services
Findings total17
Critical1 (fixed)
High5 (all fixed)
Medium4 (documented, non-blocking)
Low4 (documented)
Informational3 (documented)

Status: All Critical and High findings have been resolved and verified with regression tests. The program builds clean (cargo check — 0 errors, 0 warnings) and all 70+ on-chain tests pass.

The Critical finding (fixed)

S4-01: Expired Trigger Order Destroys Position Account

Severity: Critical Fixed: Commits 4a213f2, e1680cc Location: programs/obv-perps/src/instructions/trigger_order.rs

What it was: The trigger_close instruction used Anchor’s close account constraint on the user’s position account. When a Stop-Loss or Take-Profit order expired, the handler returned early — but Anchor’s close constraint executed unconditionally during account deserialization, destroying the user’s position account and transferring its rent to the keeper. Result: any expired SL/TP would silently delete the user’s open position.

Impact: Loss of user position state. Position would need to be reconstructed off-chain; collateral would be recoverable but the active position would effectively disappear.

Fix:

  • Removed the unconditional Anchor close constraint from the position account.
  • Manual position.close() now executes only after successful order execution.
  • The expired-order path leaves the position intact, only closing the trigger order PDA.
  • Extended fix to LimitOpen — expired limit orders now explicitly close ghost PDAs and refund collateral escrow to the user.

Regression tests: tests/e2e/s4-01-expiry-regression.test.ts covers 5 scenarios:

  1. Expired StopLoss — trigger closed, position survives.
  2. Expired TakeProfit — trigger closed, position survives.
  3. Expired LimitOpen — trigger closed, collateral refunded, no ghost PDA.
  4. Non-expired orders still execute normally.
  5. Collateral refund verification for expired LimitOpen.

The 5 High-severity findings (all fixed)

S2-03: Backstop Liquidation Skipped Funding Payment

Layer 2 backstop absorption computed payout without first deducting accumulated funding. A position owing significant funding could extract more from the insurance fund than warranted.

Fix: Funding payment is now calculated and deducted before any backstop payout math. Commit 4a213f2.

S2-04: ADL Skipped Proportional Funding

Auto-deleveraging force-closes profitable opposing positions but did not apply proportional funding to the target’s payout. A target with large accumulated funding obligations could receive a full PnL payout without settling funding debt.

Fix: Funding is now calculated and deducted proportionally before the deleveraged payout. Commit 4a213f2.

S3-02: Market Settlement Skipped Funding

When a market is sunset (oracle stale ≥ 72h) and positions are settled at the settlement price, settle_position did not deduct accumulated funding before computing the final payout.

Fix: Funding is now deducted before settlement payout computation. Commit 4a213f2.

S3-01: Missing Vault Balance Pre-Checks

Four instruction handlers that transfer SOL/USDC from the vault lacked explicit vault.amount >= payout checks. Solana would eventually reject an insufficient-funds transfer, but the lack of explicit checks meant wasted gas on failed transactions and opaque error messages.

Affected handlers: close_position, settle_position, backstop_liquidate, trigger_close.

Fix: Added explicit require!(vault.amount >= payout, OBVPerpsError::InsufficientVaultBalance) checks before every transfer. Commit 4a213f2.

S1-02: Insurance Fund Could Be Fully Drained

The withdraw_insurance admin instruction did not enforce a floor on the fund’s balance. An admin (or a compromised admin keypair) could drain the full fund balance at any time, leaving no capital to absorb Layer 2 events.

Fix: withdraw_insurance now enforces a floor of max(120% Ă— current_backstop_exposure, 20% Ă— target_balance). The exposure-linked term guarantees the fund always retains enough capital to cover outstanding Layer 2 obligations plus a 20% buffer; the target-linked term prevents a complete drain even when no positions are absorbed. Admin cannot go below either threshold, regardless of signing authority. See programs/obv-perps/src/instructions/insurance_admin.rs:51-62.

Medium-severity findings (documented)

Four findings at Medium severity — documented but not blocking. These generally involve edge cases that are unlikely in normal operation but worth noting for integrators:

  • Partial funding settlement during transfer ops — add_collateral and withdraw_collateral settle pending funding but within a narrow timing window. In theory a very aggressive caller could sequence operations to marginally delay funding payment. In practice, the timing is within one block.
  • Oracle update races with liquidation cascade — a price update landing simultaneously with a liquidation can result in the liquidation executing against the pre-update mark. Expected behavior, but documented.
  • Backstop unwind reward rounding — at very small unwind sizes, the 3% reward rounds to zero. Practically irrelevant but noted.
  • Trigger order size validation — a trigger can be placed with a size larger than the current position size; at execution time the size is capped at the current position size. Not a bug, but could be confusing; clearer error messaging planned.

Low and Informational findings

Documented in the full report. Primarily code-quality observations (redundant checks, opportunities for clearer error codes, optional efficiency improvements) that don’t affect security or correctness. None block mainnet.

Cross-audit invariants verified

Beyond the individual findings, the self-audit verified these invariants end-to-end:

  • Vault balance never becomes negative. Enforced across the liquidation cascade, sunset settlement, and trigger execution.
  • Insurance fund balance never exceeds total_deposits - total_withdrawals — no implicit emission.
  • Every position that can be opened can be closed. No dead-end states.
  • Every trigger order is either executable, cancellable, or expirable — no stuck orders.
  • PnL formula is symmetric across long and short. Same rounding bias, same direction.
  • Cumulative funding counters are monotonic. A position’s entry snapshot + later settlement always produces a non-negative unit payment delta for the obligated side.

These invariants are additionally exercised by the Phase 5 integration test suite (27 tests, 9 risk scenarios).

What the self-audit did not cover

A self-audit is valuable but has limits:

  • Blind spots. The same team that wrote the code audited it. Common biases (familiarity, shared assumptions) are not caught.
  • No adversarial review. A dedicated adversary with weeks to explore attack paths would likely surface additional findings.
  • No Solana-specific class analysis. Solana-specific attack classes (stale account lamports, rent exemption edge cases, CPI semantics) were covered at a working level but not at the depth a Solana-specialized firm would.

This is why external audits are a mainnet-blocking gate.

How to read the full report

docs/SELF-AUDIT-REPORT.md contains every finding with:

  • Full description of the issue.
  • Commit SHAs for the fix.
  • Regression test references.
  • Residual risk assessment.

It’s a ~1,200-line document. For developers integrating with SportsPerp, it’s a good read — understanding what bugs we’ve faced is informative about the kinds of bugs still possible.

Further reading