Skip to content

Commit 460fd6d

Browse files
runway-github[bot]abretonc7sgeosittamichalconsensysjaviergarciavera
authored
chore(runway): cherry-pick fix(perps): complete spot-balance parity cp-7.72.2 (#29237)
- fix(perps): complete spot-balance parity cp-7.72.2 (#29110) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Full fix for **TAT-3016 — [PROD INCIDENT] MetaMask UI shows $0 balance for accounts with spot + perps funds on HyperLiquid**. Builds on Matt's stream fix ([#29089](#29089)) and adds the two missing pieces uncovered during investigation. ### What was broken For HyperLiquid accounts that hold collateral as spot USDC (non-zero `spotClearinghouseState.balances.USDC`) but zero perps clearinghouse balance (`clearinghouseState.withdrawable == 0`, `marginSummary.accountValue == 0`), three independent code paths were under-reporting the balance: | Path | Pre-fix `totalBalance` | Pre-fix `availableBalance` | Why | |---|---|---|---| | Streamed (`HyperLiquidSubscriptionService` webData2 + clearinghouseState callbacks) | `0` | `0` | `adaptAccountStateFromSDK(data.clearinghouseState, undefined)` never fetched/attached `spotClearinghouseState` | | Standalone fetch (`PerpsController.getAccountState({standalone: true})`) | `0` | `0` | Same omission pattern in a separate provider path (`HyperLiquidProvider.ts:5545-5573`) | | Full fetch (`PerpsController.getAccountState()`) | **correct** (`101.13…`) | `0` | Only path that correctly queried both clearinghouse + spot | ### Why it surfaced now Two independent changes in the week leading up to the incident made a long-standing omission visible at scale: 1. **`feat(perps): disk-backed cold-start cache for instant data display` ([#27898](#27898), merged 2026-04-11)** changed `usePerpsLiveAccount` to seed first render from the in-memory stream snapshot (`streamManager.account.getSnapshot()`) before falling back to the preloaded disk cache. The stream snapshot has always been spot-less because `HyperLiquidSubscriptionService.ts:1406` and `:1604` have passed `spotState=undefined` to `adaptAccountStateFromSDK` since December 2025 / February 2026 (git blame). Flipping the trust order from disk cache → live stream exposed the pre-existing zero on first paint. 2. **HyperLiquid Portfolio Margin alpha shipped on the 2026-04-18 network upgrade.** PM pushes more users to hold collateral as spot USDC rather than transferring into perps clearinghouse, expanding the population hitting the spot-only account shape. Neither change is the root cause. The fix is on the MetaMask side: the streamed and standalone account paths must read `spotClearinghouseState` alongside `clearinghouseState` and include spot balance in `totalBalance` for parity with the full-fetch path. ### What this PR does - **Spot-inclusive balance across all three account-state paths.** Streamed, standalone, and full-fetch paths now fold `spotClearinghouseState.balances` into `AccountState.totalBalance` via the shared `addSpotBalanceToAccountState` helper. Only collateral-eligible coins contribute (`SPOT_COLLATERAL_COINS = {USDC, USDH}`) — non-collateral spot assets (HYPE, PURR, …) are excluded so they don't mis-gate the CTA for users who can't actually trade them. - **USDH handled for HIP-3 USDH DEXs.** The codebase already models USDH as auto-collateral (`HyperLiquidProvider.#isUsdhCollateralDex` / `#getSpotUsdhBalance`); including USDH in the allowlist keeps USDH-only HIP-3 users from hitting the same $0 regression. - **`Add Funds` CTA gates on `totalBalance`.** `PerpsMarketDetailsView.showAddFundsCTA` now checks `totalBalance < threshold && defaultPayToken === null`. "User has any money in the perps ecosystem → hide Add Funds." Also fixes the pre-existing edge case where funds locked in an open position incorrectly prompted Add Funds. - **Order-form preselect keeps `availableBalance`.** `useDefaultPayWithTokenWhenNoPerpsBalance` gates on withdrawable so spot-funded / margin-locked accounts still get an external pay token preselected in `PerpsOrderView`. CTA correctness is preserved by the component-level `totalBalance` guard. - **Race-free spot state cache.** `#spotStateGeneration` token + `#spotStatePromiseUserAddress` tracker in `HyperLiquidSubscriptionService`. `#ensureSpotState` only shares in-flight promises when the user matches; `#refreshSpotState` discards result + error if generation was bumped post-await; `cleanUp` / `clearAll` bump generation and null promise refs. Prevents user-A's spot fetch from re-populating the cache after a switch to user B. - **Cold-start SDK init.** `#refreshSpotState` now awaits `ensureSubscriptionClient` before `getInfoClient()` (which throws on fresh instances) so the first `subscribeToAccount` on a cold service gets the spot-adjusted snapshot instead of perps-only until resubscribe. - **`NaN` guard** in `addSpotBalanceToAccountState` keeps `FallbackDataDisplay` sentinels intact when upstream `totalBalance` is non-numeric. ### What this PR deliberately does NOT change - **Order-form slider and order-placement warnings** (`usePerpsOrderForm.ts`, `PerpsOrderView.tsx`) keep reading `availableBalance`. Those surfaces need *immediately-spendable withdrawable*. On standard-margin (non-Unified/non-PM) HyperLiquid accounts, spot USDC is not directly usable as perps margin — users must transfer spot → perps clearinghouse first. Showing a max order size that HyperLiquid would reject at submit would be worse UX than the current behaviour. This is HL's model for standard accounts and outside the scope of the `$0 balance` incident. - **No new fields on `AccountState`**. Considered adding `availableToTradeBalance` (see [#29090](#29090)) or `spotUsdcBalance` (see [#29092](#29092)); both leak HL primitives into the shared protocol-agnostic contract and will need reshaping once Portfolio Margin graduates from [pre-alpha](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/portfolio-margin). Reusing existing `totalBalance` for the CTA gate solves the incident with zero contract changes. - **Portfolio Margin buying-power**. PM pre-alpha uses HYPE-as-collateral with LTV-based borrow (`token_balance * oracle_price * ltv`, LTV 0.5 for HYPE, `borrow_cap(USDC) = 1000` per user). Correct PM buying-power math needs live oracle prices, LTV queries, and account-mode detection — deferred until PM graduates and the API stabilises. The spot USDC/USDH fix here still handles PM users who happen to hold spot collateral. - **Account-mode UI surface** (standard / Unified / PM). Valuable UX signal, but independent of the balance math — tracked as a separate follow-up. The fix on this PR is correct whether or not we surface mode in the UI. - **Core-side companion.** Matt's core PR [#8533](MetaMask/core#8533) covers the stream fix. The standalone-path fix on this PR needs a 1-liner mirror in `@metamask/perps-controller` before mobile syncs that package — flagging as follow-up. ## **Changelog** CHANGELOG entry: Fixed Perps $0 balance display for accounts funded via HyperLiquid spot USDC ## **Related issues** Fixes: [TAT-3016](https://consensyssoftware.atlassian.net/browse/TAT-3016) Supersedes: [#29090](#29090), [#29092](#29092) (both introduce a new `AccountState` field; this PR achieves the same user-visible outcome via `totalBalance` without a contract change) ## **Manual testing steps** ```gherkin Feature: Perps balance visibility for spot-funded accounts Background: Given the user holds spot USDC on HyperLiquid mainnet And the user's HyperLiquid perps clearinghouse balance (withdrawable, marginSummary.accountValue) is 0 And the user is on MetaMask mobile with Perps enabled Scenario: Header reflects spot-backed collateral When user navigates to the Perps tab Then the Perps header shows the spot USDC balance (e.g. $101.14), not $0 And "$0.00 available" is shown as the subtitle (correctly reflecting withdrawable) Scenario: Market detail CTA respects total balance Given user is on the Perps tab with the spot-only account When user opens the BTC market detail view Then the Long and Short action buttons are visible And the "Add Funds" CTA is not shown Scenario: Standalone account-state fetch Given a developer queries Engine.context.PerpsController.getAccountState({ standalone: true, userAddress }) Then totalBalance matches the full-fetch path and includes the spot USDC balance ``` **Agentic recipe**: `evidence/recipe.json` (also in this branch) replays the scenario via CDP and captures the stream / full-fetch / standalone values plus screenshots. Run: ```bash bash scripts/perps/agentic/validate-recipe.sh evidence --no-hud --skip-manual ``` Expected captures (after fix): `{stream,fetch,standalone}_totalBalance = "101.13506928"` for the `0x316BDE155acd07609872a56Bc32CcfB0B13201fA` Trading fixture; CTA state `{addFundsVisible:false, longButtonVisible:true, shortButtonVisible:true}`. ## **Screenshots/Recordings** Recipe: `evidence/recipe.json` on this branch — captures the 3 balance paths, screenshots PerpsHome + PerpsMarketDetails, and probes CTA testIDs on every run. <table> <tr> <th width="50%">Before (pre-fix main)</th> <th width="50%">After (this PR)</th> </tr> <tr> <td> <em>Perps tab header</em><br/> Shows <code>$0</code> — the streamed value (spot-less). PerpsHome renders the <code>PerpsEmptyBalance</code> placeholder instead of Withdraw + Add Funds action buttons. </td> <td> <em>Perps tab header</em><br/> <img src="https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/29110/after-perps-home.png" width="320"/><br/> <code>$101.14</code> balance + "$0.00 available" subtitle + Withdraw / Add Funds row (non-empty funded-state UI) </td> </tr> <tr> <td> <em>PerpsMarketDetails (BTC)</em><br/> Shows "Add Funds" CTA instead of Long / Short buttons. Trade path blocked for spot-only accounts. </td> <td> <em>PerpsMarketDetails (BTC)</em><br/> <img src="https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/29110/after-market-details.png" width="320"/><br/> Long + Short buttons, no "Add Funds" CTA </td> </tr> </table> Visual before-fix screenshot was blocked by intermittent iOS Simulator crashes during this session (unrelated Apple `libsystem_sim_platform` issue). Trace-level evidence from the unfixed code stands: ``` // Streamed (HyperLiquidSubscriptionService #cachedAccount replayed via fresh subscribeToAccount listener) { "availableBalance": "0", "totalBalance": "0", ... } // Standalone fetch: getAccountState({ standalone: true, userAddress: '0x316B...' }) { "availableBalance": "0", "totalBalance": "0", ... } // Full fetch: getAccountState() — the only path that was correct pre-fix { "availableBalance": "0", "totalBalance": "101.13506928", ... } ``` Three paths disagreed on the same account at the same moment. Matt's `[PerpsDiag][ImportedAccount]` Sentry trace from prod confirms the same spot-less streamed payload shape for multiple users hitting TAT-3016. After-fix `trace.json` captures (from `evidence/recipe.json` run on commit `7f0e9def6f`): ``` stream: totalBalance = "101.13506928", availableBalance = "0" fetch: totalBalance = "101.13506928", availableBalance = "0" standalone: totalBalance = "101.13506928", availableBalance = "0" CTA probe: addFundsVisible = false, longButtonVisible = true, shortButtonVisible = true ``` All three balance paths now agree; CTA probe confirms Long + Short visible, Add Funds hidden on the BTC market detail view. ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TAT-3016]: https://consensyssoftware.atlassian.net/browse/TAT-3016?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core perps balance reporting and adds new async spot-state caching logic, so regressions could impact displayed totals and streaming updates across accounts/DEXs. Changes are localized and covered by targeted unit tests, but still affect user-visible funded-state gating. > > **Overview** > Fixes HyperLiquid *spot-funded* accounts showing a `$0` perps balance by folding eligible spot collateral (USDC only) into `AccountState.totalBalance` across **full fetch, standalone fetch, and WebSocket-streamed** account updates via new `getSpotBalance`/`addSpotBalanceToAccountState` helpers. > > Updates `HyperLiquidSubscriptionService` to fetch/cache `spotClearinghouseState` (with generation-based anti-stale guards) and apply spot-adjusted totals for both multi-DEX aggregation and single-DEX `webData2` updates; `HyperLiquidProvider`’s standalone `getAccountState` path now also fetches spot state and applies the same adjustment. > > Adjusts `PerpsMarketDetailsView` funding CTA logic to key off “has direct order funding path” (spendable balance above threshold *or* pay-with-token preselect available), adds coverage for the “total funded but not spendable/no direct order path” case, and updates a perps market list page-object selector to tap rows by test id instead of text. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 385c39c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: geositta <matthew.denton@consensys.net> Co-authored-by: Michal Szorad <michal.szorad@consensys.net> Co-authored-by: Javier Garcia Vera <javier.vera@consensys.net> [3e535e6](3e535e6) [TAT-3016]: https://consensyssoftware.atlassian.net/browse/TAT-3016?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Co-authored-by: geositta <matthew.denton@consensys.net> Co-authored-by: Michal Szorad <michal.szorad@consensys.net> Co-authored-by: Javier Garcia Vera <javier.vera@consensys.net> Co-authored-by: chloeYue <105063779+chloeYue@users.noreply.github.com>
1 parent 6b85848 commit 460fd6d

10 files changed

Lines changed: 572 additions & 47 deletions

File tree

app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,47 @@ describe('PerpsMarketDetailsView', () => {
10561056
).toBeNull();
10571057
});
10581058

1059+
it('shows add funds CTA when total balance is funded but spendable balance has no direct order path', () => {
1060+
mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null);
1061+
mockUsePerpsAccount.mockReturnValue({
1062+
account: {
1063+
availableBalance: '0.00',
1064+
marginUsed: '0.00',
1065+
unrealizedPnl: '0.00',
1066+
returnOnEquity: '0.00',
1067+
totalBalance: '100.00',
1068+
},
1069+
isInitialLoading: false,
1070+
});
1071+
mockUsePerpsLiveAccount.mockReturnValue({
1072+
account: {
1073+
availableBalance: '0',
1074+
marginUsed: '0',
1075+
unrealizedPnl: '0',
1076+
returnOnEquity: '0',
1077+
totalBalance: '100',
1078+
},
1079+
isInitialLoading: false,
1080+
});
1081+
1082+
const { getByTestId, queryByTestId } = renderWithProvider(
1083+
<PerpsConnectionProvider>
1084+
<PerpsMarketDetailsView />
1085+
</PerpsConnectionProvider>,
1086+
{ state: initialState },
1087+
);
1088+
1089+
expect(
1090+
getByTestId(PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON),
1091+
).toBeOnTheScreen();
1092+
expect(
1093+
queryByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON),
1094+
).toBeNull();
1095+
expect(
1096+
queryByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON),
1097+
).toBeNull();
1098+
});
1099+
10591100
it('calls navigateToConfirmation and depositWithConfirmation when add funds is pressed', async () => {
10601101
mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null);
10611102
mockUsePerpsAccount.mockReturnValue({

app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -432,14 +432,10 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
432432
const availableBalance = Number.parseFloat(
433433
account?.availableBalance?.toString() ?? '0',
434434
);
435-
const showAddFundsCTA =
436-
isEligible &&
437-
!isLoadingPosition &&
438-
!existingPosition &&
439-
!isAtOICap &&
435+
const hasDirectOrderFundingPath =
440436
!isLoadingAccount &&
441-
availableBalance < PERPS_MIN_BALANCE_THRESHOLD &&
442-
defaultPayTokenWhenNoPerpsBalance === null;
437+
(availableBalance >= PERPS_MIN_BALANCE_THRESHOLD ||
438+
defaultPayTokenWhenNoPerpsBalance !== null);
443439

444440
const handleAddFunds = useCallback(async () => {
445441
if (!isEligible) {
@@ -1151,9 +1147,13 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
11511147
const shouldShowNewPositionActions =
11521148
hasLongShortButtons && !existingPosition && !isAtOICap;
11531149
const shouldShowAddFundsCTASection =
1154-
shouldShowNewPositionActions && showAddFundsCTA;
1150+
shouldShowNewPositionActions &&
1151+
isEligible &&
1152+
!isLoadingAccount &&
1153+
!isLoadingPosition &&
1154+
!hasDirectOrderFundingPath;
11551155
const shouldShowLongShortButtonsOnly =
1156-
shouldShowNewPositionActions && !showAddFundsCTA;
1156+
shouldShowNewPositionActions && !shouldShowAddFundsCTASection;
11571157

11581158
const shouldShowPerpsMarketInsights =
11591159
isPerpsInsightsEnabled &&

app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment
3939
if (!featureEnabled) {
4040
return null;
4141
}
42+
// Gate on availableBalance (spendable): order-form pay-token preselection
43+
// must fire when withdrawable is 0 but totalBalance > 0 (spot-funded or
44+
// margin-locked). The CTA consumer layers its own totalBalance guard on
45+
// top of this hook's result to hide "Add Funds" for spot-funded accounts.
4246
const availableBalance = Number.parseFloat(
4347
perpsAccount?.availableBalance?.toString() ?? '0',
4448
);

app/controllers/perps/providers/HyperLiquidProvider.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2154,6 +2154,24 @@ describe('HyperLiquidProvider', () => {
21542154
).toHaveBeenCalled();
21552155
});
21562156

2157+
it('does not count USDH-only spot balance in funded-state totals', async () => {
2158+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
2159+
createMockInfoClient({
2160+
spotClearinghouseState: jest.fn().mockResolvedValue({
2161+
balances: [{ coin: 'USDH', hold: '1000', total: '10000' }],
2162+
}),
2163+
}),
2164+
);
2165+
2166+
const accountState = await provider.getAccountState();
2167+
2168+
expect(accountState).toBeDefined();
2169+
expect(accountState.totalBalance).toBe('10500');
2170+
expect(
2171+
mockClientService.getInfoClient().spotClearinghouseState,
2172+
).toHaveBeenCalled();
2173+
});
2174+
21572175
it('gets markets successfully', async () => {
21582176
const markets = await provider.getMarkets();
21592177

@@ -8575,6 +8593,7 @@ describe('HyperLiquidProvider', () => {
85758593
clearinghouseState: jest.fn(),
85768594
frontendOpenOrders: jest.fn(),
85778595
perpDexs: jest.fn().mockResolvedValue([null]),
8596+
spotClearinghouseState: jest.fn().mockResolvedValue({ balances: [] }),
85788597
};
85798598
});
85808599

app/controllers/perps/providers/HyperLiquidProvider.ts

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ import type {
110110
} from '../types/hyperliquid-types';
111111
import type { PerpsControllerMessengerBase } from '../types/messenger';
112112
import type { ExtendedAssetMeta, ExtendedPerpDex } from '../types/perps-types';
113-
import { aggregateAccountStates } from '../utils/accountUtils';
113+
import {
114+
addSpotBalanceToAccountState,
115+
aggregateAccountStates,
116+
} from '../utils/accountUtils';
114117
import { ensureError } from '../utils/errorUtils';
115118
import {
116119
adaptAccountStateFromSDK,
@@ -5632,17 +5635,38 @@ export class HyperLiquidProvider implements PerpsProvider {
56325635
isTestnet: this.#clientService.isTestnetMode(),
56335636
});
56345637
const dexs = await this.#getStandaloneValidatedDexs();
5635-
const results = await queryStandaloneClearinghouseStates(
5636-
standaloneInfoClient,
5637-
userAddress,
5638-
dexs,
5639-
);
5638+
const [standaloneSpotStateResult, standalonePerpsResults] =
5639+
await Promise.all([
5640+
standaloneInfoClient
5641+
.spotClearinghouseState({ user: userAddress })
5642+
.catch((error: unknown) => {
5643+
this.#deps.debugLogger.log(
5644+
'Standalone spot state fetch failed — falling back to perps-only totals',
5645+
{
5646+
error: ensureError(
5647+
error,
5648+
'HyperLiquidProvider.getAccountState.standalone.spot',
5649+
).message,
5650+
},
5651+
);
5652+
return null;
5653+
}),
5654+
queryStandaloneClearinghouseStates(
5655+
standaloneInfoClient,
5656+
userAddress,
5657+
dexs,
5658+
),
5659+
]);
56405660

5641-
// Aggregate account states across all DEXs
5642-
const dexAccountStates = results.map((perpsState) =>
5661+
// Aggregate account states across all DEXs, then apply spot-backed
5662+
// adjustments so streamed/standalone/full paths report the same totals.
5663+
const dexAccountStates = standalonePerpsResults.map((perpsState) =>
56435664
adaptAccountStateFromSDK(perpsState),
56445665
);
5645-
const aggregatedAccountState = aggregateAccountStates(dexAccountStates);
5666+
const aggregatedAccountState = addSpotBalanceToAccountState(
5667+
aggregateAccountStates(dexAccountStates),
5668+
standaloneSpotStateResult,
5669+
);
56465670

56475671
this.#deps.debugLogger.log(
56485672
'HyperLiquidProvider: standalone account state fetched',
@@ -5757,19 +5781,10 @@ export class HyperLiquidProvider implements PerpsProvider {
57575781
);
57585782
return dexAccountState;
57595783
});
5760-
const aggregatedAccountState = aggregateAccountStates(dexAccountStates);
5761-
5762-
// Add spot balance to totalBalance (spot is global, not per-DEX)
5763-
let spotBalance = 0;
5764-
if (spotState?.balances && Array.isArray(spotState.balances)) {
5765-
spotBalance = spotState.balances.reduce(
5766-
(sum, balance) => sum + parseFloat(balance.total || '0'),
5767-
0,
5768-
);
5769-
}
5770-
aggregatedAccountState.totalBalance = (
5771-
parseFloat(aggregatedAccountState.totalBalance) + spotBalance
5772-
).toString();
5784+
const aggregatedAccountState = addSpotBalanceToAccountState(
5785+
aggregateAccountStates(dexAccountStates),
5786+
spotState,
5787+
);
57735788

57745789
// Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts)
57755790
const subAccountBreakdown: Record<

app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ describe('HyperLiquidSubscriptionService', () => {
104104
let mockSubscriptionClient: any;
105105
let mockWalletAdapter: any;
106106
let mockDeps: ReturnType<typeof createMockInfrastructure>;
107+
let mockSpotClearinghouseState: jest.Mock;
107108

108109
beforeEach(() => {
109110
jest.useFakeTimers();
@@ -371,9 +372,16 @@ describe('HyperLiquidSubscriptionService', () => {
371372
};
372373

373374
// Mock client service
375+
mockSpotClearinghouseState = jest.fn().mockResolvedValue({
376+
balances: [{ coin: 'USDC', total: '100.76531791' }],
377+
});
378+
374379
mockClientService = {
375380
ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined),
376381
getSubscriptionClient: jest.fn(() => mockSubscriptionClient),
382+
getInfoClient: jest.fn(() => ({
383+
spotClearinghouseState: mockSpotClearinghouseState,
384+
})),
377385
isTestnetMode: jest.fn(() => false),
378386
ensureTransportReady: jest.fn().mockResolvedValue(undefined),
379387
getConnectionState: jest.fn(() => 'connected'),
@@ -3672,6 +3680,155 @@ describe('HyperLiquidSubscriptionService', () => {
36723680
});
36733681
});
36743682

3683+
describe('spot-adjusted account balance parity', () => {
3684+
it('includes spot balance exactly once in streamed totalBalance across multiple DEXs', async () => {
3685+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
3686+
availableBalance: '0',
3687+
totalBalance: '0',
3688+
marginUsed: '0',
3689+
unrealizedPnl: '0',
3690+
returnOnEquity: '0',
3691+
}));
3692+
3693+
mockSubscriptionClient.clearinghouseState.mockImplementation(
3694+
(_params: any, callback: any) => {
3695+
setTimeout(() => {
3696+
callback({
3697+
dex: _params.dex || '',
3698+
clearinghouseState: {
3699+
assetPositions: [],
3700+
marginSummary: {
3701+
accountValue: '0',
3702+
totalMarginUsed: '0',
3703+
},
3704+
withdrawable: '0',
3705+
},
3706+
});
3707+
}, 0);
3708+
return Promise.resolve({
3709+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3710+
});
3711+
},
3712+
);
3713+
mockSubscriptionClient.openOrders.mockImplementation(
3714+
(_params: any, callback: any) => {
3715+
setTimeout(() => callback({ dex: _params.dex || '', orders: [] }), 0);
3716+
return Promise.resolve({
3717+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3718+
});
3719+
},
3720+
);
3721+
3722+
const hip3Service = new HyperLiquidSubscriptionService(
3723+
mockClientService,
3724+
mockWalletService,
3725+
mockDeps,
3726+
true,
3727+
);
3728+
3729+
await hip3Service.updateFeatureFlags(true, ['xyz'], [], []);
3730+
3731+
const mockCallback = jest.fn();
3732+
const unsubscribe = hip3Service.subscribeToAccount({
3733+
callback: mockCallback,
3734+
});
3735+
3736+
await jest.runAllTimersAsync();
3737+
3738+
expect(mockCallback).toHaveBeenCalled();
3739+
const accountState = mockCallback.mock.calls.at(-1)[0];
3740+
expect(accountState.totalBalance).toBe('100.76531791');
3741+
expect(accountState.availableBalance).toBe('0');
3742+
expect(accountState.subAccountBreakdown).toEqual({
3743+
main: { availableBalance: '0', totalBalance: '0' },
3744+
xyz: { availableBalance: '0', totalBalance: '0' },
3745+
});
3746+
expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(1);
3747+
3748+
unsubscribe();
3749+
});
3750+
3751+
it('includes spot balance in webData2 (single-DEX) account updates without flickering', async () => {
3752+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
3753+
availableBalance: '50',
3754+
totalBalance: '200',
3755+
marginUsed: '10',
3756+
unrealizedPnl: '5',
3757+
returnOnEquity: '0.05',
3758+
}));
3759+
3760+
let webData2Callback: ((data: any) => void) | undefined;
3761+
mockSubscriptionClient.webData2.mockImplementation(
3762+
(_params: any, callback: any) => {
3763+
webData2Callback = callback;
3764+
setTimeout(() => {
3765+
callback({
3766+
clearinghouseState: {
3767+
assetPositions: [],
3768+
marginSummary: {
3769+
accountValue: '200',
3770+
totalMarginUsed: '10',
3771+
},
3772+
withdrawable: '50',
3773+
},
3774+
openOrders: [],
3775+
perpsAtOpenInterestCap: [],
3776+
});
3777+
}, 0);
3778+
return Promise.resolve({
3779+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3780+
});
3781+
},
3782+
);
3783+
3784+
const singleDexService = new HyperLiquidSubscriptionService(
3785+
mockClientService,
3786+
mockWalletService,
3787+
mockDeps,
3788+
false,
3789+
);
3790+
3791+
const mockCallback = jest.fn();
3792+
const unsubscribe = singleDexService.subscribeToAccount({
3793+
callback: mockCallback,
3794+
});
3795+
3796+
await jest.runAllTimersAsync();
3797+
3798+
expect(mockCallback).toHaveBeenCalled();
3799+
const firstUpdate = mockCallback.mock.calls.at(-1)[0];
3800+
expect(firstUpdate.totalBalance).toBe('300.76531791');
3801+
expect(firstUpdate.availableBalance).toBe('50');
3802+
3803+
// Simulate a second WebSocket tick — should still include spot balance,
3804+
// not revert to perps-only 200.
3805+
mockCallback.mockClear();
3806+
expect(webData2Callback).toBeDefined();
3807+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3808+
webData2Callback!({
3809+
clearinghouseState: {
3810+
assetPositions: [],
3811+
marginSummary: {
3812+
accountValue: '200',
3813+
totalMarginUsed: '10',
3814+
},
3815+
withdrawable: '50',
3816+
},
3817+
openOrders: [],
3818+
perpsAtOpenInterestCap: [],
3819+
});
3820+
3821+
await jest.runAllTimersAsync();
3822+
3823+
if (mockCallback.mock.calls.length > 0) {
3824+
const secondUpdate = mockCallback.mock.calls.at(-1)[0];
3825+
expect(secondUpdate.totalBalance).toBe('300.76531791');
3826+
}
3827+
3828+
unsubscribe();
3829+
});
3830+
});
3831+
36753832
describe('aggregateAccountStates - returnOnEquity calculation', () => {
36763833
it('calculates positive ROE when unrealizedPnl is positive', async () => {
36773834
// Override the adapter mock

0 commit comments

Comments
 (0)