Skip to content

fix: align streamed hyperliquid total balance with account state#8533

Draft
geositta wants to merge 1 commit intomainfrom
perps/fix-stream-account-spot-parity-core-wt
Draft

fix: align streamed hyperliquid total balance with account state#8533
geositta wants to merge 1 commit intomainfrom
perps/fix-stream-account-spot-parity-core-wt

Conversation

@geositta
Copy link
Copy Markdown

Explanation

This change fixes a Perps controller inconsistency where HyperLiquid’s streamed account data and one-shot account fetches disagreed on totalBalance.

getAccountState() already included spot balances, but the live subscription path only aggregated perps clearinghouse balances. That caused Perps surfaces to show $0 total balance for accounts funded via spot-backed collateral but with no perps clearinghouse balance.

The fix updates the streamed HyperLiquid account aggregation to include spot balances in totalBalance, using the same spot-inclusive logic as the fetch path. This restores parity between stream and fetch for the same account, so Perps headers now show the correct total balance without needing any UI side workaround.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

runway-github Bot added a commit to MetaMask/metamask-mobile that referenced this pull request Apr 21, 2026
<!--
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>
runway-github Bot added a commit to MetaMask/metamask-mobile that referenced this pull request Apr 21, 2026
<!--
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>
chloeYue pushed a commit to MetaMask/metamask-mobile that referenced this pull request Apr 21, 2026
…29145)

- 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>
pull Bot pushed a commit to Reality2byte/metamask-mobile that referenced this pull request Apr 21, 2026
<!--
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
([MetaMask#29089](MetaMask#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`
([MetaMask#27898](MetaMask#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
[MetaMask#29090](MetaMask#29090)) or
`spotUsdcBalance` (see
[MetaMask#29092](MetaMask#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
[MetaMask#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:
[MetaMask#29090](MetaMask#29090),
[MetaMask#29092](MetaMask#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>
runway-github Bot added a commit to MetaMask/metamask-mobile that referenced this pull request Apr 22, 2026
<!--
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>
runway-github Bot added a commit to MetaMask/metamask-mobile that referenced this pull request Apr 22, 2026
<!--
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>
runway-github Bot pushed a commit to MetaMask/metamask-mobile that referenced this pull request Apr 22, 2026
…p-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>
runway-github Bot added a commit to MetaMask/metamask-mobile that referenced this pull request Apr 22, 2026
<!--
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>
runway-github Bot added a commit to MetaMask/metamask-mobile that referenced this pull request Apr 22, 2026
<!--
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>
runway-github Bot pushed a commit to MetaMask/metamask-mobile that referenced this pull request Apr 22, 2026
…p-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant