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
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 `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
Expand Down
26 changes: 20 additions & 6 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 {
aggregateAccountStates,
getSpotBalanceByCoin,
} from '../utils/accountUtils';
import { ensureError } from '../utils/errorUtils';
import {
adaptAccountStateFromSDK,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
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 {
calculateWeightedReturnOnEquity,
getSpotBalanceByCoin,
} from '../utils/accountUtils';
import { ensureError } from '../utils/errorUtils';
import {
adaptPositionFromSDK,
Expand Down Expand Up @@ -163,6 +167,12 @@ export class HyperLiquidSubscriptionService {

#cachedAccount: AccountState | null = null; // Aggregated account

#cachedSpotState: SpotClearinghouseStateResponse | null = null;

#cachedSpotStateUserAddress: string | null = null;

#spotStatePromise?: Promise<void>;

#ordersCacheInitialized = false; // Track if orders cache has received WebSocket data

#positionsCacheInitialized = false; // Track if positions cache has received WebSocket data
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.spotUsdcBalance ?? '0'}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`;
}

// Cache hashes to avoid recomputation
Expand Down Expand Up @@ -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(),
Expand All @@ -992,6 +1006,44 @@ export class HyperLiquidSubscriptionService {
};
}

async #refreshSpotState(userAddress: string): Promise<void> {
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<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;
}
}

/**
* Subscribe to live price updates with singleton subscription architecture
* Uses allMids for fast price updates and predictedFundings for accurate funding rates
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -2268,6 +2329,8 @@ export class HyperLiquidSubscriptionService {
return () => {
unsubscribe();
this.#accountSubscriberCount -= 1;
this.#cachedSpotState = null;
this.#cachedSpotStateUserAddress = null;
this.#cleanupSharedWebData3ISubscription();
};
}
Expand Down Expand Up @@ -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
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
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
Expand Down
21 changes: 21 additions & 0 deletions packages/perps-controller/src/utils/accountUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
);
}
3 changes: 3 additions & 0 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 { getSpotBalanceByCoin } from './accountUtils';
import {
countSignificantFigures,
roundToSignificantFigures,
Expand Down Expand Up @@ -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',
Expand Down
Loading