Skip to content
Draft
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
12 changes: 6 additions & 6 deletions packages/perps-controller/.sync-state.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"lastSyncedMobileCommit": "c2248a8a9e83e02f1fcb1b95ec33c23f5cde2a5d",
"lastSyncedMobileBranch": "sync-perps-core",
"lastSyncedCoreCommit": "e30c0b11e0808242382f62283b1643cc6723cf4a",
"lastSyncedCoreBranch": "latest-perps-sync",
"lastSyncedDate": "2026-04-17T16:42:46Z",
"sourceChecksum": "8cd74a6ee57a4ead596f0eea26176aa9d386da905b6306928b347aafbf28a0c0"
"lastSyncedMobileCommit": "9adef4fea5ba0a50cd9e30267b01e5904a9ba188",
"lastSyncedMobileBranch": "perps/fix-available-to-trade-balance-mobile-wt",
"lastSyncedCoreCommit": "c78a9d48ac2f4fdc55abb88723febb58463ad469",
"lastSyncedCoreBranch": "perps/fix-available-to-trade-balance-core-wt",
"lastSyncedDate": "2026-04-21T04:57:05Z",
"sourceChecksum": "476f0a41f9219d014fca19b0dbf642b46e45469bea4209d957199bf30a7fdaae"
}
8 changes: 8 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `availableToTradeBalance` to perps account state so funded-state checks can distinguish HyperLiquid spot-backed buying power from withdrawable balance ([#0000](https://github.com/MetaMask/core/pull/0000))

### Fixed

- Include spot USDC in HyperLiquid funded-state balance for both one-shot account fetches and live streamed account updates while keeping `availableBalance` mapped to withdrawable funds ([#0000](https://github.com/MetaMask/core/pull/0000))

## [3.2.0]

### Added
Expand Down
15 changes: 11 additions & 4 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ import type {
} from '../types/hyperliquid-types';
import type { PerpsControllerMessengerBase } from '../types/messenger';
import type { ExtendedAssetMeta, ExtendedPerpDex } from '../types/perps-types';
import { aggregateAccountStates } from '../utils/accountUtils';
import {
addSpotUsdcToAvailableToTradeBalance,
aggregateAccountStates,
} from '../utils/accountUtils';
import { ensureError } from '../utils/errorUtils';
import {
adaptAccountStateFromSDK,
Expand Down Expand Up @@ -5691,6 +5694,10 @@ export class HyperLiquidProvider implements PerpsProvider {
aggregatedAccountState.totalBalance = (
parseFloat(aggregatedAccountState.totalBalance) + spotBalance
).toString();
const spotAdjustedAccountState = addSpotUsdcToAvailableToTradeBalance(
aggregatedAccountState,
spotState,
);

// Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts)
const subAccountBreakdown: Record<
Expand All @@ -5709,14 +5716,14 @@ export class HyperLiquidProvider implements PerpsProvider {
});

// Add sub-account breakdown to result
aggregatedAccountState.subAccountBreakdown = subAccountBreakdown;
spotAdjustedAccountState.subAccountBreakdown = subAccountBreakdown;

this.#deps.debugLogger.log(
'Aggregated account state:',
aggregatedAccountState,
spotAdjustedAccountState,
);

return aggregatedAccountState;
return spotAdjustedAccountState;
} catch (error) {
this.#deps.logger.error(
ensureError(error, 'HyperLiquidProvider.getAccountState'),
Expand Down
2 changes: 2 additions & 0 deletions packages/perps-controller/src/providers/MYXProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ export class MYXProvider implements PerpsProvider {
});
return {
availableBalance: '0',
availableToTradeBalance: '0',
totalBalance: '0',
marginUsed: '0',
unrealizedPnl: '0',
Expand Down Expand Up @@ -937,6 +938,7 @@ export class MYXProvider implements PerpsProvider {
() =>
params.callback({
availableBalance: '0',
availableToTradeBalance: '0',
totalBalance: '0',
marginUsed: '0',
unrealizedPnl: '0',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ import type {
PerpsPlatformDependencies,
PerpsLogger,
} from '../types';
import { calculateWeightedReturnOnEquity } from '../utils/accountUtils';
import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types';
import {
addSpotUsdcToAvailableToTradeBalance,
calculateWeightedReturnOnEquity,
} from '../utils/accountUtils';
import { ensureError } from '../utils/errorUtils';
import {
adaptPositionFromSDK,
Expand Down Expand Up @@ -157,6 +161,12 @@ export class HyperLiquidSubscriptionService {

readonly #dexAccountCache = new Map<string, AccountState>(); // Per-DEX account state

#cachedSpotState: SpotClearinghouseStateResponse | null = null;

#cachedSpotStateUserAddress: string | null = null;

#spotStatePromise?: Promise<void>;

#cachedPositions: Position[] | null = null; // Aggregated positions

#cachedOrders: Order[] | null = null; // Aggregated orders
Expand Down Expand Up @@ -684,7 +694,7 @@ export class HyperLiquidSubscriptionService {
}

#hashAccountState(account: AccountState): string {
return `${account.availableBalance}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`;
return `${account.availableBalance}:${account.availableToTradeBalance}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`;
}

// Cache hashes to avoid recomputation
Expand Down Expand Up @@ -943,6 +953,7 @@ export class HyperLiquidSubscriptionService {
{ availableBalance: string; totalBalance: string }
> = {};
let totalAvailableBalance = 0;
let totalAvailableToTradeBalance = 0;
let totalBalance = 0;
let totalMarginUsed = 0;
let totalUnrealizedPnl = 0;
Expand All @@ -962,6 +973,9 @@ export class HyperLiquidSubscriptionService {
totalBalance: state.totalBalance,
};
totalAvailableBalance += parseFloat(state.availableBalance);
totalAvailableToTradeBalance += parseFloat(
state.availableToTradeBalance,
);
totalBalance += parseFloat(state.totalBalance);
totalMarginUsed += parseFloat(state.marginUsed);
totalUnrealizedPnl += parseFloat(state.unrealizedPnl);
Expand All @@ -981,15 +995,63 @@ export class HyperLiquidSubscriptionService {
// Calculate weighted returnOnEquity across all DEXs
const returnOnEquity = calculateWeightedReturnOnEquity(accountStatesForROE);

return {
...firstDexAccount,
availableBalance: totalAvailableBalance.toString(),
totalBalance: totalBalance.toString(),
marginUsed: totalMarginUsed.toString(),
unrealizedPnl: totalUnrealizedPnl.toString(),
subAccountBreakdown,
returnOnEquity,
};
return addSpotUsdcToAvailableToTradeBalance(
{
...firstDexAccount,
availableBalance: totalAvailableBalance.toString(),
availableToTradeBalance: totalAvailableToTradeBalance.toString(),
totalBalance: totalBalance.toString(),
marginUsed: totalMarginUsed.toString(),
unrealizedPnl: totalUnrealizedPnl.toString(),
subAccountBreakdown,
returnOnEquity,
},
this.#cachedSpotState,
);
}

async #ensureSpotState(accountId?: CaipAccountId): Promise<void> {
const userAddress =
await this.#walletService.getUserAddressWithDefault(accountId);

if (
this.#cachedSpotState &&
this.#cachedSpotStateUserAddress === userAddress
) {
return;
}

if (this.#spotStatePromise) {
await this.#spotStatePromise;
return;
}

this.#spotStatePromise = this.#refreshSpotState(userAddress);

try {
await this.#spotStatePromise;
} finally {
this.#spotStatePromise = undefined;
}
}

async #refreshSpotState(userAddress: string): Promise<void> {
try {
const infoClient = this.#clientService.getInfoClient();
this.#cachedSpotState = await infoClient.spotClearinghouseState({
user: userAddress,
});
this.#cachedSpotStateUserAddress = userAddress;

if (this.#dexAccountCache.size > 0) {
this.#aggregateAndNotifySubscribers();
}
} catch (error) {
this.#logErrorUnlessClearing(
ensureError(error, 'HyperLiquidSubscriptionService.refreshSpotState'),
this.#getErrorContext('refreshSpotState'),
);
}
}

/**
Expand Down Expand Up @@ -1943,6 +2005,8 @@ export class HyperLiquidSubscriptionService {
this.#cachedPositions = null;
this.#cachedOrders = null;
this.#cachedAccount = null;
this.#cachedSpotState = null;
this.#cachedSpotStateUserAddress = null;
this.#ordersCacheInitialized = false; // Reset cache initialization flag
this.#positionsCacheInitialized = false; // Reset cache initialization flag

Expand Down Expand Up @@ -2252,11 +2316,18 @@ export class HyperLiquidSubscriptionService {
// Increment account subscriber count
this.#accountSubscriberCount += 1;

// Immediately provide cached data if available
if (this.#cachedAccount) {
// Immediately provide cached data if available and already spot-adjusted
if (this.#cachedAccount && this.#cachedSpotState) {
callback(this.#cachedAccount);
}

this.#ensureSpotState(accountId).catch((error) => {
this.#logErrorUnlessClearing(
ensureError(error, 'HyperLiquidSubscriptionService.subscribeToAccount'),
this.#getErrorContext('subscribeToAccount.ensureSpotState'),
);
});

// Ensure shared subscription is active (reuses existing connection)
this.#ensureSharedWebData3Subscription(accountId).catch((error) => {
this.#logErrorUnlessClearing(
Expand Down Expand Up @@ -3654,6 +3725,8 @@ export class HyperLiquidSubscriptionService {
this.#cachedPositions = null;
this.#cachedOrders = null;
this.#cachedAccount = null;
this.#cachedSpotState = null;
this.#cachedSpotStateUserAddress = null;
this.#cachedFills = null;
this.#ordersCacheInitialized = false; // Reset cache initialization flag
this.#positionsCacheInitialized = false; // Reset cache initialization flag
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export type Position = {
// Using 'type' instead of 'interface' for BaseController Json compatibility
export type AccountState = {
availableBalance: string; // Based on HyperLiquid: withdrawable
availableToTradeBalance: string; // Buying power for funded-state checks (HyperLiquid: withdrawable + spot USDC)
totalBalance: string; // Based on HyperLiquid: accountValue
marginUsed: string; // Based on HyperLiquid: marginUsed
unrealizedPnl: string; // Based on HyperLiquid: unrealizedPnl
Expand Down
71 changes: 71 additions & 0 deletions packages/perps-controller/src/utils/accountUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api';

import { PERPS_CONSTANTS } from '../constants/perpsConfig';
import type { AccountState, PerpsInternalAccount } from '../types';
import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types';

const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']);

Expand Down Expand Up @@ -89,6 +90,71 @@ export function calculateWeightedReturnOnEquity(
return weightedROE.toString();
}

export function getSpotBalance(
spotState?: SpotClearinghouseStateResponse | null,
): number {
if (!spotState?.balances || !Array.isArray(spotState.balances)) {
return 0;
}

return spotState.balances.reduce(
(sum: number, balance: { total?: string }) =>
sum + parseFloat(balance.total ?? '0'),
0,
);
}

export function getSpotBalanceByCoin(
spotState: SpotClearinghouseStateResponse | null | undefined,
coin: string,
): number {
if (!spotState?.balances || !Array.isArray(spotState.balances)) {
return 0;
}

const matchingBalance = spotState.balances.find(
(balance: { coin?: string }) => balance.coin === coin,
);

return matchingBalance ? parseFloat(matchingBalance.total ?? '0') : 0;
}

export function addSpotBalanceToAccountState(
accountState: AccountState,
spotState?: SpotClearinghouseStateResponse | null,
): AccountState {
const spotBalance = getSpotBalance(spotState);

if (spotBalance === 0) {
return accountState;
}

return {
...accountState,
totalBalance: (
parseFloat(accountState.totalBalance) + spotBalance
).toString(),
};
}

export function addSpotUsdcToAvailableToTradeBalance(
accountState: AccountState,
spotState?: SpotClearinghouseStateResponse | null,
): AccountState {
const spotUsdcBalance = getSpotBalanceByCoin(spotState, 'USDC');

if (spotUsdcBalance === 0) {
return accountState;
}

return {
...accountState,
availableToTradeBalance: (
parseFloat(accountState.availableToTradeBalance) + spotUsdcBalance
).toString(),
};
}

/**
* Aggregate multiple per-DEX AccountState objects into one by summing numeric fields.
* ROE is recalculated as (totalUnrealizedPnl / totalMarginUsed) * 100.
Expand All @@ -99,6 +165,7 @@ export function calculateWeightedReturnOnEquity(
export function aggregateAccountStates(states: AccountState[]): AccountState {
const fallback: AccountState = {
availableBalance: PERPS_CONSTANTS.FallbackDataDisplay,
availableToTradeBalance: PERPS_CONSTANTS.FallbackDataDisplay,
totalBalance: PERPS_CONSTANTS.FallbackDataDisplay,
marginUsed: PERPS_CONSTANTS.FallbackDataDisplay,
unrealizedPnl: PERPS_CONSTANTS.FallbackDataDisplay,
Expand All @@ -117,6 +184,10 @@ export function aggregateAccountStates(states: AccountState[]): AccountState {
availableBalance: (
parseFloat(acc.availableBalance) + parseFloat(state.availableBalance)
).toString(),
availableToTradeBalance: (
parseFloat(acc.availableToTradeBalance) +
parseFloat(state.availableToTradeBalance)
).toString(),
totalBalance: (
parseFloat(acc.totalBalance) + parseFloat(state.totalBalance)
).toString(),
Expand Down
16 changes: 6 additions & 10 deletions packages/perps-controller/src/utils/hyperLiquidAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
MetaResponse,
SDKOrderParams,
} from '../types/hyperliquid-types';
import { getSpotBalance, getSpotBalanceByCoin } from './accountUtils';
import {
countSignificantFigures,
roundToSignificantFigures,
Expand Down Expand Up @@ -287,20 +288,15 @@ export function adaptAccountStateFromSDK(
: '0';

const perpsBalance = parseFloat(perpsState.marginSummary.accountValue);

let spotBalance = 0;
if (spotState?.balances && Array.isArray(spotState.balances)) {
spotBalance = spotState.balances.reduce(
(sum: number, balance: { total?: string }) =>
sum + parseFloat(balance.total ?? '0'),
0,
);
}

const spotBalance = getSpotBalance(spotState);
const spotUsdcBalance = getSpotBalanceByCoin(spotState, 'USDC');
const totalBalance = (spotBalance + perpsBalance).toString();

const accountState: AccountState = {
availableBalance: perpsState.withdrawable || '0',
availableToTradeBalance: (
parseFloat(perpsState.withdrawable || '0') + spotUsdcBalance
).toString(),
totalBalance: totalBalance || '0',
marginUsed: perpsState.marginSummary.totalMarginUsed || '0',
unrealizedPnl: totalUnrealizedPnl.toString() || '0',
Expand Down
Loading
Loading