Skip to content

fix: avoid float precision loss in Limitless balances#683

Merged
realfishsam merged 2 commits into
pmxt-dev:mainfrom
00anon0X:codex/bounty-675-202605260113
Jun 1, 2026
Merged

fix: avoid float precision loss in Limitless balances#683
realfishsam merged 2 commits into
pmxt-dev:mainfrom
00anon0X:codex/bounty-675-202605260113

Conversation

@00anon0X

Copy link
Copy Markdown

Fixes #675.

This avoids precision loss when converting raw USDC integer balances for Limitless.

Changes:

  • Adds a shared scaledIntegerToNumber helper using integer division/modulo before final number conversion.
  • Replaces unsafe balance conversions using parseFloat(utils.formatUnits(...)) and parseFloat(rawBalance.toString()) / Math.pow(...).
  • Adds unit coverage for a raw balance above Number.MAX_SAFE_INTEGER and ethers BigNumber input.

Verification:

  • npm --workspace=pmxt-core test -- --runInBand core/test/unit/limitless-balance.core.test.ts
  • npm --workspace=pmxt-core run build

@realfishsam realfishsam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: VERIFIED

What This Does

Adds a scaledIntegerToNumber(value, decimals) helper in limitless/utils.ts that performs integer division/modulo in bigint arithmetic before converting to float, eliminating the precision loss that occurs when rawBalance > Number.MAX_SAFE_INTEGER (≈ 9 quadrillion USDC raw units). Replaces two unsafe conversion call sites: parseFloat(rawBalance.toString()) / Math.pow(10, USDC_DECIMALS) in index.ts and parseFloat(utils.formatUnits(balance, decimals)) in client.ts.

Blast Radius

  • Limitless only — two internal methods (LimitlessExchange.fetchBalance in index.ts and LimitlessClient.getBalance in client.ts)
  • No changes to unified types, OpenAPI schema, SDK clients, or other exchanges
  • The helper is not exported from the exchange index.ts, so it doesn't widen the public API surface

Consumer Verification

Before (base branch — parseFloat(rawBalance.toString()) / Math.pow(10, USDC_DECIMALS)):

raw = 9007199254740993n  (> Number.MAX_SAFE_INTEGER)
parseFloat("9007199254740993")  →  9007199254740992  (rounds, loses the last digit)
9007199254740992 / 1_000_000    →  9007199254.740992  ← WRONG (off by 1 ulp in the 6th decimal)

fetchBalance would return 9007199254.740992 USDC instead of 9007199254.740993 USDC.

After (PR branch — scaledIntegerToNumber):

raw = 9007199254740993n
whole   = 9007199254740993n / 1000000n  =  9007199254n  (exact bigint division)
fraction= 9007199254740993n % 1000000n  =    740993n    (exact bigint remainder)
result  = Number(9007199254) + Number(740993)/Number(1000000)
        = 9007199254 + 0.740993  =  9007199254.740993  ← CORRECT

Both the index.ts path (native bigint from on-chain call) and the client.ts path (ethers v5 BigNumber via toBigInt() / toString() fallback) are fixed. Confirmed in Node.js:

BEFORE (parseFloat/pow):        9007199254.740992   WRONG (off by 1 ulp)
AFTER  (scaledIntegerToNumber): 9007199254.740993   CORRECT
BigNumber toString fallback:    1234567.890123      CORRECT
decimals=0 edge case:           42                  CORRECT

Server starts cleanly on PR branch; POST /api/limitless/fetchBalance is reachable (returns expected auth error without credentials, as intended).


Test Results

  • Build: PASS (tsc clean)
  • New unit tests (limitless-balance.core.test.ts): PASS (2/2)
  • Full suite on PR branch: 3 suites failing, 53 tests failing
  • Full suite on main: 4 suites failing, 57 tests failing

The 3 failing suites on the PR branch (exchange-normalizers, param-forwarding, watch-order-book-api) are identical to failures on main — pre-existing, unrelated to this change. The PR branch actually resolves a 4th suite (schema-drift.test.ts) that fails on current main, because the PR is based on an older commit (389e781) that predates the drift regression on main.

  • E2E smoke (Limitless fetchMarkets): PASS — live markets returned with correct shape

Findings

  1. Number(decimals) in client.ts:401 relies on implicit toString() coercioncontract.decimals() returns an ethers v5 BigNumber; Number(bigNumber) falls through to toString() (returns "6") then converts, giving 6. This works and is validated by scaledIntegerToNumber's guard, but Number(decimals.toString()) or decimals.toNumber() would be more explicit. Not blocking.

  2. index.ts:316–320 still contains parseFloat(m.matchedSize) ... / Math.pow(10, USDC_DECIMALS) for order fill sizes — out of scope for this PR (those values originate as API string floats, not raw on-chain integers, so the overflow path via > MAX_SAFE_INTEGER is different). Not a regression introduced here.

  3. mergeable_state: "dirty" — branch needs a rebase onto current main before it can be merged. Unrelated to correctness of the fix.


PMXT Pipeline Check

  • Field propagation (3-layer): N/A — no new fields
  • OpenAPI sync: N/A — no schema changes
  • Financial precision: OK — this PR is precisely fixing a financial precision bug
  • Type safety: OK — no any introduced, no ! assertions, generic type union handles both bigint and ethers BigNumber
  • Auth safety: N/A

Semver Impact

patch — pure bug fix to an internal balance conversion helper, no API surface change

Risk

On-chain balance calls require real credentials on Base mainnet, so the exact balance return value at the fetchBalance API boundary was verified via unit test and math proof rather than a live end-to-end call. The negative-balance branch in scaledIntegerToNumber is unreachable in practice (ERC-20 balanceOf returns uint256), but the guard is harmless.


Generated by Claude Code

@realfishsam realfishsam force-pushed the codex/bounty-675-202605260113 branch from cac3480 to 11a52bb Compare June 1, 2026 11:50
@realfishsam realfishsam merged commit d8d6210 into pmxt-dev:main Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Float safety: Limitless balance parseFloat(formatUnits) and parseFloat / Math.pow float conversions

2 participants