Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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))
- Include `number_orders_canceled` in batch `OrderCancelTransaction` events, counting successful cancellations and reporting zero for empty or failed batches. ([#11276](https://github.com/MetaMask/core/pull/11276))

## [22.1.1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand All @@ -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({
Expand All @@ -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()),
);

Expand All @@ -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,
},
Expand Down
154 changes: 86 additions & 68 deletions packages/perps-controller/src/utils/accountUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -157,42 +159,33 @@ export function calculateWeightedReturnOnEquity(
// not inflate the balances shown or validated by withdraw/payment flows.
const SPOT_COLLATERAL_COINS = new Set<string>(['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();
}

/**
Expand Down Expand Up @@ -239,23 +232,17 @@ 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)) {
return accountState;
}

if (spotBalance === 0) {
// No spot wealth means no hold either in the HL payload shape, so the
// later totalBalance adjustment would also be a no-op.
if (!currentTotal.isFinite()) {
return accountState;
}

Expand Down Expand Up @@ -283,34 +270,47 @@ 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),
);

if (
nextTotal === accountState.totalBalance &&
nextSpendable === accountState.spendableBalance &&
nextWithdrawable === accountState.withdrawableBalance
) {
return accountState;
}

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) {
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 (Number.isFinite(currentNumeric)) {
return (currentNumeric + freeSpot).toString();
}
// 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 {
return value.decimalPlaces(USDC_DECIMALS, BigNumber.ROUND_DOWN).toFixed();
}

/**
Expand All @@ -331,8 +331,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'),
Expand All @@ -342,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.
Expand All @@ -368,16 +370,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(),
Expand All @@ -388,6 +389,14 @@ export function aggregateAccountStates(states: AccountState[]): AccountState {
};
}, fallback);

aggregated.spendableBalance = normalizeDecimalString(
aggregated.spendableBalance,
);
aggregated.withdrawableBalance = normalizeDecimalString(
aggregated.withdrawableBalance,
);
aggregated.totalBalance = normalizeDecimalString(aggregated.totalBalance);

// Recalculate ROE across all DEXs
const totalMarginUsed = parseFloat(aggregated.marginUsed);
const totalUnrealizedPnl = parseFloat(aggregated.unrealizedPnl);
Expand All @@ -407,3 +416,12 @@ export function aggregateAccountStates(states: AccountState[]): AccountState {

return aggregated;
}

function addDecimalValues(left: string, right: string): string {
return new BigNumber(left).plus(right).toFixed();
}

function normalizeDecimalString(value: string): string {
const amount = new BigNumber(value);
return amount.isFinite() ? amount.toFixed() : value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading