🚧 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 published SDK and documented constants. 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⁶i64Per-unit cumulative funding
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 the same 10⁶ scale as prices and USDC amounts (KISS — no separate precision factor). The cumulative per-unit funding is settled as size × Δaccumulator / 10⁶; a per-interval delta is ~1000 at the ±10 bps cap, so even a year of capped funding (~1.1 million) stays far below the i64 ceiling.

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 the on-chain math modules must be mirrored in the SDK’s math modules, and vice versa, in the same commit.

Parity is enforced by:

  1. Test vectors. The SDK math test suite includes test cases for each function. The expected outputs are the same numbers the Rust unit tests assert.
  2. Devnet integration tests. The SDK devnet-integration suite 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 project’s development guidelines 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: the i64 max of ~9.2 × 10¹⁸ at the 10⁶ scale represents a cumulative per-unit funding magnitude of ~9.2 × 10¹²
  • Realistic usage: a per-interval delta is ~1000 at the ±10 bps cap, so a full year of capped funding accumulates only ~1.1 million → astronomically below the ceiling

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

  • The on-chain Rust math modules — implementation of PnL, margin, funding, mark, liquidation.
  • The SDK math modules — the TypeScript mirror.
  • Entry Price & PnL — practical use of the PnL formulas.
  • SDK — how to consume the math helpers in your own application.