🚧 SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
Protocol ArchitectureFixed-Point Math

Fixed-Point Math

SportsPerp uses integer fixed-point arithmetic throughout — no floats in any on-chain calculation, no floats in any math the program depends on. This page documents the scales, the parity rule between Rust and TypeScript, and the discipline that keeps them in sync.

Why no floats

Floats are forbidden for three reasons:

  1. Determinism. Solana’s BPF runtime does not expose IEEE-754 floating-point; even if it did, rounding differences between architectures would mean two validators could disagree on the same instruction. Integer arithmetic is bit-exact on every machine.
  2. Auditability. A trader should be able to reproduce any on-chain calculation themselves from the open-source inputs. Integer math is mechanically verifiable; float math depends on compiler flags, CPU generation, and library version.
  3. Overflow safety. All integer operations in the program use checked_add, checked_mul, checked_div. An overflow becomes an explicit error, not undefined behavior or NaN propagation.

The scales

Every quantity in the program has a documented fixed-point scale:

QuantityScaleRust typeExample
Prices (oracle, mark, entry)10⁶i64Index 500.0 → 500_000_000
Collateral, size, amounts10⁶u64100 USDC → 100_000_000
Leverage10²u165x → 500, 2x → 200
Basis points (fees, margins, rates)10⁴u16 / u64100% → 10000, 1% → 100, 0.1% → 10
Funding accumulators10¹²i64High-precision cumulative rate
Per-event OBVNative float, quantized on ingestN/A (off-chain)+0.08 → 80_000 at 10⁶ scale

Prices and amounts share the same 10⁶ scale. This is deliberate — it means position_size (notional in USDC) and mark_price × collateral are directly comparable without conversion factors.

Basis points are the universal 10⁴ scale for percentages. 100% = 10000 bps. This matches every other DeFi protocol and simplifies cross-protocol integrations.

Funding accumulators use 10¹² scale — the extra six digits of precision prevent rounding loss over long holding periods when a per-interval delta can be ~100 at 10⁶ scale but aggregates over many intervals.

Multiplication and division order

The cardinal rule of fixed-point: multiply first, divide last to preserve precision.

// Correct — preserves precision
let payment = size_i
    .checked_mul(diff)?                    // (u128-safe intermediate)
    .checked_div(1_000_000)?;              // rescale last
 
// Wrong — accumulates rounding error
let payment = size_i * (diff / 1_000_000);

All multiplications escalate to u128 or i128 before division. This gives 128 bits of headroom — enough to cover the product of any two u64 (up to 2^127) without overflow. The final division brings the result back to a fixed-point representation at the correct scale.

Mirror rule: Rust ↔ TypeScript

The program and the SDK both implement pricing math. The rule:

Every math change in programs/obv-perps/src/math/ must be mirrored in sdk/src/math/, and vice versa, in the same commit.

Parity is enforced by:

  1. Test vectors. sdk/tests/math.test.ts includes test cases for each function. The expected outputs are the same numbers the Rust tests assert (programs/obv-perps/src/math/*.rs #[cfg(test)]).
  2. Devnet integration tests. sdk/tests/devnet-integration.test.ts opens a real position, computes the expected PnL in TS via the SDK, closes the position, and asserts the on-chain settlement amount equals the SDK-predicted amount to the last integer.
  3. Code review discipline. The Development Guidelines in CLAUDE.md specifically call out the mirror requirement. PRs that change one side without the other are rejected at review.

BigInt in TypeScript

JavaScript’s native number is a double-precision float — exactly what we’re avoiding. The SDK uses BN.js for 256-bit arbitrary-precision integers, with a thin wrapper that mirrors Rust semantics:

import BN from "bn.js";
 
// Compute PnL on a long position (mirrors calculate_pnl in Rust)
function calculatePnl(direction: Direction, size: BN, entry: BN, mark: BN): BN {
  if (direction === Direction.Long) {
    return size.mul(mark.sub(entry)).div(entry);
  } else {
    return size.mul(entry.sub(mark)).div(entry);
  }
}

No floats. Same multiplication-before-division order. Same result as the Rust program’s calculate_pnl.

Representative formulas

Here are the core formulas as they appear in both Rust and TypeScript:

PnL

Long:  pnl = size × (mark − entry) / entry
Short: pnl = size × (entry − mark) / entry

Margin ratio

margin_ratio_bps = (collateral + pnl) × 10000 / size

Funding payment

payment = size × (cumulative_now − cumulative_at_entry) / 10^6

Composite mark

vamm_mid = oracle × (1 + (long_oi − short_oi) / (long_oi + short_oi) × impact_factor / 10^6)
composite = (oracle × oracle_weight_bps + vamm_mid × (10000 − oracle_weight_bps)) / 10000

Every one of these has a unit test in Rust that asserts the exact integer output for specific inputs, and a corresponding TS test that asserts the same output.

Overflow boundaries

At 10⁶ scale on u64:

  • Max representable value: ~1.8 × 10¹³ (1.8 × 10⁷ USDC = ~$18M)
  • u128 intermediate: ~3.4 × 10³⁸ — ample for any u64 × u64 product

At 10¹² scale on i64 (funding accumulator):

  • Max representable value: ~9.2 × 10⁶ (9.2 million at the 10¹² scale represents a cumulative funding rate of ~9.2)
  • Realistic usage: cumulative funding over a full year caps at < 1 at 10¹² scale → millions of years of headroom

The program enforces size ≤ 50% of effective_oi (5-tier-based, where effective_oi = max(market_total_oi, initial_capacity)) explicitly, and the global pool_token is capped at a reasonable devnet scale, so overflow is a theoretical concern, not a practical one.

Rounding semantics

All integer division in Rust uses truncation toward zero (same as Math.trunc in JavaScript). This is the simplest rule and is deterministic.

Consequences:

  • Closing a position can leave 1 unit (1e-6 USDC) of rounding dust on the final divide. Dust is absorbed by the protocol (effectively flows to the insurance fund over many trades).
  • Liquidator rewards round down. A 5% reward on 17 units rounds to 0, not 1. The liquidator pays the transaction fee for an instruction that returns zero to them. In practice, Solana TX fees are too low for this to matter; liquidator rewards on real positions are always meaningful amounts.
  • PnL is symmetric around zero. calculate_pnl uses the same formula in both directions — the truncation bias applies equally to longs and shorts.

The better-sqlite3 candle store is a separate concern

Candle data persisted to SQLite (via the crank) uses REAL columns and thus is floating-point. This is acceptable because:

  • Candles are for display and historical querying, not for any margin or settlement math.
  • Every candle’s price is reconstructable from the on-chain oracle history.
  • The SQLite store is considered derived, not canonical.

The distinction keeps the fixed-point discipline where it matters (any math that affects custody) without paying a cost for historical data where precision at the 10⁶ level would bloat storage unnecessarily.

Testing the parity

Before any commit that touches math in either language, the expected flow is:

# Rust side
cargo test --manifest-path programs/obv-perps/Cargo.toml
 
# TypeScript side
cd sdk && npm test
 
# Cross-check: devnet integration opens + closes a real position
yarn test:e2e:open-close

All three green → math parity is verified end-to-end. If any fail, the canonical source is the Rust program; the SDK is adjusted to match.

Further reading