From de27c636390038a444260d8b4fc3758b5354b02d Mon Sep 17 00:00:00 2001 From: geositta Date: Mon, 20 Apr 2026 23:00:22 -0500 Subject: [PATCH] fix: align streamed hyperliquid total balance with account state --- packages/perps-controller/.sync-state.json | 12 +-- packages/perps-controller/CHANGELOG.md | 6 +- .../src/providers/HyperLiquidProvider.ts | 22 ++--- .../HyperLiquidSubscriptionService.ts | 92 ++++++++++++++++--- .../src/utils/accountUtils.ts | 33 +++++++ 5 files changed, 132 insertions(+), 33 deletions(-) diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index e182ed77fe8..7131c4a3240 100644 --- a/packages/perps-controller/.sync-state.json +++ b/packages/perps-controller/.sync-state.json @@ -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" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a5831c4602c..2c59e3a0dfa 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 + +- 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 @@ -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 diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 5cb17883ce2..bfd9440cd93 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -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, @@ -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< diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 56205357082..81651721d08 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -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, @@ -157,6 +161,12 @@ export class HyperLiquidSubscriptionService { readonly #dexAccountCache = new Map(); // Per-DEX account state + #cachedSpotState: SpotClearinghouseStateResponse | null = null; + + #cachedSpotStateUserAddress: string | null = null; + + #spotStatePromise?: Promise; + #cachedPositions: Position[] | null = null; // Aggregated positions #cachedOrders: Order[] | null = null; // Aggregated orders @@ -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 { + 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 { + 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'), + ); + } } /** @@ -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 @@ -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( @@ -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. diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 377cdde6f1d..569ee71f1af 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -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']); @@ -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.