fix(perps): complete spot-balance parity cp-7.72.2#29110
Conversation
Mirrors the stream + full-fetch fix in the standalone path at
HyperLiquidProvider.getAccountState({ standalone: true }). Previously
this path queried only clearinghouseState and skipped spotClearinghouseState,
so spot-funded accounts returned totalBalance: 0 from standalone fetches
even after the stream fix landed.
Query spotClearinghouseState in parallel with standalone clearinghouse
states and reuse the addSpotBalanceToAccountState helper so streamed,
full-fetch, and standalone paths all report the same totalBalance.
PerpsMarketDetailsView and useDefaultPayWithTokenWhenNoPerpsBalance were gating the Add Funds CTA on AccountState.availableBalance, which maps to HyperLiquid withdrawable. Accounts funded via spot USDC have withdrawable = 0 even when they hold collateral in spot, so they saw the Add Funds CTA instead of Long/Short even after the upstream stream fix made totalBalance spot-inclusive. Switch the CTA gate to totalBalance. Semantic match: "user has any money in the perps ecosystem" → hide Add Funds. Also fixes a pre-existing edge case where accounts with all funds locked in an open position (marginUsed high, withdrawable = 0) incorrectly prompted Add Funds. Order-form slider and order-placement warnings intentionally keep availableBalance since those need immediately-spendable withdrawable for standard-margin accounts — spot-backed margin requires a transfer to perps clearinghouse first on non-PM accounts.
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
- accountUtils.addSpotBalanceToAccountState: guard against non-finite
totalBalance sentinel ("--") to avoid producing "NaN" when the
standalone path aggregates an empty perps-DEX result alongside a
successful spot query.
- HyperLiquidSubscriptionService.subscribeToAccount: drop the
#cachedSpotState condition on the immediate cache push — new
subscribers were starved indefinitely if the spot fetch had never
resolved. Stale-but-present data is strictly better; the next
aggregation after spot resolves pushes the spot-inclusive value.
Bind spot-state fetches to a generation token and user address so an in-flight fetch for account A cannot re-populate the cache after a switch to B. cleanUp and clearAll bump the generation and drop the promise tracker; refreshSpotState discards its result and error when the generation moved on, preventing cross-account contamination in aggregateAndNotifySubscribers.
useDefaultPayWithTokenWhenNoPerpsBalance is shared by the market-details Add Funds CTA and by useInitPerpsPaymentToken in PerpsOrderView. Gating on totalBalance broke order-entry for accounts with availableBalance=0 but totalBalance>0 (spot-funded, or margin locked in a position) — they were sent into the trade flow with Perps balance preselected and zero usable amount. Restore the availableBalance check inside the hook. The CTA correctness is preserved by the component-level totalBalance guard in PerpsMarketDetailsView (showAddFundsCTA combines totalBalance<threshold AND defaultPayToken===null), which already hides the CTA for spot-funded accounts regardless of what the hook returns.
P1: #refreshSpotState now awaits ensureSubscriptionClient before calling getInfoClient(). On a fresh service instance subscribeToAccount fires the spot fetch before any webData3 path had a chance to init the SDK — getInfoClient() would throw and the error swallower left the first subscriber reporting a perps-only totalBalance until resubscribe. P2: getSpotBalance now filters to USDC only (SPOT_COLLATERAL_COINS). Hyperliquid only accepts USDC as perps margin; summing HYPE, PURR, or any other spot asset into totalBalance would hide the Add Funds CTA for users whose only spot holdings cannot back a perps trade. Covered by updated accountUtils + standalone HyperLiquidProvider tests.
HIP-3 USDH DEXs pull collateral from spot USDH automatically (see HyperLiquidProvider.#isUsdhCollateralDex / #getSpotUsdhBalance). Restricting SPOT_COLLATERAL_COINS to USDC left USDH-collateralized accounts with totalBalance = 0, reproducing the same header / CTA regression the PR is meant to fix — for the HIP-3 cohort. Adds USDH to the allowlist and covers it with unit tests.
USDH_CONFIG.TokenName was undefined at module load when HyperLiquidWalletService imported accountUtils, due to the init order between hyperLiquidConfig and the wallet service's own dependency graph. That crashed the whole test suite at accountUtils.ts:102. Use the string literal 'USDH' directly — same value, no import cycle.
…e flicker The webData2 callback (single-DEX / non-HIP3 path) was storing perps-only account state directly, bypassing addSpotBalanceToAccountState. This caused totalBalance to flicker between the correct spot-inclusive value and $0 on every WebSocket tick for single-DEX users.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e061239. Configure here.
| // Today the in-app HyperLiquid market surface is USDC-collateralized only, | ||
| // so USDH must not inflate the shared funded-state path that hides Add Funds. | ||
| // Non-stablecoin spot assets (HYPE, PURR, …) also remain excluded. | ||
| const SPOT_COLLATERAL_COINS = new Set<string>(['USDC']); |
There was a problem hiding this comment.
USDH excluded from spot collateral despite PR intent
Medium Severity
SPOT_COLLATERAL_COINS only contains 'USDC', but the PR description explicitly states it is {USDC, USDH} and that "including USDH in the allowlist keeps USDH-only HIP-3 users from hitting the same $0 regression." The old full-fetch path summed ALL spot balances (including USDH); the new addSpotBalanceToAccountState helper excludes USDH, creating a regression for USDH-collateral DEX users who hold only USDH in their spot wallet. The codebase already models USDH as auto-collateral via #isUsdhCollateralDex / #getSpotUsdhBalance.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit e061239. Configure here.
There was a problem hiding this comment.
False positive for the current product as confirmed with MSO.
|
|
||
| if (this.#dexAccountCache.size > 0) { | ||
| this.#aggregateAndNotifySubscribers(); | ||
| } |
There was a problem hiding this comment.
Spot refresh skips notification for single-DEX users
Medium Severity
In #refreshSpotState, the guard if (this.#dexAccountCache.size > 0) before calling #aggregateAndNotifySubscribers() is never true for single-DEX (webData2) users because webData2 updates #cachedAccount directly without populating #dexAccountCache. When the spot fetch completes for these users, subscribers are never notified — the spot-inclusive balance only appears on the next WebSocket tick, causing an unnecessary delay or flicker from $0 to the correct balance.
Reviewed by Cursor Bugbot for commit e061239. Configure here.
There was a problem hiding this comment.
In the single-DEX webData2 path we do populate #dexAccountCache before notifying:
- app/controllers/perps/services/HyperLiquidSubscriptionService.ts:1520
So when #refreshSpotState completes, the existing if (this.#dexAccountCache.size > 0) path does run for single-DEX users:
- app/controllers/perps/services/HyperLiquidSubscriptionService.ts:1087
That flows into #aggregateAndNotifySubscribers(), which re-applies spot balance and notifies on hash change. We also already have a focused regression test for this webData2 behavior:
- app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts:3755
So the reported root cause is false positive
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #29110 +/- ##
==========================================
+ Coverage 82.19% 82.21% +0.02%
==========================================
Files 5069 5079 +10
Lines 133674 133962 +288
Branches 29969 30049 +80
==========================================
+ Hits 109870 110134 +264
- Misses 16334 16345 +11
- Partials 7470 7483 +13 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
Tag selection rationale:
No other feature areas are impacted - changes are fully contained within Perps controllers, services, UI components, and test infrastructure. Performance Test Selection: |
|
|
✅ E2E Fixture Validation — Schema is up to date |




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) 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:totalBalanceavailableBalanceHyperLiquidSubscriptionServicewebData2 + clearinghouseState callbacks)00adaptAccountStateFromSDK(data.clearinghouseState, undefined)never fetched/attachedspotClearinghouseStatePerpsController.getAccountState({standalone: true}))00HyperLiquidProvider.ts:5545-5573)PerpsController.getAccountState())101.13…)0Why it surfaced now
Two independent changes in the week leading up to the incident made a long-standing omission visible at scale:
feat(perps): disk-backed cold-start cache for instant data display(#27898, merged 2026-04-11) changedusePerpsLiveAccountto 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 becauseHyperLiquidSubscriptionService.ts:1406and:1604have passedspotState=undefinedtoadaptAccountStateFromSDKsince December 2025 / February 2026 (git blame). Flipping the trust order from disk cache → live stream exposed the pre-existing zero on first paint.Neither change is the root cause. The fix is on the MetaMask side: the streamed and standalone account paths must read
spotClearinghouseStatealongsideclearinghouseStateand include spot balance intotalBalancefor parity with the full-fetch path.What this PR does
spotClearinghouseState.balancesintoAccountState.totalBalancevia the sharedaddSpotBalanceToAccountStatehelper. 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.HyperLiquidProvider.#isUsdhCollateralDex/#getSpotUsdhBalance); including USDH in the allowlist keeps USDH-only HIP-3 users from hitting the same $0 regression.Add FundsCTA gates ontotalBalance.PerpsMarketDetailsView.showAddFundsCTAnow checkstotalBalance < 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.availableBalance.useDefaultPayWithTokenWhenNoPerpsBalancegates on withdrawable so spot-funded / margin-locked accounts still get an external pay token preselected inPerpsOrderView. CTA correctness is preserved by the component-leveltotalBalanceguard.#spotStateGenerationtoken +#spotStatePromiseUserAddresstracker inHyperLiquidSubscriptionService.#ensureSpotStateonly shares in-flight promises when the user matches;#refreshSpotStatediscards result + error if generation was bumped post-await;cleanUp/clearAllbump generation and null promise refs. Prevents user-A's spot fetch from re-populating the cache after a switch to user B.#refreshSpotStatenow awaitsensureSubscriptionClientbeforegetInfoClient()(which throws on fresh instances) so the firstsubscribeToAccounton a cold service gets the spot-adjusted snapshot instead of perps-only until resubscribe.NaNguard inaddSpotBalanceToAccountStatekeepsFallbackDataDisplaysentinels intact when upstreamtotalBalanceis non-numeric.What this PR deliberately does NOT change
usePerpsOrderForm.ts,PerpsOrderView.tsx) keep readingavailableBalance. 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 balanceincident.AccountState. Considered addingavailableToTradeBalance(see #29090) orspotUsdcBalance(see #29092); both leak HL primitives into the shared protocol-agnostic contract and will need reshaping once Portfolio Margin graduates from pre-alpha. Reusing existingtotalBalancefor the CTA gate solves the incident with zero contract changes.token_balance * oracle_price * ltv, LTV 0.5 for HYPE,borrow_cap(USDC) = 1000per 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.@metamask/perps-controllerbefore 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
Supersedes: #29090, #29092 (both introduce a new
AccountStatefield; this PR achieves the same user-visible outcome viatotalBalancewithout a contract change)Manual testing steps
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:Expected captures (after fix):
{stream,fetch,standalone}_totalBalance = "101.13506928"for the0x316BDE155acd07609872a56Bc32CcfB0B13201fATrading fixture; CTA state{addFundsVisible:false, longButtonVisible:true, shortButtonVisible:true}.Screenshots/Recordings
Recipe:
evidence/recipe.jsonon this branch — captures the 3 balance paths, screenshots PerpsHome + PerpsMarketDetails, and probes CTA testIDs on every run.Shows
$0— the streamed value (spot-less). PerpsHome renders thePerpsEmptyBalanceplaceholder instead of Withdraw + Add Funds action buttons.$101.14balance + "$0.00 available" subtitle + Withdraw / Add Funds row (non-empty funded-state UI)Shows "Add Funds" CTA instead of Long / Short buttons. Trade path blocked for spot-only accounts.
Long + Short buttons, no "Add Funds" CTA
Visual before-fix screenshot was blocked by intermittent iOS Simulator crashes during this session (unrelated Apple
libsystem_sim_platformissue). Trace-level evidence from the unfixed code stands: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.jsoncaptures (fromevidence/recipe.jsonrun on commit7f0e9def6f):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
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
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
$0perps balance by folding eligible spot collateral (USDC only) intoAccountState.totalBalanceacross full fetch, standalone fetch, and WebSocket-streamed account updates via newgetSpotBalance/addSpotBalanceToAccountStatehelpers.Updates
HyperLiquidSubscriptionServiceto fetch/cachespotClearinghouseState(with generation-based anti-stale guards) and apply spot-adjusted totals for both multi-DEX aggregation and single-DEXwebData2updates;HyperLiquidProvider’s standalonegetAccountStatepath now also fetches spot state and applies the same adjustment.Adjusts
PerpsMarketDetailsViewfunding 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.Reviewed by Cursor Bugbot for commit 385c39c. Bugbot is set up for automated code reviews on this repo. Configure here.