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:
- 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.
- 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.
- 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:
| Quantity | Scale | Rust type | Example |
|---|---|---|---|
| Prices (oracle, mark, entry) | 10⁶ | i64 | Index 500.0 → 500_000_000 |
| Collateral, size, amounts | 10⁶ | u64 | 100 USDC → 100_000_000 |
| Leverage | 10² | u16 | 5x → 500, 2x → 200 |
| Basis points (fees, margins, rates) | 10⁴ | u16 / u64 | 100% → 10000, 1% → 100, 0.1% → 10 |
| Funding accumulators | 10¹² | i64 | High-precision cumulative rate |
| Per-event OBV | Native float, quantized on ingest | N/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 insdk/src/math/, and vice versa, in the same commit.
Parity is enforced by:
- Test vectors.
sdk/tests/math.test.tsincludes test cases for each function. The expected outputs are the same numbers the Rust tests assert (programs/obv-perps/src/math/*.rs #[cfg(test)]). - Devnet integration tests.
sdk/tests/devnet-integration.test.tsopens 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. - Code review discipline. The Development Guidelines in
CLAUDE.mdspecifically 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) / entryMargin ratio
margin_ratio_bps = (collateral + pnl) × 10000 / sizeFunding payment
payment = size × (cumulative_now − cumulative_at_entry) / 10^6Composite 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)) / 10000Every 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)
u128intermediate: ~3.4 × 10³⁸ — ample for anyu64 × u64product
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_pnluses 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-closeAll 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
programs/obv-perps/src/math/— Rust implementation (PnL, margin, funding, mark, liquidation).sdk/src/math/— TypeScript mirror.- Entry Price & PnL — practical use of the PnL formulas.
- SDK — how to consume the math helpers in your own application.