diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index e182ed77fe8..64c0688991d 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-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" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a5831c4602c..09546f8fef1 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -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 diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 5cb17883ce2..64531ea3f43 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 { + addSpotUsdcToAvailableToTradeBalance, + aggregateAccountStates, +} from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptAccountStateFromSDK, @@ -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< @@ -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'), diff --git a/packages/perps-controller/src/providers/MYXProvider.ts b/packages/perps-controller/src/providers/MYXProvider.ts index 8b77f29e9cf..a59cff4d5ea 100644 --- a/packages/perps-controller/src/providers/MYXProvider.ts +++ b/packages/perps-controller/src/providers/MYXProvider.ts @@ -687,6 +687,7 @@ export class MYXProvider implements PerpsProvider { }); return { availableBalance: '0', + availableToTradeBalance: '0', totalBalance: '0', marginUsed: '0', unrealizedPnl: '0', @@ -937,6 +938,7 @@ export class MYXProvider implements PerpsProvider { () => params.callback({ availableBalance: '0', + availableToTradeBalance: '0', totalBalance: '0', marginUsed: '0', unrealizedPnl: '0', diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 56205357082..e13e4194b8c 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 { + addSpotUsdcToAvailableToTradeBalance, + 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 @@ -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 @@ -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; @@ -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); @@ -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 { + 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 +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 @@ -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( @@ -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 diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 6f5e61775ec..ce3eeb27544 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -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 diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 377cdde6f1d..b8aaa9b33a1 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,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. @@ -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, @@ -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(), diff --git a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts index b618616cb3b..c485f7a9852 100644 --- a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts +++ b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts @@ -19,6 +19,7 @@ import type { MetaResponse, SDKOrderParams, } from '../types/hyperliquid-types'; +import { getSpotBalance, getSpotBalanceByCoin } from './accountUtils'; import { countSignificantFigures, roundToSignificantFigures, @@ -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', diff --git a/packages/perps-controller/src/utils/myxAdapter.ts b/packages/perps-controller/src/utils/myxAdapter.ts index ee9b348ad9b..e460cc8c1df 100644 --- a/packages/perps-controller/src/utils/myxAdapter.ts +++ b/packages/perps-controller/src/utils/myxAdapter.ts @@ -466,6 +466,7 @@ export function adaptAccountStateFromMYX( return { availableBalance: availableBalance.toString(), + availableToTradeBalance: availableBalance.toString(), totalBalance: totalBalance.toString(), marginUsed: marginUsed.toString(), unrealizedPnl: unrealizedPnl.toString(), diff --git a/packages/perps-controller/tests/available-to-trade-balance.test.ts b/packages/perps-controller/tests/available-to-trade-balance.test.ts new file mode 100644 index 00000000000..8cfc80cc22b --- /dev/null +++ b/packages/perps-controller/tests/available-to-trade-balance.test.ts @@ -0,0 +1,82 @@ +import { + addSpotUsdcToAvailableToTradeBalance, + aggregateAccountStates, +} from '../src/utils/accountUtils'; +import { adaptAccountStateFromSDK } from '../src/utils/hyperLiquidAdapter'; + +describe('availableToTradeBalance', () => { + it('includes only spot USDC in HyperLiquid buying power', () => { + const accountState = adaptAccountStateFromSDK( + { + withdrawable: '0', + marginSummary: { + accountValue: '0.0', + totalMarginUsed: '0.0', + }, + crossMarginSummary: { + accountValue: '0.0', + totalMarginUsed: '0.0', + }, + assetPositions: [], + }, + { + balances: [ + { coin: 'USDC', total: '100.76531791', hold: '0.0' }, + { coin: 'HYPE', total: '0.36975137', hold: '0.0' }, + ], + }, + ); + + expect(accountState.availableBalance).toBe('0'); + expect(accountState.availableToTradeBalance).toBe('100.76531791'); + expect(accountState.totalBalance).toBe('101.13506928'); + }); + + it('preserves non-USDC spot outside the funded-state field', () => { + const accountState = addSpotUsdcToAvailableToTradeBalance( + { + availableBalance: '5', + availableToTradeBalance: '5', + totalBalance: '7', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }, + { + balances: [ + { coin: 'USDC', total: '10.5', hold: '0.0' }, + { coin: 'HYPE', total: '3.25', hold: '0.0' }, + ], + }, + ); + + expect(accountState.availableBalance).toBe('5'); + expect(accountState.availableToTradeBalance).toBe('15.5'); + expect(accountState.totalBalance).toBe('7'); + }); + + it('aggregates the funded-state field across providers', () => { + const accountState = aggregateAccountStates([ + { + availableBalance: '1', + availableToTradeBalance: '11', + totalBalance: '20', + marginUsed: '2', + unrealizedPnl: '3', + returnOnEquity: '4', + }, + { + availableBalance: '2', + availableToTradeBalance: '22', + totalBalance: '30', + marginUsed: '5', + unrealizedPnl: '6', + returnOnEquity: '7', + }, + ]); + + expect(accountState.availableBalance).toBe('3'); + expect(accountState.availableToTradeBalance).toBe('33'); + expect(accountState.totalBalance).toBe('50'); + }); +});