Skip to content

Commit b5947ba

Browse files
chore(runway): cherry-pick fix(perps): HL Unified-mode live balance — spotState ws + tradeable-balance + total-balance math cp-7.72.2 (#29259)
- fix(perps): HL Unified-mode live balance — spotState ws + tradeable-balance + total-balance math cp-7.72.2 (#29226) ## **Description** TAT-3016 follow-up covering the remaining HL balance gaps after the initial spot-balance parity work. **What's broken on main** 1. `AccountState.totalBalance` is never updated live after a limit order is placed or cancelled — the HL spot clearinghouse state changes but nothing reflects it in our streamed cache. The REST `#refreshSpotState` path only runs on cold-start and standalone fetches, so the cached snapshot goes stale on every on-chain event (local trade, external-client trade, funding, liquidation, deposit, transfer). 2. `AccountState.availableToTradeBalance` doesn't exist — order-entry surfaces read `availableBalance` (= HL `withdrawable`), which is always `$0` on Unified-mode accounts whose collateral is held as spot USDC. Users with tradeable balance see the app refuse to open an order. 3. `totalBalance` on the three account-state paths sums `perps.accountValue + spot.total` without subtracting `spot.hold`. On Unified/PM the held margin is reported in both fields, so pre-fix `totalBalance` inflates by the margin amount whenever a limit order is placed and deflates when cancelled — even though no wealth changed hands. **What this PR does** - Subscribes to HL's `spotState` WebSocket channel alongside the existing `webData2/3` user-data subscription. Handler updates `#cachedSpotState`, bumps `#spotStateGeneration` to invalidate any in-flight REST race, and re-runs `#aggregateAndNotifySubscribers` so UI consumers see spot-folded totals within one network round-trip of the change. REST fallback stays for cold-start and the standalone path. - Adds `AccountState.availableToTradeBalance` as a first-class optional field: `withdrawable + (spot.total − spot.hold)` for HL, `availableBalance` trivial default for other providers. Order-entry surfaces (`PerpsMarketBalanceActions`, `PerpsMarketDetailsView`, `useDefaultPayWithTokenWhenNoPerpsBalance`, `usePerpsOrderForm`) read `availableToTradeBalance ?? availableBalance`. Withdraw path (`PerpsWithdrawView`) keeps reading `availableBalance` unchanged so the withdraw row never leaks the spot fold. - Subtracts `spot.hold` from the `totalBalance` sum in both the single-DEX adapter and the aggregated fold helper. On Standard mode `spot.hold = 0` so the subtraction is a no-op; on Unified/PM it cancels the double-count. Result: `totalBalance` no longer ping-pongs on limit place/cancel, matching HL web. - Exposes `HyperLiquidProvider.getExchangeClient()` as a non-interface escape hatch for agentic validation flows that drive HL mutations directly. - Adds a `perps-withdraw-available-balance-text` testID to anchor the non-regression check that withdraw keeps rendering `availableBalance`. - Adds the latest HL reference docs (`account-abstraction-modes.md`, `portfolio-margin.md`, `margin-tiers.md`, updated `subscriptions.md` + `margining.md`) so the code rationale can cite them. **Follow-ups (not in this PR)** - Rename `availableBalance → withdrawableBalance` (TAT-3047) — pure rename against main, kept separate so OTA cherry-picks don't see symbol drift. - Agentic regression recipe exercising mode-flip + limit-cycle + REST parity — tracked separately. ## **Changelog** CHANGELOG entry: Fixed Perps balance not refreshing after trades, funding, or transfers for HyperLiquid users, and corrected total balance inflation on Unified-mode accounts. ## **Related issues** Fixes: [TAT-3016](https://consensyssoftware.atlassian.net/browse/TAT-3016) Supersedes: #29150, #29217 (both closed). ## **Manual testing steps** ```gherkin Feature: HL spotState live-balance refresh Scenario: Spot-funded Unified account sees live balance Given the Trading fixture (0x316B…01fA) is connected in Unified mode And the Perps screen is open Then AccountState.availableToTradeBalance reflects withdrawable + (spot.total − spot.hold) And PerpsMarketBalanceActions 'Available' row shows the same value And PerpsWithdrawView 'Available Perps balance' row shows $0 (withdrawable only) And PerpsOrderView 'Pay with' row defaults to 'Perps balance' Scenario: Limit order cycle leaves totalBalance stable When the user places a limit order on BTC Then AccountState.availableToTradeBalance drops by the reserved margin And AccountState.totalBalance is unchanged When the user cancels the limit Then AccountState.availableToTradeBalance returns to baseline within 5s And AccountState.totalBalance is still unchanged Scenario: Screenshot parity with HL web Then the total shown in the MetaMask Perps header matches app.hyperliquid.xyz (within $0.20) And the order-form 'Available' value matches HL 'Available to trade' ``` ## **Screenshots/Recordings** ### **Before** <img width="422" height="865" alt="image" src="https://github.com/user-attachments/assets/c83cba7e-c70d-442a-9fd3-db0feb7341a0" /> ### **After** <img width="412" height="881" alt="image" src="https://github.com/user-attachments/assets/fd351457-1233-4105-9388-658c527f144e" /> ## **Pre-merge author checklist** - [x] 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 - [x] 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 - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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 [756b701](756b701) [TAT-3016]: https://consensyssoftware.atlassian.net/browse/TAT-3016?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
1 parent 86438b3 commit b5947ba

20 files changed

Lines changed: 699 additions & 38 deletions

app/components/UI/Perps/Perps.testIds.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ export const PerpsWithdrawViewSelectorsIDs = {
323323
DEST_TOKEN_AREA: 'dest-token-area',
324324
CONTINUE_BUTTON: 'continue-button',
325325
BOTTOM_SHEET_TOOLTIP: 'withdraw-bottom-sheet-tooltip',
326+
RECEIVE_VALUE: 'perps-withdraw-receive-value',
327+
FEE_VALUE: 'perps-withdraw-fee-value',
328+
TIME_VALUE: 'perps-withdraw-time-value',
329+
// Must render availableBalance only (not availableToTradeBalance):
330+
// withdraw does not offer spot collateral.
331+
AVAILABLE_BALANCE_TEXT: 'perps-withdraw-available-balance-text',
326332
};
327333

328334
// ========================================

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,12 +436,14 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
436436
useDefaultPayWithTokenWhenNoPerpsBalance();
437437
const { depositWithConfirmation } = usePerpsTrading();
438438
const { navigateToConfirmation } = useConfirmNavigation();
439-
const availableBalance = Number.parseFloat(
440-
account?.availableBalance?.toString() ?? '0',
439+
const tradeableBalance = Number.parseFloat(
440+
account?.availableToTradeBalance?.toString() ??
441+
account?.availableBalance?.toString() ??
442+
'0',
441443
);
442444
const hasDirectOrderFundingPath =
443445
!isLoadingAccount &&
444-
(availableBalance >= PERPS_MIN_BALANCE_THRESHOLD ||
446+
(tradeableBalance >= PERPS_MIN_BALANCE_THRESHOLD ||
445447
defaultPayTokenWhenNoPerpsBalance !== null);
446448

447449
const handleAddFunds = useCallback(async () => {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,11 @@ const PerpsWithdrawView: React.FC = () => {
406406
]}
407407
/>
408408
</Box>
409-
<Text variant={TextVariant.BodyMD} color={TextColor.Alternative}>
409+
<Text
410+
variant={TextVariant.BodyMD}
411+
color={TextColor.Alternative}
412+
testID={PerpsWithdrawViewSelectorsIDs.AVAILABLE_BALANCE_TEXT}
413+
>
410414
{strings('perps.withdrawal.available_balance', {
411415
amount: formattedBalance,
412416
})}

app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,13 @@ const PerpsMarketBalanceActions: React.FC<PerpsMarketBalanceActionsProps> = ({
182182
[stopBalanceAnimation],
183183
);
184184

185-
const availableBalance = perpsAccount?.availableBalance || '0';
185+
// Order-entry surface reads availableToTradeBalance (withdrawable +
186+
// unreserved spot collateral). Withdraw surfaces keep reading
187+
// availableBalance directly.
188+
const availableBalance =
189+
perpsAccount?.availableToTradeBalance ??
190+
perpsAccount?.availableBalance ??
191+
'0';
186192

187193
// Show skeleton while loading initial account data
188194
if (isInitialLoading) {

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

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

50-
if (availableBalance > PERPS_MIN_BALANCE_THRESHOLD) {
48+
if (tradeableBalance > PERPS_MIN_BALANCE_THRESHOLD) {
5149
return null;
5250
}
5351
if (!allowlistAssets?.length) {
@@ -97,6 +95,7 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment
9795
}, [
9896
featureEnabled,
9997
perpsAccount?.availableBalance,
98+
perpsAccount?.availableToTradeBalance,
10099
allowlistAssets,
101100
activeProvider,
102101
currentNetwork,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ export function usePerpsOrderForm(
9292
const availableBalance = Number.parseFloat(
9393
effectiveAvailableBalanceParam != null
9494
? effectiveAvailableBalanceParam.toString()
95-
: (account?.availableBalance?.toString() ?? '0'),
95+
: (account?.availableToTradeBalance?.toString() ??
96+
account?.availableBalance?.toString() ??
97+
'0'),
9698
);
9799

98100
// When paying with a custom token, use selected token amount in USD (including 0); otherwise use Perps balance

app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,7 @@ describe('hyperLiquidAdapter', () => {
10771077

10781078
expect(result).toEqual({
10791079
availableBalance: '700.25',
1080+
availableToTradeBalance: '700.25', // withdrawable + free spot (no spot provided)
10801081
marginUsed: '300.25',
10811082
unrealizedPnl: '24.5', // 50.0 + (-25.5)
10821083
returnOnEquity: '7.991673605328893', // Calculated from weighted return and margin
@@ -1120,6 +1121,7 @@ describe('hyperLiquidAdapter', () => {
11201121

11211122
expect(result).toEqual({
11221123
availableBalance: '350.0',
1124+
availableToTradeBalance: '850.5', // withdrawable 350 + free spot 500.5 (hold = 0)
11231125
marginUsed: '150.0',
11241126
unrealizedPnl: '100',
11251127
returnOnEquity: '0',
@@ -1158,6 +1160,7 @@ describe('hyperLiquidAdapter', () => {
11581160

11591161
expect(result).toEqual({
11601162
availableBalance: '800.0',
1163+
availableToTradeBalance: '800', // withdrawable + 0 (no spot totals)
11611164
marginUsed: '200.0',
11621165
unrealizedPnl: '0',
11631166
returnOnEquity: '0',

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,7 +2148,7 @@ describe('HyperLiquidProvider', () => {
21482148
const accountState = await provider.getAccountState();
21492149

21502150
expect(accountState).toBeDefined();
2151-
expect(accountState.totalBalance).toBe('20500'); // 10000 (spot) + 10500 (perps marginSummary)
2151+
expect(accountState.totalBalance).toBe('19500'); // 10500 (perps) + 10000 (spot.total) - 1000 (spot.hold, double-counted in accountValue)
21522152
expect(
21532153
mockClientService.getInfoClient().clearinghouseState,
21542154
).toHaveBeenCalled();
@@ -3786,7 +3786,7 @@ describe('HyperLiquidProvider', () => {
37863786

37873787
const accountState = await hip3Provider.getAccountState();
37883788

3789-
expect(parseFloat(accountState.totalBalance)).toBe(20500);
3789+
expect(parseFloat(accountState.totalBalance)).toBe(19500); // perps 10500 + spot.total 10000 - spot.hold 1000
37903790
expect(parseFloat(accountState.marginUsed)).toBe(500);
37913791
expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({
37923792
user: '0x123',
@@ -9821,4 +9821,19 @@ describe('HyperLiquidProvider', () => {
98219821
expect(Array.isArray(markets)).toBe(true);
98229822
});
98239823
});
9824+
9825+
describe('getExchangeClient escape hatch', () => {
9826+
it('delegates to the client service and resolves with the underlying ExchangeClient', async () => {
9827+
const sentinel = mockClientService.getExchangeClient();
9828+
await expect(provider.getExchangeClient()).resolves.toBe(sentinel);
9829+
});
9830+
9831+
it('propagates errors thrown by the client service', async () => {
9832+
const bomb = new Error('client not initialized');
9833+
mockClientService.getExchangeClient = jest.fn(() => {
9834+
throw bomb;
9835+
});
9836+
await expect(provider.getExchangeClient()).rejects.toBe(bomb);
9837+
});
9838+
});
98249839
});

app/controllers/perps/providers/HyperLiquidProvider.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CaipAccountId, hasProperty } from '@metamask/utils';
22
import type { Hex } from '@metamask/utils';
3+
import type { ExchangeClient } from '@nktkas/hyperliquid';
34
import { v4 as uuidv4 } from 'uuid';
45

56
import type { CandlePeriod } from '../constants/chartConfig';
@@ -7772,6 +7773,19 @@ export class HyperLiquidProvider implements PerpsProvider {
77727773
}
77737774
}
77747775

7776+
/**
7777+
* Escape hatch for agentic validation flows and test harnesses that drive
7778+
* HL mutations directly. NOT part of the PerpsProvider interface.
7779+
* Production code paths must go through the provider's own methods.
7780+
*
7781+
* @returns A promise resolving to the underlying HyperLiquid SDK
7782+
* ExchangeClient. Promise shape matches the existing agentic flows
7783+
* (hl-provision-fixture) that chain `.then` on the result.
7784+
*/
7785+
public async getExchangeClient(): Promise<ExchangeClient> {
7786+
return this.#clientService.getExchangeClient();
7787+
}
7788+
77757789
/**
77767790
* Disconnect provider
77777791
*

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

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,12 @@ describe('HyperLiquidSubscriptionService', () => {
283283
}, 0);
284284
return Promise.resolve(mockSubscription);
285285
}),
286+
spotState: jest.fn((_params: any, _callback: any) =>
287+
// Default: subscribe resolves but never emits. Tests that need the
288+
// push-driven path call mockSubscriptionClient.spotState.mock.calls[0][1]
289+
// manually to drive the handler.
290+
Promise.resolve(mockSubscription),
291+
),
286292
l2Book: jest.fn((_params: any, callback: any) => {
287293
// Simulate l2Book data
288294
setTimeout(() => {
@@ -3684,6 +3690,121 @@ describe('HyperLiquidSubscriptionService', () => {
36843690
});
36853691
});
36863692

3693+
describe('spotState WebSocket Subscription', () => {
3694+
it('establishes a spotState subscription on subscribeToAccount', async () => {
3695+
const unsubscribe = service.subscribeToAccount({
3696+
callback: jest.fn(),
3697+
});
3698+
await jest.runAllTimersAsync();
3699+
3700+
expect(mockSubscriptionClient.spotState).toHaveBeenCalledWith(
3701+
expect.objectContaining({ user: expect.stringMatching(/^0x/) }),
3702+
expect.any(Function),
3703+
);
3704+
3705+
unsubscribe();
3706+
});
3707+
3708+
it('does not re-subscribe spotState for the same user', async () => {
3709+
const unsubscribe1 = service.subscribeToAccount({
3710+
callback: jest.fn(),
3711+
});
3712+
const unsubscribe2 = service.subscribeToAccount({
3713+
callback: jest.fn(),
3714+
});
3715+
await jest.runAllTimersAsync();
3716+
3717+
expect(mockSubscriptionClient.spotState).toHaveBeenCalledTimes(1);
3718+
3719+
unsubscribe1();
3720+
unsubscribe2();
3721+
});
3722+
3723+
it('re-notifies account subscribers when a spotState push arrives', async () => {
3724+
// Seed aggregation with a perps tick so #dexAccountCache is non-empty,
3725+
// which is the guard the handler uses before calling
3726+
// #aggregateAndNotifySubscribers.
3727+
const firstCallback = jest.fn();
3728+
const firstUnsubscribe = service.subscribeToAccount({
3729+
callback: firstCallback,
3730+
});
3731+
await jest.runAllTimersAsync();
3732+
3733+
const notifyCallback = jest.fn();
3734+
const unsubscribe = service.subscribeToAccount({
3735+
callback: notifyCallback,
3736+
});
3737+
await jest.runAllTimersAsync();
3738+
3739+
const callsBefore = notifyCallback.mock.calls.length;
3740+
3741+
const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1];
3742+
spotListener({
3743+
user: '0x123',
3744+
spotState: {
3745+
balances: [
3746+
{
3747+
coin: 'USDC',
3748+
token: 0,
3749+
hold: '0',
3750+
total: '123.45',
3751+
entryNtl: '123.45',
3752+
},
3753+
],
3754+
},
3755+
});
3756+
3757+
expect(notifyCallback.mock.calls.length).toBeGreaterThan(callsBefore);
3758+
3759+
firstUnsubscribe();
3760+
unsubscribe();
3761+
});
3762+
3763+
it('ignores spotState events for a different user', async () => {
3764+
// First seed perps state so the handler's re-aggregate guard could fire.
3765+
const unsubscribe = service.subscribeToAccount({
3766+
callback: jest.fn(),
3767+
});
3768+
await jest.runAllTimersAsync();
3769+
3770+
const observerCallback = jest.fn();
3771+
const observerUnsubscribe = service.subscribeToAccount({
3772+
callback: observerCallback,
3773+
});
3774+
await jest.runAllTimersAsync();
3775+
3776+
const callsBefore = observerCallback.mock.calls.length;
3777+
3778+
const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1];
3779+
spotListener({
3780+
user: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
3781+
spotState: { balances: [] },
3782+
});
3783+
3784+
expect(observerCallback.mock.calls.length).toBe(callsBefore);
3785+
3786+
observerUnsubscribe();
3787+
unsubscribe();
3788+
});
3789+
3790+
it('unsubscribes spotState when the last account subscriber leaves', async () => {
3791+
const unsubSpot = jest.fn().mockResolvedValue(undefined);
3792+
mockSubscriptionClient.spotState.mockResolvedValueOnce({
3793+
unsubscribe: unsubSpot,
3794+
});
3795+
3796+
const unsubscribe = service.subscribeToAccount({
3797+
callback: jest.fn(),
3798+
});
3799+
await jest.runAllTimersAsync();
3800+
3801+
unsubscribe();
3802+
await jest.runAllTimersAsync();
3803+
3804+
expect(unsubSpot).toHaveBeenCalled();
3805+
});
3806+
});
3807+
36873808
describe('spot-adjusted account balance parity', () => {
36883809
it('includes spot balance exactly once in streamed totalBalance across multiple DEXs', async () => {
36893810
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({

0 commit comments

Comments
 (0)