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 date | April 17, 2026 |
| Auditor | Internal (Klaw) |
| Scope | 27 Anchor instructions + 7 account types + 6 math modules + 6 off-chain services |
| Findings total | 17 |
| Critical | 1 (fixed) |
| High | 5 (all fixed) |
| Medium | 4 (documented, non-blocking) |
| Low | 4 (documented) |
| Informational | 3 (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
closeconstraint 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:
- Expired StopLoss — trigger closed, position survives.
- Expired TakeProfit — trigger closed, position survives.
- Expired LimitOpen — trigger closed, collateral refunded, no ghost PDA.
- Non-expired orders still execute normally.
- 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_collateralandwithdraw_collateralsettle 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
- Audits — the external audit process.
- Bug Bounty — rewards for external findings.
- Smart Contract Risk — the risk framing these findings sit within.