From a9b711b1963871512a9bba9c48169141ef842f79 Mon Sep 17 00:00:00 2001 From: Flux VPS Backup Date: Tue, 26 May 2026 02:11:33 +0000 Subject: [PATCH 1/2] fix: avoid float precision loss in Limitless balances --- BOUNTY_HANDOFF.md | 73 +++++++++++++++++++ core/src/exchanges/limitless/client.ts | 5 +- core/src/exchanges/limitless/index.ts | 4 +- core/src/exchanges/limitless/utils.ts | 20 +++++ core/test/unit/limitless-balance.core.test.ts | 18 +++++ 5 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 BOUNTY_HANDOFF.md create mode 100644 core/test/unit/limitless-balance.core.test.ts diff --git a/BOUNTY_HANDOFF.md b/BOUNTY_HANDOFF.md new file mode 100644 index 00000000..725eab34 --- /dev/null +++ b/BOUNTY_HANDOFF.md @@ -0,0 +1,73 @@ +# Bounty Handoff: Issue #675 + +## Issue + +https://github.com/pmxt-dev/pmxt/issues/675 + +## Validation / Reproduction + +Validated the raw integer precision loss locally: + +```sh +node - <<'NODE' +const raw = 9007199254740993n; +console.log('legacy', parseFloat(raw.toString()) / 1_000_000); +const scale = 1_000_000n; +console.log('split', Number(raw / scale) + Number(raw % scale) / 1_000_000); +console.log('format parse example', parseFloat('9999999999.999999')); +NODE +``` + +Output: + +```text +legacy 9007199254.740992 +split 9007199254.740993 +format parse example 9999999999.999998 +``` + +Note: Node did not reproduce the issue body's exact `parseFloat("9999999999.999999") = 10000000000` example; it rounded to `9999999999.999998` in this environment. The raw integer `parseFloat(rawBalance.toString()) / Math.pow(...)` loss did reproduce. + +## Diff Summary + +- Added `scaledIntegerToNumber` in `core/src/exchanges/limitless/utils.ts`. +- Updated `core/src/exchanges/limitless/client.ts` balance conversion to avoid `parseFloat(utils.formatUnits(...))`. +- Updated `core/src/exchanges/limitless/index.ts` balance conversion to avoid parsing the raw integer string before scaling and to remove `Math.pow` from this balance path. +- Added `core/test/unit/limitless-balance.core.test.ts` covering the `2^53 + 1` raw balance case and ethers `BigNumber` input. + +## Commands Run + +```sh +gh issue view https://github.com/pmxt-dev/pmxt/issues/675 --json title,body,comments,state,labels,author +``` + +Failed: `gh` is not installed in this environment. + +```sh +rg -n "parseFloat|Math\.pow|formatUnits|formatEther|Number\(" -S . +git status --short && git branch --show-current && git remote -v +npm --workspace=pmxt-core test -- --runTestsByPath core/test/unit/limitless-balance.core.test.ts +``` + +Failed before running tests because dependencies were not installed and `jest` was not found. + +```sh +npm install +npm --workspace=pmxt-core test -- --runTestsByPath core/test/unit/limitless-balance.core.test.ts +``` + +Failed because the workspace script resolves paths from `core/`, producing `core/core/test/...`. + +```sh +npm --workspace=pmxt-core test -- --runTestsByPath test/unit/limitless-balance.core.test.ts +npm --workspace=pmxt-core run build +``` + +## Test Results + +- `npm --workspace=pmxt-core test -- --runTestsByPath test/unit/limitless-balance.core.test.ts`: passed, 2 tests. +- `npm --workspace=pmxt-core run build`: passed. + +## Remaining Risk + +The public balance API still returns `number`, so very large balances remain limited by IEEE-754 representation after the final conversion. This change removes the avoidable extra precision loss from converting the full raw integer to a float before decimal scaling. diff --git a/core/src/exchanges/limitless/client.ts b/core/src/exchanges/limitless/client.ts index 93488fb1..0ba431af 100644 --- a/core/src/exchanges/limitless/client.ts +++ b/core/src/exchanges/limitless/client.ts @@ -1,6 +1,7 @@ import { HttpClient, OrderClient, OrderBuilder, OrderSigner, MarketFetcher, Side, OrderType } from '@limitless-exchange/sdk'; -import { Wallet, providers, Contract, utils } from 'ethers'; +import { Wallet, providers, Contract } from 'ethers'; import { LIMITLESS_RPC_URL } from './config'; +import { scaledIntegerToNumber } from './utils'; const DEFAULT_LIMITLESS_API_URL = process.env.LIMITLESS_BASE_URL || 'https://api.limitless.exchange'; @@ -397,6 +398,6 @@ export class LimitlessClient { const balance = await contract.balanceOf(this.signer.address); const decimals = await contract.decimals(); // Should be 6 - return parseFloat(utils.formatUnits(balance, decimals)); + return scaledIntegerToNumber(balance, Number(decimals)); } } diff --git a/core/src/exchanges/limitless/index.ts b/core/src/exchanges/limitless/index.ts index f081d575..36ed5e72 100644 --- a/core/src/exchanges/limitless/index.ts +++ b/core/src/exchanges/limitless/index.ts @@ -36,7 +36,7 @@ import { LIMITLESS_RPC_URL } from './config'; import { limitlessErrorMapper } from './errors'; import { LimitlessFetcher } from './fetcher'; import { LimitlessNormalizer } from './normalizer'; -import { DEFAULT_LIMITLESS_API_URL } from './utils'; +import { DEFAULT_LIMITLESS_API_URL, scaledIntegerToNumber } from './utils'; import { LimitlessWebSocket, LimitlessWebSocketConfig } from './websocket'; import { logger } from '../../utils/logger'; @@ -582,7 +582,7 @@ export class LimitlessExchange extends PredictionMarketExchange { ); const rawBalance = await usdcContract.balanceOf(targetAddress); const USDC_DECIMALS = 6; - const total = parseFloat(rawBalance.toString()) / Math.pow(10, USDC_DECIMALS); + const total = scaledIntegerToNumber(rawBalance, USDC_DECIMALS); return [{ currency: 'USDC', diff --git a/core/src/exchanges/limitless/utils.ts b/core/src/exchanges/limitless/utils.ts index 78e2b4ae..bfe8c683 100644 --- a/core/src/exchanges/limitless/utils.ts +++ b/core/src/exchanges/limitless/utils.ts @@ -3,6 +3,26 @@ import { addBinaryOutcomes } from '../../utils/market-utils'; export const DEFAULT_LIMITLESS_API_URL = 'https://api.limitless.exchange'; +export function scaledIntegerToNumber(value: bigint | { toBigInt?: () => bigint; toString(): string }, decimals: number): number { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`[limitless] Invalid token decimals: ${decimals}`); + } + + const raw = typeof value === 'bigint' + ? value + : typeof value.toBigInt === 'function' + ? value.toBigInt() + : BigInt(value.toString()); + const sign = raw < 0n ? -1 : 1; + const abs = raw < 0n ? -raw : raw; + const scale = 10n ** BigInt(decimals); + const whole = abs / scale; + const fraction = abs % scale; + const amount = Number(whole) + (Number(fraction) / Number(scale)); + + return sign * amount; +} + export interface LimitlessMarketContext { eventId?: string; eventTitle?: string; diff --git a/core/test/unit/limitless-balance.core.test.ts b/core/test/unit/limitless-balance.core.test.ts new file mode 100644 index 00000000..c62925b3 --- /dev/null +++ b/core/test/unit/limitless-balance.core.test.ts @@ -0,0 +1,18 @@ +import { BigNumber } from 'ethers'; +import { scaledIntegerToNumber } from '../../src/exchanges/limitless/utils'; + +describe('Limitless balance conversion', () => { + test('scales raw integer balances before converting to number', () => { + const raw = 9007199254740993n; + const legacy = parseFloat(raw.toString()) / 1_000_000; + + expect(legacy).toBe(9007199254.740992); + expect(scaledIntegerToNumber(raw, 6)).toBe(9007199254.740993); + }); + + test('accepts ethers BigNumber balances without formatUnits parsing', () => { + const raw = BigNumber.from('1234567890123'); + + expect(scaledIntegerToNumber(raw, 6)).toBe(1234567.890123); + }); +}); From cac3480ce367dcff25ef474eb762f550595558c2 Mon Sep 17 00:00:00 2001 From: Flux VPS Backup Date: Tue, 26 May 2026 03:13:29 +0000 Subject: [PATCH 2/2] chore: remove local bounty handoff from PR --- BOUNTY_HANDOFF.md | 73 ----------------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 BOUNTY_HANDOFF.md diff --git a/BOUNTY_HANDOFF.md b/BOUNTY_HANDOFF.md deleted file mode 100644 index 725eab34..00000000 --- a/BOUNTY_HANDOFF.md +++ /dev/null @@ -1,73 +0,0 @@ -# Bounty Handoff: Issue #675 - -## Issue - -https://github.com/pmxt-dev/pmxt/issues/675 - -## Validation / Reproduction - -Validated the raw integer precision loss locally: - -```sh -node - <<'NODE' -const raw = 9007199254740993n; -console.log('legacy', parseFloat(raw.toString()) / 1_000_000); -const scale = 1_000_000n; -console.log('split', Number(raw / scale) + Number(raw % scale) / 1_000_000); -console.log('format parse example', parseFloat('9999999999.999999')); -NODE -``` - -Output: - -```text -legacy 9007199254.740992 -split 9007199254.740993 -format parse example 9999999999.999998 -``` - -Note: Node did not reproduce the issue body's exact `parseFloat("9999999999.999999") = 10000000000` example; it rounded to `9999999999.999998` in this environment. The raw integer `parseFloat(rawBalance.toString()) / Math.pow(...)` loss did reproduce. - -## Diff Summary - -- Added `scaledIntegerToNumber` in `core/src/exchanges/limitless/utils.ts`. -- Updated `core/src/exchanges/limitless/client.ts` balance conversion to avoid `parseFloat(utils.formatUnits(...))`. -- Updated `core/src/exchanges/limitless/index.ts` balance conversion to avoid parsing the raw integer string before scaling and to remove `Math.pow` from this balance path. -- Added `core/test/unit/limitless-balance.core.test.ts` covering the `2^53 + 1` raw balance case and ethers `BigNumber` input. - -## Commands Run - -```sh -gh issue view https://github.com/pmxt-dev/pmxt/issues/675 --json title,body,comments,state,labels,author -``` - -Failed: `gh` is not installed in this environment. - -```sh -rg -n "parseFloat|Math\.pow|formatUnits|formatEther|Number\(" -S . -git status --short && git branch --show-current && git remote -v -npm --workspace=pmxt-core test -- --runTestsByPath core/test/unit/limitless-balance.core.test.ts -``` - -Failed before running tests because dependencies were not installed and `jest` was not found. - -```sh -npm install -npm --workspace=pmxt-core test -- --runTestsByPath core/test/unit/limitless-balance.core.test.ts -``` - -Failed because the workspace script resolves paths from `core/`, producing `core/core/test/...`. - -```sh -npm --workspace=pmxt-core test -- --runTestsByPath test/unit/limitless-balance.core.test.ts -npm --workspace=pmxt-core run build -``` - -## Test Results - -- `npm --workspace=pmxt-core test -- --runTestsByPath test/unit/limitless-balance.core.test.ts`: passed, 2 tests. -- `npm --workspace=pmxt-core run build`: passed. - -## Remaining Risk - -The public balance API still returns `number`, so very large balances remain limited by IEEE-754 representation after the final conversion. This change removes the avoidable extra precision loss from converting the full raw integer to a float before decimal scaling.