diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a5831c4602c..f5a12eee3ce 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 `spotUsdcBalance` to perps account state so consumers can distinguish raw HyperLiquid spot USDC from perps `withdrawable` balance. + +### Fixed + +- Populate `spotUsdcBalance` in HyperLiquid account fetches and live account updates so funded-state checks can use spot USDC without changing the meaning of `availableBalance`. + ## [3.2.0] ### Added diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 5cb17883ce2..e58775f7329 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 { + aggregateAccountStates, + getSpotBalanceByCoin, +} from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptAccountStateFromSDK, @@ -5553,17 +5556,24 @@ export class HyperLiquidProvider implements PerpsProvider { isTestnet: this.#clientService.isTestnetMode(), }); const dexs = await this.#getStandaloneValidatedDexs(); - const results = await queryStandaloneClearinghouseStates( - standaloneInfoClient, - userAddress, - dexs, - ); + const [spotState, results] = await Promise.all([ + standaloneInfoClient.spotClearinghouseState({ user: userAddress }), + queryStandaloneClearinghouseStates( + standaloneInfoClient, + userAddress, + dexs, + ), + ]); // Aggregate account states across all DEXs const dexAccountStates = results.map((perpsState) => adaptAccountStateFromSDK(perpsState), ); const aggregatedAccountState = aggregateAccountStates(dexAccountStates); + aggregatedAccountState.spotUsdcBalance = getSpotBalanceByCoin( + spotState, + 'USDC', + ).toString(); this.#deps.debugLogger.log( 'HyperLiquidProvider: standalone account state fetched', @@ -5679,6 +5689,10 @@ export class HyperLiquidProvider implements PerpsProvider { return dexAccountState; }); const aggregatedAccountState = aggregateAccountStates(dexAccountStates); + aggregatedAccountState.spotUsdcBalance = getSpotBalanceByCoin( + spotState, + 'USDC', + ).toString(); // Add spot balance to totalBalance (spot is global, not per-DEX) let spotBalance = 0; diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 56205357082..2d20294fb20 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 { + calculateWeightedReturnOnEquity, + getSpotBalanceByCoin, +} from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptPositionFromSDK, @@ -163,6 +167,12 @@ export class HyperLiquidSubscriptionService { #cachedAccount: AccountState | null = null; // Aggregated account + #cachedSpotState: SpotClearinghouseStateResponse | null = null; + + #cachedSpotStateUserAddress: string | null = null; + + #spotStatePromise?: Promise; + #ordersCacheInitialized = false; // Track if orders cache has received WebSocket data #positionsCacheInitialized = false; // Track if positions cache has received WebSocket data @@ -684,7 +694,7 @@ export class HyperLiquidSubscriptionService { } #hashAccountState(account: AccountState): string { - return `${account.availableBalance}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`; + return `${account.availableBalance}:${account.spotUsdcBalance ?? '0'}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`; } // Cache hashes to avoid recomputation @@ -984,6 +994,10 @@ export class HyperLiquidSubscriptionService { return { ...firstDexAccount, availableBalance: totalAvailableBalance.toString(), + spotUsdcBalance: getSpotBalanceByCoin( + this.#cachedSpotState, + 'USDC', + ).toString(), totalBalance: totalBalance.toString(), marginUsed: totalMarginUsed.toString(), unrealizedPnl: totalUnrealizedPnl.toString(), @@ -992,6 +1006,44 @@ export class HyperLiquidSubscriptionService { }; } + async #refreshSpotState(userAddress: string): Promise { + const infoClient = this.#clientService.getInfoClient(); + this.#cachedSpotState = await infoClient.spotClearinghouseState({ + user: userAddress, + }); + this.#cachedSpotStateUserAddress = userAddress; + + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } + + 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; + } + } + /** * Subscribe to live price updates with singleton subscription architecture * Uses allMids for fast price updates and predictedFundings for accurate funding rates @@ -1943,6 +1995,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 @@ -2253,10 +2307,17 @@ export class HyperLiquidSubscriptionService { this.#accountSubscriberCount += 1; // Immediately provide cached data if available - if (this.#cachedAccount) { + 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( @@ -2268,6 +2329,8 @@ export class HyperLiquidSubscriptionService { return () => { unsubscribe(); this.#accountSubscriberCount -= 1; + this.#cachedSpotState = null; + this.#cachedSpotStateUserAddress = null; this.#cleanupSharedWebData3ISubscription(); }; } @@ -3654,6 +3717,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..69b4c88b48d 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 + spotUsdcBalance?: string; // Raw spot USDC balance used for funded-state checks on HyperLiquid 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..f822289a875 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -3,6 +3,7 @@ * Handles account selection and EVM account filtering */ import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { SpotClearinghouseStateResponse } from '@nktkas/hyperliquid'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState, PerpsInternalAccount } from '../types'; @@ -99,6 +100,7 @@ export function calculateWeightedReturnOnEquity( export function aggregateAccountStates(states: AccountState[]): AccountState { const fallback: AccountState = { availableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + spotUsdcBalance: '0', totalBalance: PERPS_CONSTANTS.FallbackDataDisplay, marginUsed: PERPS_CONSTANTS.FallbackDataDisplay, unrealizedPnl: PERPS_CONSTANTS.FallbackDataDisplay, @@ -117,6 +119,10 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { availableBalance: ( parseFloat(acc.availableBalance) + parseFloat(state.availableBalance) ).toString(), + spotUsdcBalance: ( + parseFloat(acc.spotUsdcBalance ?? '0') + + parseFloat(state.spotUsdcBalance ?? '0') + ).toString(), totalBalance: ( parseFloat(acc.totalBalance) + parseFloat(state.totalBalance) ).toString(), @@ -144,3 +150,18 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { return aggregated; } + +export function getSpotBalanceByCoin( + spotState: SpotClearinghouseStateResponse | null | undefined, + coin: string, +): number { + if (!spotState?.balances || !Array.isArray(spotState.balances)) { + return 0; + } + + return spotState.balances.reduce( + (sum, balance) => + balance.coin === coin ? sum + parseFloat(balance.total ?? '0') : sum, + 0, + ); +} diff --git a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts index b618616cb3b..21dc99c1266 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 { getSpotBalanceByCoin } from './accountUtils'; import { countSignificantFigures, roundToSignificantFigures, @@ -298,9 +299,11 @@ export function adaptAccountStateFromSDK( } const totalBalance = (spotBalance + perpsBalance).toString(); + const spotUsdcBalance = getSpotBalanceByCoin(spotState, 'USDC'); const accountState: AccountState = { availableBalance: perpsState.withdrawable || '0', + spotUsdcBalance: spotUsdcBalance.toString(), totalBalance: totalBalance || '0', marginUsed: perpsState.marginSummary.totalMarginUsed || '0', unrealizedPnl: totalUnrealizedPnl.toString() || '0',