From 803f537aa9696702fa37bbdd9b63cf6660dccb6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 04:29:57 +0000 Subject: [PATCH 1/4] fix(perps): make Hyperliquid balances decimal-safe Co-authored-by: Matt D. --- .../HyperLiquidSubscriptionService.ts | 21 +-- .../src/utils/accountUtils.ts | 123 ++++++++++-------- .../HyperLiquidProvider.data.test.ts | 55 ++++++++ ...perLiquidSubscriptionService.cache.test.ts | 15 ++- .../tests/src/utils/accountUtils.test.ts | 113 +++++++++++++++- 5 files changed, 243 insertions(+), 84 deletions(-) diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index a45f687eff0..911f832ad15 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -53,8 +53,8 @@ import type { } from '../types/index.js'; import { addSpotBalanceToAccountState, + aggregateAccountStates, calculateWeightedReturnOnEquity, - sumCrossMarginSummaries, } from '../utils/accountUtils.js'; import type { AddSpotBalanceOptions } from '../utils/accountUtils.js'; import { @@ -1399,11 +1399,6 @@ export class HyperLiquidSubscriptionService { totalBalance: string; } > = {}; - let totalSpendableBalance = 0; - let totalWithdrawableBalance = 0; - let totalBalance = 0; - let totalMarginUsed = 0; - let totalUnrealizedPnl = 0; // Collect account states for weighted ROE calculation const accountStatesForROE: { @@ -1420,11 +1415,6 @@ export class HyperLiquidSubscriptionService { withdrawableBalance: state.withdrawableBalance, totalBalance: state.totalBalance, }; - totalSpendableBalance += parseFloat(state.spendableBalance); - totalWithdrawableBalance += parseFloat(state.withdrawableBalance); - totalBalance += parseFloat(state.totalBalance); - totalMarginUsed += parseFloat(state.marginUsed); - totalUnrealizedPnl += parseFloat(state.unrealizedPnl); // Collect data for weighted ROE calculation accountStatesForROE.push({ @@ -1439,7 +1429,7 @@ export class HyperLiquidSubscriptionService { this.#dexAccountCache.values().next().value ?? ({} as AccountState); const { crossMarginSummary: _firstDexSummary, ...firstDexBase } = firstDexAccount; - const crossMarginSummary = sumCrossMarginSummaries( + const aggregatedAccountState = aggregateAccountStates( Array.from(this.#dexAccountCache.values()), ); @@ -1449,12 +1439,7 @@ export class HyperLiquidSubscriptionService { return addSpotBalanceToAccountState( { ...firstDexBase, - ...(crossMarginSummary ? { crossMarginSummary } : {}), - spendableBalance: totalSpendableBalance.toString(), - withdrawableBalance: totalWithdrawableBalance.toString(), - totalBalance: totalBalance.toString(), - marginUsed: totalMarginUsed.toString(), - unrealizedPnl: totalUnrealizedPnl.toString(), + ...aggregatedAccountState, subAccountBreakdown, returnOnEquity, }, diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index f0fd46b22a9..879ccfaf9d4 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -3,7 +3,9 @@ * Handles account selection and EVM account filtering */ import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { BigNumber } from 'bignumber.js'; +import { USDC_DECIMALS } from '../constants/hyperLiquidConfig.js'; import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types.js'; import type { @@ -157,42 +159,33 @@ export function calculateWeightedReturnOnEquity( // not inflate the balances shown or validated by withdraw/payment flows. const SPOT_COLLATERAL_COINS = new Set(['USDC']); -export function getSpotBalance( - spotState?: SpotClearinghouseStateResponse | null, -): number { +function getSpotCollateralAmount( + spotState: SpotClearinghouseStateResponse | null | undefined, + field: 'hold' | 'total', +): BigNumber { if (!spotState?.balances || !Array.isArray(spotState.balances)) { - return 0; + return new BigNumber(0); } - return spotState.balances.reduce( - (sum: number, balance: { coin?: string; total?: string }) => { - if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { - return sum; - } - const value = parseFloat(balance.total ?? '0'); - return Number.isFinite(value) ? sum + value : sum; - }, - 0, - ); + return spotState.balances.reduce((sum, balance) => { + if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { + return sum; + } + const value = new BigNumber(balance[field] ?? '0'); + return value.isFinite() ? sum.plus(value) : sum; + }, new BigNumber(0)); } -export function getSpotHold( +export function getSpotBalance( spotState?: SpotClearinghouseStateResponse | null, ): number { - if (!spotState?.balances || !Array.isArray(spotState.balances)) { - return 0; - } + return getSpotCollateralAmount(spotState, 'total').toNumber(); +} - return spotState.balances.reduce( - (sum: number, balance: { coin?: string; hold?: string }) => { - if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { - return sum; - } - const value = parseFloat(balance.hold ?? '0'); - return Number.isFinite(value) ? sum + value : sum; - }, - 0, - ); +export function getSpotHold( + spotState?: SpotClearinghouseStateResponse | null, +): number { + return getSpotCollateralAmount(spotState, 'hold').toNumber(); } /** @@ -239,21 +232,21 @@ export function addSpotBalanceToAccountState( // over-report withdrawable funds for Standard / default users. const foldIntoCollateral = options?.foldIntoCollateral ?? false; - const spotBalance = getSpotBalance(spotState); - const spotHold = getSpotHold(spotState); - const freeSpot = Math.max(0, spotBalance - spotHold); + const spotBalance = getSpotCollateralAmount(spotState, 'total'); + const spotHold = getSpotCollateralAmount(spotState, 'hold'); + const freeSpot = BigNumber.max(0, spotBalance.minus(spotHold)); - const currentTotal = parseFloat(accountState.totalBalance); - const currentSpendable = parseFloat(accountState.spendableBalance); - const currentWithdrawable = parseFloat(accountState.withdrawableBalance); + const currentTotal = new BigNumber(accountState.totalBalance); + const currentSpendable = new BigNumber(accountState.spendableBalance); + const currentWithdrawable = new BigNumber(accountState.withdrawableBalance); // Preserve sentinel totals (e.g. PERPS_CONSTANTS.FallbackDataDisplay '--') // rather than coercing them to NaN. - if (!Number.isFinite(currentTotal)) { + if (!currentTotal.isFinite()) { return accountState; } - if (spotBalance === 0) { + if (spotBalance.isZero()) { // No spot wealth means no hold either in the HL payload shape, so the // later totalBalance adjustment would also be a no-op. return accountState; @@ -283,27 +276,29 @@ export function addSpotBalanceToAccountState( // double-counting on Unified/PM accounts where marginSummary.accountValue // already includes the margin that HL surfaces via spot.hold. Standard // mode has spotHold = 0 by construction, so the subtraction is a no-op. - const nextTotal = currentTotal + spotBalance - spotHold; + const nextTotal = canonicalizeUsdc( + currentTotal.plus(spotBalance).minus(spotHold), + ); return { ...accountState, - totalBalance: nextTotal.toString(), + totalBalance: nextTotal, spendableBalance: nextSpendable, withdrawableBalance: nextWithdrawable, }; } function resolveFoldedBalance( - currentNumeric: number, + currentNumeric: BigNumber, currentRaw: string, - freeSpot: number, + freeSpot: BigNumber, foldIntoCollateral: boolean, ): string { if (!foldIntoCollateral) { return currentRaw; } - if (Number.isFinite(currentNumeric)) { - return (currentNumeric + freeSpot).toString(); + if (currentNumeric.isFinite()) { + return canonicalizeUsdc(currentNumeric.plus(freeSpot)); } // Non-finite currentNumeric means the adapter passed a sentinel like // `PERPS_CONSTANTS.FallbackDataDisplay` ("--") during loading. Preserve @@ -313,6 +308,10 @@ function resolveFoldedBalance( return currentRaw; } +function canonicalizeUsdc(value: BigNumber): string { + return value.decimalPlaces(USDC_DECIMALS, BigNumber.ROUND_DOWN).toFixed(); +} + /** * Sum per-DEX cross-margin summaries. Undefined unless every state carries * one, so a partial sum never reads as the whole account. @@ -331,8 +330,8 @@ export function sumCrossMarginSummaries( } const sum = (field: keyof CrossMarginSummary): string => summaries - .reduce((total, summary) => total + parseFloat(summary[field]), 0) - .toString(); + .reduce((total, summary) => total.plus(summary[field]), new BigNumber(0)) + .toFixed(); return { accountValue: sum('accountValue'), maintenanceMargin: sum('maintenanceMargin'), @@ -368,16 +367,15 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { } return { - spendableBalance: ( - parseFloat(acc.spendableBalance) + parseFloat(state.spendableBalance) - ).toString(), - withdrawableBalance: ( - parseFloat(acc.withdrawableBalance) + - parseFloat(state.withdrawableBalance) - ).toString(), - totalBalance: ( - parseFloat(acc.totalBalance) + parseFloat(state.totalBalance) - ).toString(), + spendableBalance: addDecimalValues( + acc.spendableBalance, + state.spendableBalance, + ), + withdrawableBalance: addDecimalValues( + acc.withdrawableBalance, + state.withdrawableBalance, + ), + totalBalance: addDecimalValues(acc.totalBalance, state.totalBalance), marginUsed: ( parseFloat(acc.marginUsed) + parseFloat(state.marginUsed) ).toString(), @@ -388,6 +386,14 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { }; }, fallback); + aggregated.spendableBalance = canonicalizeUsdcString( + aggregated.spendableBalance, + ); + aggregated.withdrawableBalance = canonicalizeUsdcString( + aggregated.withdrawableBalance, + ); + aggregated.totalBalance = canonicalizeUsdcString(aggregated.totalBalance); + // Recalculate ROE across all DEXs const totalMarginUsed = parseFloat(aggregated.marginUsed); const totalUnrealizedPnl = parseFloat(aggregated.unrealizedPnl); @@ -407,3 +413,12 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { return aggregated; } + +function addDecimalValues(left: string, right: string): string { + return new BigNumber(left).plus(right).toFixed(); +} + +function canonicalizeUsdcString(value: string): string { + const amount = new BigNumber(value); + return amount.isFinite() ? canonicalizeUsdc(amount) : value; +} diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts index 946f8ddebbb..8b2b4229fdc 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -641,6 +641,61 @@ describe('HyperLiquidProvider', () => { expect(accountState.totalBalance).toBe('90'); }); + it('returns a decimal-safe unified balance with isolated positions across DEXs', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const clearinghouseState = vi + .fn() + .mockImplementation(({ dex }: { dex?: string }) => { + const dexBalance = dex ? '0.2' : '0.1'; + return Promise.resolve({ + marginSummary: { + totalMarginUsed: '0', + accountValue: dexBalance, + }, + withdrawable: dexBalance, + assetPositions: [ + { + position: { + coin: dex ? 'xyz:TSLA' : 'BTC', + unrealizedPnl: '0', + marginUsed: '0', + returnOnEquity: '0', + leverage: { type: 'isolated', value: 10 }, + }, + type: 'oneWay', + }, + ], + }); + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: vi + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState, + spotClearinghouseState: vi.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '2.524821', total: '4.000004' }], + }), + userAbstraction: vi.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = vi.fn().mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('1.775183'); + expect(accountState.withdrawableBalance).toBe('1.775183'); + expect(accountState.totalBalance).toBe('1.775183'); + expect(clearinghouseState).toHaveBeenCalledWith({ user: '0x123' }); + expect(clearinghouseState).toHaveBeenCalledWith({ + user: '0x123', + dex: 'xyz', + }); + }); + it.each(['default', 'disabled'] as const)( 'does not fold USDC spot balance in %s account mode', async (abstractionMode) => { diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts index a7581452611..45adb5f10b2 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -1564,9 +1564,10 @@ describe('HyperLiquidSubscriptionService', () => { // withdrawable (all three carry the same value when perps balances // are zero). Per-DEX subAccountBreakdown entries stay perps-only — // the fold is applied once at the aggregation level, not per DEX. - expect(accountState.totalBalance).toBe('100.76531791'); - expect(accountState.spendableBalance).toBe('100.76531791'); - expect(accountState.withdrawableBalance).toBe('100.76531791'); + // Emitted USDC balances are canonicalized down to six decimals. + expect(accountState.totalBalance).toBe('100.765317'); + expect(accountState.spendableBalance).toBe('100.765317'); + expect(accountState.withdrawableBalance).toBe('100.765317'); expect(accountState.subAccountBreakdown).toEqual({ main: { spendableBalance: '0', @@ -1793,9 +1794,9 @@ describe('HyperLiquidSubscriptionService', () => { // total = perps.accountValue (200) + spot (100.77) = 300.77 // spendable = perps.withdrawable (50) + spot (100.77) = 150.77 // withdrawable = perps.withdrawable (50) + spot (100.77) = 150.77 - expect(firstUpdate.totalBalance).toBe('300.76531791'); - expect(firstUpdate.spendableBalance).toBe('150.76531791'); - expect(firstUpdate.withdrawableBalance).toBe('150.76531791'); + expect(firstUpdate.totalBalance).toBe('300.765317'); + expect(firstUpdate.spendableBalance).toBe('150.765317'); + expect(firstUpdate.withdrawableBalance).toBe('150.765317'); // Simulate a second WebSocket tick — should still include spot balance, // not revert to perps-only 200. @@ -1808,7 +1809,7 @@ describe('HyperLiquidSubscriptionService', () => { if (mockCallback.mock.calls.length > 0) { const secondUpdate = mockCallback.mock.calls.at(-1)?.[0]; - expect(secondUpdate.totalBalance).toBe('300.76531791'); + expect(secondUpdate.totalBalance).toBe('300.765317'); } unsubscribe(); diff --git a/packages/perps-controller/tests/src/utils/accountUtils.test.ts b/packages/perps-controller/tests/src/utils/accountUtils.test.ts index f524cca35ee..abf524c49c9 100644 --- a/packages/perps-controller/tests/src/utils/accountUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/accountUtils.test.ts @@ -25,16 +25,21 @@ describe('aggregateAccountStates', () => { expect(aggregateAccountStates([])).toEqual(fallback); }); - it('returns the single state unchanged when given one element', () => { + it('canonicalizes balances when given one state', () => { const single: AccountState = { - spendableBalance: '100', - withdrawableBalance: '100', - totalBalance: '200', + spendableBalance: '100.000000', + withdrawableBalance: '100.000000', + totalBalance: '200.000000', marginUsed: '50', unrealizedPnl: '10', returnOnEquity: '20', }; - expect(aggregateAccountStates([single])).toEqual(single); + expect(aggregateAccountStates([single])).toEqual({ + ...single, + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + }); }); it('sums numeric fields from two states and recalculates ROE', () => { @@ -186,6 +191,31 @@ describe('aggregateAccountStates', () => { expect(parseFloat(result.marginUsed)).toBeCloseTo(81, 0); expect(parseFloat(result.unrealizedPnl)).toBeCloseTo(17, 0); }); + + it('emits canonical six-decimal balances when aggregating DEX states', () => { + const stateA: AccountState = { + spendableBalance: '0.0000009', + withdrawableBalance: '0.0000009', + totalBalance: '0.0000009', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + const stateB: AccountState = { + spendableBalance: '0.0000009', + withdrawableBalance: '0.0000009', + totalBalance: '0.0000009', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(result.spendableBalance).toBe('0.000001'); + expect(result.withdrawableBalance).toBe('0.000001'); + expect(result.totalBalance).toBe('0.000001'); + }); }); describe('spot balance helpers', () => { @@ -193,6 +223,71 @@ describe('spot balance helpers', () => { expect(getSpotBalance()).toBe(0); }); + it('emits the same exact balance for equivalent decimal inputs', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const results = [ + ['8.000006', '6.524823'], + ['4.000004', '2.524821'], + ].map(([total, hold]) => + addSpotBalanceToAccountState( + accountState, + { balances: [{ coin: 'USDC', total, hold }] } as never, + { foldIntoCollateral: true }, + ), + ); + + expect( + results.map( + ({ spendableBalance, withdrawableBalance, totalBalance }) => ({ + spendableBalance, + withdrawableBalance, + totalBalance, + }), + ), + ).toEqual([ + { + spendableBalance: '1.475183', + withdrawableBalance: '1.475183', + totalBalance: '1.475183', + }, + { + spendableBalance: '1.475183', + withdrawableBalance: '1.475183', + totalBalance: '1.475183', + }, + ]); + }); + + it('preserves a genuine one-micro-USDC balance change', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + const getWithdrawableBalance = (total: string) => + addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total, hold: '6.524823' }], + } as never, + { foldIntoCollateral: true }, + ).withdrawableBalance; + + expect(getWithdrawableBalance('8.000006')).toBe('1.475183'); + expect(getWithdrawableBalance('8.000007')).toBe('1.475184'); + }); + it('bumps totalBalance, spendableBalance, and withdrawableBalance by spot USDC without mutating the input', () => { const accountState: AccountState = { spendableBalance: '0', @@ -471,6 +566,14 @@ describe('cross margin summary aggregation', () => { }); }); + it('sums decimal DEX summaries without floating-point artifacts', () => { + expect( + sumCrossMarginSummaries([withSummary('0.1'), withSummary('0.2')]), + ).toMatchObject({ + accountValue: '0.3', + }); + }); + it('returns undefined when any DEX lacks a summary', () => { expect(sumCrossMarginSummaries([withSummary('100'), base])).toBeUndefined(); expect(sumCrossMarginSummaries([])).toBeUndefined(); From 57202326704481b780aa90e751674995f51b3d18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 04:30:30 +0000 Subject: [PATCH 2/4] docs(perps): record decimal-safe balances Co-authored-by: Matt D. --- packages/perps-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 43e21b52820..b9aa3b7163a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Hyperliquid account balance aggregation now uses decimal-safe arithmetic and emits canonical six-decimal USDC balances, preventing equivalent unified-account balances from differing by one atomic unit ([#11253](https://github.com/MetaMask/core/pull/11253)) - `FeatureFlagConfigurationService` reports each invalid HIP-3 allowlist or blocklist pattern once per service lifetime, keyed by field name and pattern, instead of on every remote feature flag state change ([#11190](https://github.com/MetaMask/core/pull/11190)) - Invalid patterns are still dropped and the current configuration is retained. - Lighter `Position.returnOnEquity` is now a fraction (`-0.107` for -10.7%), the same unit as Hyperliquid positions, instead of a percent, so clients that multiply it by 100 no longer show Lighter position PnL % 100x too large ([#11080](https://github.com/MetaMask/core/pull/11080)) From 76621998bf8606a8245d2108026162e2356fb259 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 05:06:34 +0000 Subject: [PATCH 3/4] fix(perps): defer balance rounding until spot fold Co-authored-by: Matt D. --- .../src/utils/accountUtils.ts | 47 ++++++++++--------- .../tests/src/utils/accountUtils.test.ts | 31 ++++++++++-- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 879ccfaf9d4..5ed4964dcd8 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -246,12 +246,6 @@ export function addSpotBalanceToAccountState( return accountState; } - if (spotBalance.isZero()) { - // No spot wealth means no hold either in the HL payload shape, so the - // later totalBalance adjustment would also be a no-op. - return accountState; - } - // Folding is gated strictly on the resolved abstraction mode (see callers' // `foldIntoCollateral` argument). Standard / DEX-abstraction users keep // perps and spot independent, so spot must NOT surface as a perps- @@ -280,6 +274,14 @@ export function addSpotBalanceToAccountState( currentTotal.plus(spotBalance).minus(spotHold), ); + if ( + nextTotal === accountState.totalBalance && + nextSpendable === accountState.spendableBalance && + nextWithdrawable === accountState.withdrawableBalance + ) { + return accountState; + } + return { ...accountState, totalBalance: nextTotal, @@ -294,18 +296,17 @@ function resolveFoldedBalance( freeSpot: BigNumber, foldIntoCollateral: boolean, ): string { - if (!foldIntoCollateral) { + if (!currentNumeric.isFinite()) { + // Non-finite currentNumeric means the adapter passed a sentinel like + // `PERPS_CONSTANTS.FallbackDataDisplay` ("--") during loading. Preserve + // the sentinel rather than synthesising a numeric fold; the caller's + // UI treats "--" as "loading" and would otherwise show a misleading + // spot-only figure while the per-DEX perps fetch is still in flight. return currentRaw; } - if (currentNumeric.isFinite()) { - return canonicalizeUsdc(currentNumeric.plus(freeSpot)); - } - // Non-finite currentNumeric means the adapter passed a sentinel like - // `PERPS_CONSTANTS.FallbackDataDisplay` ("--") during loading. Preserve - // the sentinel rather than synthesising a numeric fold; the caller's - // UI treats "--" as "loading" and would otherwise show a misleading - // spot-only figure while the per-DEX perps fetch is still in flight. - return currentRaw; + + const spotAmount = foldIntoCollateral ? freeSpot : new BigNumber(0); + return canonicalizeUsdc(currentNumeric.plus(spotAmount)); } function canonicalizeUsdc(value: BigNumber): string { @@ -341,7 +342,9 @@ export function sumCrossMarginSummaries( } /** - * Aggregate multiple per-DEX AccountState objects into one by summing numeric fields. + * Aggregate multiple per-DEX AccountState objects into one by summing numeric + * fields exactly. Collateral subtotals retain their full precision so spot + * collateral can be added before the final USDC canonicalization. * ROE is recalculated as (totalUnrealizedPnl / totalMarginUsed) * 100. * * @param states - The array of per-DEX account states to aggregate. @@ -386,13 +389,13 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { }; }, fallback); - aggregated.spendableBalance = canonicalizeUsdcString( + aggregated.spendableBalance = normalizeDecimalString( aggregated.spendableBalance, ); - aggregated.withdrawableBalance = canonicalizeUsdcString( + aggregated.withdrawableBalance = normalizeDecimalString( aggregated.withdrawableBalance, ); - aggregated.totalBalance = canonicalizeUsdcString(aggregated.totalBalance); + aggregated.totalBalance = normalizeDecimalString(aggregated.totalBalance); // Recalculate ROE across all DEXs const totalMarginUsed = parseFloat(aggregated.marginUsed); @@ -418,7 +421,7 @@ function addDecimalValues(left: string, right: string): string { return new BigNumber(left).plus(right).toFixed(); } -function canonicalizeUsdcString(value: string): string { +function normalizeDecimalString(value: string): string { const amount = new BigNumber(value); - return amount.isFinite() ? canonicalizeUsdc(amount) : value; + return amount.isFinite() ? amount.toFixed() : value; } diff --git a/packages/perps-controller/tests/src/utils/accountUtils.test.ts b/packages/perps-controller/tests/src/utils/accountUtils.test.ts index abf524c49c9..919646777e0 100644 --- a/packages/perps-controller/tests/src/utils/accountUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/accountUtils.test.ts @@ -192,7 +192,7 @@ describe('aggregateAccountStates', () => { expect(parseFloat(result.unrealizedPnl)).toBeCloseTo(17, 0); }); - it('emits canonical six-decimal balances when aggregating DEX states', () => { + it('preserves the exact subtotal when aggregating DEX states', () => { const stateA: AccountState = { spendableBalance: '0.0000009', withdrawableBalance: '0.0000009', @@ -212,9 +212,9 @@ describe('aggregateAccountStates', () => { const result = aggregateAccountStates([stateA, stateB]); - expect(result.spendableBalance).toBe('0.000001'); - expect(result.withdrawableBalance).toBe('0.000001'); - expect(result.totalBalance).toBe('0.000001'); + expect(result.spendableBalance).toBe('0.0000018'); + expect(result.withdrawableBalance).toBe('0.0000018'); + expect(result.totalBalance).toBe('0.0000018'); }); }); @@ -288,6 +288,29 @@ describe('spot balance helpers', () => { expect(getWithdrawableBalance('8.000007')).toBe('1.475184'); }); + it('rounds once after combining fractional perps and spot balances', () => { + const perpsState: AccountState = { + spendableBalance: '10.0000009', + withdrawableBalance: '10.0000009', + totalBalance: '10.0000009', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + aggregateAccountStates([perpsState]), + { + balances: [{ coin: 'USDC', total: '0.0000009', hold: '0' }], + } as never, + { foldIntoCollateral: true }, + ); + + expect(result.spendableBalance).toBe('10.000001'); + expect(result.withdrawableBalance).toBe('10.000001'); + expect(result.totalBalance).toBe('10.000001'); + }); + it('bumps totalBalance, spendableBalance, and withdrawableBalance by spot USDC without mutating the input', () => { const accountState: AccountState = { spendableBalance: '0', From ccb43f9dea2e0dbb6f6ea4673e19ee052d6c1744 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Nov 2026 00:52:33 +0000 Subject: [PATCH 4/4] docs(perps): move balance fix to unreleased Co-authored-by: Matt D. --- packages/perps-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index b9aa3b7163a..e40457b2e8a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Hyperliquid account balance aggregation now uses decimal-safe arithmetic and emits canonical six-decimal USDC balances, preventing equivalent unified-account balances from differing by one atomic unit ([#11253](https://github.com/MetaMask/core/pull/11253)) + ## [22.1.1] ### Changed @@ -43,7 +47,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Hyperliquid account balance aggregation now uses decimal-safe arithmetic and emits canonical six-decimal USDC balances, preventing equivalent unified-account balances from differing by one atomic unit ([#11253](https://github.com/MetaMask/core/pull/11253)) - `FeatureFlagConfigurationService` reports each invalid HIP-3 allowlist or blocklist pattern once per service lifetime, keyed by field name and pattern, instead of on every remote feature flag state change ([#11190](https://github.com/MetaMask/core/pull/11190)) - Invalid patterns are still dropped and the current configuration is retained. - Lighter `Position.returnOnEquity` is now a fraction (`-0.107` for -10.7%), the same unit as Hyperliquid positions, instead of a percent, so clients that multiply it by 100 no longer show Lighter position PnL % 100x too large ([#11080](https://github.com/MetaMask/core/pull/11080))