Skip to content

Commit b166faf

Browse files
chore(runway): cherry-pick feat(perps): force unified account (#29673)
- feat(perps): force unified account (#29492) ## **Description** HyperLiquid is deprecating DEX Abstraction mode (~May 9). This PR forces every Perps user onto **Unified Account** mode on app open and fixes the withdraw + balance-display flows that were broken in the target state. ### 1. Forced migration to Unified Account Migration paths by current abstraction mode: - `default` / `disabled` → silently migrated via `agentSetAbstraction({ abstraction: 'u' })` — no signing prompt - `dexAbstraction` → one-time EIP-712 prompt via `userSetAbstraction({ user, abstraction: 'unifiedAccount' })` — agent-key path is blocked by HL for this transition - `unifiedAccount` → no-op, cached immediately Key details: - Replaces deprecated `agentEnableDexAbstraction` / `userDexAbstraction` with `agentSetAbstraction` / `userSetAbstraction` / `userAbstraction` - Runs on perps section open (`#ensureReady()`) so users are set up before trading - `TradingReadinessCache` prevents repeated prompts (critical for hardware/QR wallets); `KEYRING_LOCKED` skips the cache so it retries on unlock - In-flight deduplication blocks concurrent signing attempts across provider instances - Segment analytics: `Perp Account Setup` event tracks mode distribution + outcome (`already_enabled` / `migration_required` / `success` / `failed`) ### 2. Withdraw + balance display fix (folded in from #29537) In Unified mode, USDC collateral lives in the spot clearinghouse, so `clearinghouseState.withdrawable` is $0 — pre-fix the withdraw screen showed $0 max with the button disabled, and the confirm-flow alert blocked submission. - `accountUtils.addSpotBalanceToAccountState` folds free spot USDC into `availableToTradeBalance` for Unified / Portfolio Margin; `dexAbstraction` / Standard keep spot separate (fold gated on resolved abstraction mode) - `HyperLiquidSubscriptionService.invalidateUserAbstractionCache(addr)` evicts stale pre-migration mode and re-aggregates immediately. Called by `HyperLiquidProvider` after both successful migration paths so the WS-driven aggregator doesn't serve a $0 balance for ~60s after migration completes. - Withdraw screen, withdraw validation, confirm-flow insufficient-balance alert, and percentage buttons all read `availableToTradeBalance ?? availableBalance` — fallback keeps Standard / legacy callers correct. ## **Changelog** CHANGELOG entry: Fixed Hyperliquid withdraw showing $0 and being blocked for users on Unified Account mode. ## **Related issues** Fixes: TAT-3112 (Unified Account migration), withdrawal break tracked in [TAT-3047](https://consensyssoftware.atlassian.net/browse/TAT-3047) ## **Manual testing steps** ```gherkin Feature: Unified Account migration + withdraw Scenario: First-time migration (default/disabled mode) Given the user has never used Perps When they open the Perps section Then migration runs silently (no prompt) And HIP-3 markets are visible Scenario: dexAbstraction → unifiedAccount migration Given the user has DEX Abstraction enabled When they open the Perps section Then a one-time EIP-712 signing prompt appears When they sign Then HIP-3 markets are visible and trades succeed And reopening Perps does not prompt again Scenario: Unified Account user withdraws spot-funded balance Given the user is in Unified Mode with $0 perps withdrawable and >$0 spot USDC When they open the Withdraw screen Then "Available Perps balance" shows the unified value (perps + free spot USDC) And Max enables and submission proceeds via withdraw3 And spot USDC drops by amount + fee ``` ### Live validation evidence Validated on dev1 mainnet (`0x8dc6…9003`) in the exact bug-class state: - HL mode: `unifiedAccount` / perps `withdrawable`: $0 / spot USDC free: $26.41 - App: `availableBalance` = $0 / `availableToTradeBalance` = $26.41 - Withdraw screen renders **"Available Perps balance: $26.41"** + Max enabled (pre-fix would show $0 / disabled) ## **Screenshots/Recordings** ### **Before** <!-- $0 max + disabled Max button on dev1 mainnet pre-fix --> ### **After** <!-- $26.41 max + Max enabled + successful withdraw on dev1 mainnet --> ## **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 - [x] 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)). #### 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-3047]: https://consensyssoftware.atlassian.net/browse/TAT-3047?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > High risk because it changes Perps account-mode migration/signing flow (including hardware-wallet behavior) and alters withdraw/payment balance calculations that gate user funds and transaction validation. > > **Overview** > Forces Perps users onto HyperLiquid **Unified Account** by replacing deprecated DEX-abstraction checks/calls with `userAbstraction` + `agentSetAbstraction`/`userSetAbstraction`, adding global in-flight/cached gating, retry semantics, and new `Perp Account Setup` analytics. > > Updates withdraw, confirmation, and pay-with flows to prefer `availableToTradeBalance ?? availableBalance`, and changes spot→perps folding to be **mode-gated** (fail-closed when abstraction mode is unknown) so Unified/Portfolio Margin users see spendable USDC while Standard/dexAbstraction users don’t over-report withdrawable funds. > > Renames cache-clearing APIs from DEX abstraction to Unified Account, adds hardware-wallet detection to defer user-sign prompts on browse, and expands tests/docs to cover unified-mode folding, migration paths, and race conditions in spot/account aggregation. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e5495f9. 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: Nick Gambino <nicholas.gambino@consensys.net> Co-authored-by: Arthur Breton <arthur.breton@consensys.net> Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> [cc44460](cc44460) [TAT-3047]: https://consensyssoftware.atlassian.net/browse/TAT-3047?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Alejandro Garcia Anglada <aganglada@gmail.com>
1 parent 2f18776 commit b166faf

31 files changed

Lines changed: 2398 additions & 604 deletions

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,18 @@ describe('PerpsWithdrawView', () => {
277277
beforeEach(() => {
278278
jest.clearAllMocks();
279279
(useNavigation as jest.Mock).mockReturnValue(mockNavigation);
280+
const mockUsePerpsLiveAccount =
281+
jest.requireMock('../../hooks/stream').usePerpsLiveAccount;
282+
mockUsePerpsLiveAccount.mockReturnValue({
283+
account: {
284+
availableBalance: '1000.00',
285+
marginUsed: '0.00',
286+
unrealizedPnl: '0.00',
287+
returnOnEquity: '0.00',
288+
totalBalance: '1000.00',
289+
},
290+
isInitialLoading: false,
291+
});
280292
});
281293

282294
describe('Component Rendering', () => {
@@ -296,6 +308,32 @@ describe('PerpsWithdrawView', () => {
296308
).toBeOnTheScreen();
297309
});
298310

311+
it('uses availableToTradeBalance for the displayed Unified Account balance', () => {
312+
const mockUsePerpsLiveAccount =
313+
jest.requireMock('../../hooks/stream').usePerpsLiveAccount;
314+
mockUsePerpsLiveAccount.mockReturnValue({
315+
account: {
316+
availableBalance: '0.00',
317+
availableToTradeBalance: '2500.00',
318+
marginUsed: '0.00',
319+
unrealizedPnl: '0.00',
320+
returnOnEquity: '0.00',
321+
totalBalance: '2500.00',
322+
},
323+
isInitialLoading: false,
324+
});
325+
326+
renderWithProviders(<PerpsWithdrawView />);
327+
328+
expect(
329+
screen.getByText(
330+
strings('perps.withdrawal.available_balance', {
331+
amount: '$2,500',
332+
}),
333+
),
334+
).toBeOnTheScreen();
335+
});
336+
299337
it('renders percentage buttons when focused', () => {
300338
renderWithProviders(<PerpsWithdrawView />);
301339

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,16 @@ const PerpsWithdrawView: React.FC = () => {
108108
// Get withdrawal tokens from hook
109109
const { destToken } = useWithdrawTokens();
110110

111-
// Truncate to 2 decimals so the user can withdraw exactly what they see.
111+
// Release-branch bridge for Unified Account: availableToTradeBalance includes
112+
// collateral HL can use in target mode. The full balance contract will replace
113+
// this with an explicit withdrawableBalance field. Truncate so users can
114+
// withdraw exactly the amount they see.
112115
const availableBalance = useMemo(() => {
113-
if (!account?.availableBalance) return 0;
114-
return truncateToTwoDecimals(parseCurrencyString(account.availableBalance));
115-
}, [account?.availableBalance]);
116+
const balance =
117+
account?.availableToTradeBalance ?? account?.availableBalance;
118+
if (!balance) return 0;
119+
return truncateToTwoDecimals(parseCurrencyString(balance));
120+
}, [account?.availableBalance, account?.availableToTradeBalance]);
116121

117122
const formattedBalance = useMemo(
118123
() => formatPerpsFiat(availableBalance),
@@ -154,7 +159,7 @@ const PerpsWithdrawView: React.FC = () => {
154159
usePerpsMeasurement({
155160
traceName: TraceName.PerpsWithdrawView,
156161
conditions: [
157-
!!account?.availableBalance,
162+
!!(account?.availableToTradeBalance ?? account?.availableBalance),
158163
!!destToken,
159164
availableBalance !== undefined,
160165
],

app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,28 @@ describe('usePerpsBalanceTokenFilter', () => {
207207
}
208208
});
209209

210+
it('prefers availableToTradeBalance for Unified Account users', () => {
211+
// Unified Account / Portfolio Margin: collateral lives in spot, so
212+
// HL's `clearinghouseState.withdrawable` (mirrored as availableBalance)
213+
// is $0. The synthetic Perps balance row in the Pay-with sheet must
214+
// read the unified-aware `availableToTradeBalance` instead.
215+
mockUseSelector.mockReturnValue({
216+
availableBalance: '0.00',
217+
availableToTradeBalance: '2500.00',
218+
});
219+
const inputTokens: AssetType[] = [];
220+
221+
const { result } = renderHook(() => usePerpsBalanceTokenFilter());
222+
const output = result.current(inputTokens);
223+
224+
expect(output).toHaveLength(1);
225+
expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true);
226+
if (isHighlightedItemOutsideAssetList(output[0])) {
227+
expect(output[0].name_description).toBe('$2500.00');
228+
expect(output[0].fiat).toBe('$2500.00');
229+
}
230+
});
231+
210232
it('uses zero balance when perps account is null', () => {
211233
mockUseSelector.mockImplementation(
212234
(selector: (state: unknown) => unknown) => {

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,14 @@ export function usePerpsBalanceTokenFilter(): (
8383
return tokens;
8484
}
8585

86-
const availableBalance = perpsAccount?.availableBalance || '0';
86+
// Prefer `availableToTradeBalance` so Unified Account / Portfolio
87+
// Margin users see their real spendable balance in the Pay-with
88+
// header — `availableBalance` mirrors HL's perps-only
89+
// `clearinghouseState.withdrawable`, which is $0 in unified mode.
90+
const availableBalance =
91+
perpsAccount?.availableToTradeBalance ??
92+
perpsAccount?.availableBalance ??
93+
'0';
8794
const balanceInSelectedCurrency = formatFiat(
8895
new BigNumber(availableBalance),
8996
);
@@ -135,6 +142,7 @@ export function usePerpsBalanceTokenFilter(): (
135142
onPerpsPaymentTokenChange,
136143
isPerpsBalanceSelected,
137144
perpsAccount?.availableBalance,
145+
perpsAccount?.availableToTradeBalance,
138146
transactionMeta,
139147
],
140148
);

app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,26 @@ describe('usePerpsPaymentTokens', () => {
294294
expect(hyperliquidUsdc.balanceFiat).toBe('$0.00');
295295
});
296296

297+
it('uses availableToTradeBalance for Unified Account users', () => {
298+
// Unified Account / Portfolio Margin: collateral lives in spot, so HL's
299+
// `clearinghouseState.withdrawable` is $0. The Pay-with sheet must read
300+
// `availableToTradeBalance` (perps + folded spot USDC) instead.
301+
mockUsePerpsLiveAccount.mockReturnValue({
302+
account: {
303+
...mockAccountState,
304+
availableBalance: '0',
305+
availableToTradeBalance: '2500.00',
306+
},
307+
isInitialLoading: false,
308+
});
309+
310+
const { result } = renderHook(() => usePerpsPaymentTokens());
311+
312+
const hyperliquidUsdc = result.current[0];
313+
expect(hyperliquidUsdc.balance).toBe('2500000000');
314+
expect(hyperliquidUsdc.balanceFiat).toBe('$2500.00');
315+
});
316+
297317
it('handles null account state', () => {
298318
mockUsePerpsLiveAccount.mockReturnValue({
299319
account: null,

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,16 @@ export function usePerpsPaymentTokens(): PerpsToken[] {
3434
// Use ref to store previous token array
3535
const previousTokensRef = useRef<PerpsToken[]>([]);
3636

37-
// Get Hyperliquid account balance
37+
// Get Hyperliquid account balance. Prefer `availableToTradeBalance` so
38+
// Unified Account / Portfolio Margin users see their real spendable balance
39+
// in the Pay-with sheet — `availableBalance` mirrors HL's perps-only
40+
// `clearinghouseState.withdrawable`, which is $0 in unified mode.
3841
const { account } = usePerpsLiveAccount();
3942
const currentNetwork = usePerpsNetwork();
4043
const hyperliquidBalance = Number.parseFloat(
41-
account?.availableBalance?.toString() || '0',
44+
(
45+
account?.availableToTradeBalance ?? account?.availableBalance
46+
)?.toString() || '0',
4247
);
4348

4449
// Get all chain IDs to search for tokens (exclude Hyperliquid chains)

app/components/UI/Perps/hooks/useWithdrawValidation.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ describe('useWithdrawValidation', () => {
8181
expect(result.current.availableBalance).toBe('1000');
8282
});
8383

84+
it('prefers availableToTradeBalance for Unified Account target state', () => {
85+
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
86+
account: {
87+
availableBalance: '$0.00',
88+
availableToTradeBalance: '$2500.00',
89+
},
90+
isInitialLoading: false,
91+
});
92+
93+
const { result } = renderHook(() =>
94+
useWithdrawValidation({ withdrawAmount: '100' }),
95+
);
96+
97+
expect(result.current.availableBalance).toBe('2500');
98+
expect(result.current.hasInsufficientBalance).toBe(false);
99+
});
100+
84101
it('should handle empty balance', () => {
85102
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
86103
account: {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ export const useWithdrawValidation = ({
2828
const perpsNetwork = usePerpsNetwork();
2929
const isTestnet = perpsNetwork === 'testnet';
3030

31-
// Truncate to 2 decimal places so validation matches the displayed balance.
31+
// Release-branch bridge for Unified Account: availableToTradeBalance includes
32+
// collateral HL can use in target mode. The full balance contract will replace
33+
// this with an explicit withdrawableBalance field.
3234
const availableBalance = useMemo(() => {
33-
const balance = account?.availableBalance || '0';
35+
const balance =
36+
account?.availableToTradeBalance ?? account?.availableBalance ?? '0';
3437
return truncateToTwoDecimals(parseCurrencyString(balance)).toString();
3538
}, [account]);
3639

app/components/UI/Perps/services/PerpsConnectionManager.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jest.mock('@metamask/perps-controller', () => {
77
TradingReadinessCache: {
88
clear: jest.fn(),
99
clearAll: jest.fn(),
10-
clearDexAbstraction: jest.fn(),
10+
clearUnifiedAccount: jest.fn(),
1111
clearBuilderFee: jest.fn(),
1212
clearReferral: jest.fn(),
1313
get: jest.fn(),
@@ -919,27 +919,27 @@ describe('PerpsConnectionManager', () => {
919919
});
920920
});
921921

922-
describe('DEX Abstraction Cache Clearing (PR 25334)', () => {
922+
describe('Unified Account Cache Clearing (PR 25334)', () => {
923923
beforeEach(() => {
924924
jest.clearAllMocks();
925925
});
926926

927-
describe('clearDexAbstractionCache', () => {
928-
it('clears only DEX abstraction for specific network and user address', () => {
927+
describe('clearUnifiedAccountCache', () => {
928+
it('clears only unified account for specific network and user address', () => {
929929
// Arrange
930930
const network = 'mainnet' as const;
931931
const userAddress = '0x1234567890123456789012345678901234567890';
932932

933933
// Act
934-
PerpsConnectionManager.clearDexAbstractionCache(network, userAddress);
934+
PerpsConnectionManager.clearUnifiedAccountCache(network, userAddress);
935935

936-
// Assert - should call clearDexAbstraction, NOT clear (which deletes entire entry)
936+
// Assert - should call clearUnifiedAccount, NOT clear (which deletes entire entry)
937937
expect(
938-
mockTradingReadinessCache.clearDexAbstraction,
938+
mockTradingReadinessCache.clearUnifiedAccount,
939939
).toHaveBeenCalledWith(network, userAddress);
940940
expect(mockTradingReadinessCache.clear).not.toHaveBeenCalled();
941941
expect(mockDevLogger.log).toHaveBeenCalledWith(
942-
'PerpsConnectionManager: DEX abstraction cache cleared',
942+
'PerpsConnectionManager: Unified Account cache cleared',
943943
{ network, userAddress },
944944
);
945945
});
@@ -950,11 +950,11 @@ describe('PerpsConnectionManager', () => {
950950
const userAddress = '0xTestnetUser12345678901234567890123456';
951951

952952
// Act
953-
PerpsConnectionManager.clearDexAbstractionCache(network, userAddress);
953+
PerpsConnectionManager.clearUnifiedAccountCache(network, userAddress);
954954

955955
// Assert
956956
expect(
957-
mockTradingReadinessCache.clearDexAbstraction,
957+
mockTradingReadinessCache.clearUnifiedAccount,
958958
).toHaveBeenCalledWith(network, userAddress);
959959
});
960960
});

app/components/UI/Perps/services/PerpsConnectionManager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,16 +1441,16 @@ class PerpsConnectionManagerClass {
14411441
}
14421442

14431443
/**
1444-
* Clear DEX abstraction cache for a specific address
1444+
* Clear unified account cache for a specific address
14451445
* Useful for debugging or allowing user to retry after rejecting signature
1446-
* Note: This only clears DEX abstraction state, preserving builder fee and referral states
1446+
* Note: This only clears unified account state, preserving builder fee and referral states
14471447
*/
1448-
clearDexAbstractionCache(
1448+
clearUnifiedAccountCache(
14491449
network: 'mainnet' | 'testnet',
14501450
userAddress: string,
14511451
): void {
1452-
TradingReadinessCache.clearDexAbstraction(network, userAddress);
1453-
DevLogger.log('PerpsConnectionManager: DEX abstraction cache cleared', {
1452+
TradingReadinessCache.clearUnifiedAccount(network, userAddress);
1453+
DevLogger.log('PerpsConnectionManager: Unified Account cache cleared', {
14541454
network,
14551455
userAddress,
14561456
});

0 commit comments

Comments
 (0)