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-stream-account-spot-parity-mobile-wt",
"lastSyncedCoreCommit": "c78a9d48ac2f4fdc55abb88723febb58463ad469",
"lastSyncedCoreBranch": "perps/fix-stream-account-spot-parity-core-wt",
"lastSyncedDate": "2026-04-21T01:46:42Z",
"sourceChecksum": "9f33e7fc41bbd0321d1262f1afbc370f53ad0eb6f812cfa07162f69c6fa419e3"
}
6 changes: 5 additions & 1 deletion packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Align streamed HyperLiquid account `totalBalance` with `getAccountState()` by including spot balances in the aggregated account total ([#NNNN](https://github.com/MetaMask/core/pull/NNNN))

## [3.2.0]

### Added
Expand Down Expand Up @@ -222,7 +226,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[3.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.1.1...@metamask/perps-controller@3.2.0
[3.1.1]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.1.0...@metamask/perps-controller@3.1.1
[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.0.0...@metamask/perps-controller@3.1.0
[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@2.0.0...@metamask/perps-controller@3.0.0
[3.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/perps-controller@3.0.0
[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.3.0...@metamask/perps-controller@2.0.0
[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.2.0...@metamask/perps-controller@1.3.0
[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.1.0...@metamask/perps-controller@1.2.0
Expand Down
22 changes: 8 additions & 14 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 {
addSpotBalanceToAccountState,
aggregateAccountStates,
} from '../utils/accountUtils';
import { ensureError } from '../utils/errorUtils';
import {
adaptAccountStateFromSDK,
Expand Down Expand Up @@ -5678,19 +5681,10 @@ export class HyperLiquidProvider implements PerpsProvider {
);
return dexAccountState;
});
const aggregatedAccountState = aggregateAccountStates(dexAccountStates);

// Add spot balance to totalBalance (spot is global, not per-DEX)
let spotBalance = 0;
if (spotState?.balances && Array.isArray(spotState.balances)) {
spotBalance = spotState.balances.reduce(
(sum, balance) => sum + parseFloat(balance.total || '0'),
0,
);
}
aggregatedAccountState.totalBalance = (
parseFloat(aggregatedAccountState.totalBalance) + spotBalance
).toString();
const aggregatedAccountState = addSpotBalanceToAccountState(
aggregateAccountStates(dexAccountStates),
spotState,
);

// Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts)
const subAccountBreakdown: Record<
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 {
addSpotBalanceToAccountState,
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 @@ -981,15 +991,62 @@ 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 addSpotBalanceToAccountState(
{
...firstDexAccount,
availableBalance: totalAvailableBalance.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 +2000,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 +2311,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 @@ -3684,6 +3750,8 @@ export class HyperLiquidSubscriptionService {
this.#dexPositionsCache.clear();
this.#dexOrdersCache.clear();
this.#dexAccountCache.clear();
this.#cachedSpotState = null;
this.#cachedSpotStateUserAddress = null;
this.#dexAssetCtxsCache.clear();

// Unsubscribe all active subscriptions before clearing references.
Expand Down
33 changes: 33 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,38 @@ 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 addSpotBalanceToAccountState(
accountState: AccountState,
spotState?: SpotClearinghouseStateResponse | null,
): AccountState {
const spotBalance = getSpotBalance(spotState);

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

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

/**
* Aggregate multiple per-DEX AccountState objects into one by summing numeric fields.
* ROE is recalculated as (totalUnrealizedPnl / totalMarginUsed) * 100.
Expand Down
Loading