Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [7.75.1]

### Fixed

- Fixed Hyperliquid withdraw showing $0 and being blocked for users on Unified Account mode. (#29492)

## [7.75.0]

### Added
Expand Down Expand Up @@ -11365,7 +11371,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957)
- [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954)

[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...HEAD
[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.1...HEAD
[7.75.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...v7.75.1
[7.75.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.3...v7.75.0
[7.74.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.2...v7.74.3
[7.74.2]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.1...v7.74.2
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,8 @@ android {
applicationId "io.metamask"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionName "7.75.0"
versionCode 4784
versionName "7.75.1"
versionCode 4800
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ describe('PerpsWithdrawView', () => {
beforeEach(() => {
jest.clearAllMocks();
(useNavigation as jest.Mock).mockReturnValue(mockNavigation);
const mockUsePerpsLiveAccount =
jest.requireMock('../../hooks/stream').usePerpsLiveAccount;
mockUsePerpsLiveAccount.mockReturnValue({
account: {
availableBalance: '1000.00',
marginUsed: '0.00',
unrealizedPnl: '0.00',
returnOnEquity: '0.00',
totalBalance: '1000.00',
},
isInitialLoading: false,
});
});

describe('Component Rendering', () => {
Expand All @@ -296,6 +308,32 @@ describe('PerpsWithdrawView', () => {
).toBeOnTheScreen();
});

it('uses availableToTradeBalance for the displayed Unified Account balance', () => {
const mockUsePerpsLiveAccount =
jest.requireMock('../../hooks/stream').usePerpsLiveAccount;
mockUsePerpsLiveAccount.mockReturnValue({
account: {
availableBalance: '0.00',
availableToTradeBalance: '2500.00',
marginUsed: '0.00',
unrealizedPnl: '0.00',
returnOnEquity: '0.00',
totalBalance: '2500.00',
},
isInitialLoading: false,
});

renderWithProviders(<PerpsWithdrawView />);

expect(
screen.getByText(
strings('perps.withdrawal.available_balance', {
amount: '$2,500',
}),
),
).toBeOnTheScreen();
});

it('renders percentage buttons when focused', () => {
renderWithProviders(<PerpsWithdrawView />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,16 @@ const PerpsWithdrawView: React.FC = () => {
// Get withdrawal tokens from hook
const { destToken } = useWithdrawTokens();

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

const formattedBalance = useMemo(
() => formatPerpsFiat(availableBalance),
Expand Down Expand Up @@ -154,7 +159,7 @@ const PerpsWithdrawView: React.FC = () => {
usePerpsMeasurement({
traceName: TraceName.PerpsWithdrawView,
conditions: [
!!account?.availableBalance,
!!(account?.availableToTradeBalance ?? account?.availableBalance),
!!destToken,
availableBalance !== undefined,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,28 @@ describe('usePerpsBalanceTokenFilter', () => {
}
});

it('prefers availableToTradeBalance for Unified Account users', () => {
// Unified Account / Portfolio Margin: collateral lives in spot, so
// HL's `clearinghouseState.withdrawable` (mirrored as availableBalance)
// is $0. The synthetic Perps balance row in the Pay-with sheet must
// read the unified-aware `availableToTradeBalance` instead.
mockUseSelector.mockReturnValue({
availableBalance: '0.00',
availableToTradeBalance: '2500.00',
});
const inputTokens: AssetType[] = [];

const { result } = renderHook(() => usePerpsBalanceTokenFilter());
const output = result.current(inputTokens);

expect(output).toHaveLength(1);
expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true);
if (isHighlightedItemOutsideAssetList(output[0])) {
expect(output[0].name_description).toBe('$2500.00');
expect(output[0].fiat).toBe('$2500.00');
}
});

it('uses zero balance when perps account is null', () => {
mockUseSelector.mockImplementation(
(selector: (state: unknown) => unknown) => {
Expand Down
10 changes: 9 additions & 1 deletion app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ export function usePerpsBalanceTokenFilter(): (
return tokens;
}

const availableBalance = perpsAccount?.availableBalance || '0';
// Prefer `availableToTradeBalance` so Unified Account / Portfolio
// Margin users see their real spendable balance in the Pay-with
// header — `availableBalance` mirrors HL's perps-only
// `clearinghouseState.withdrawable`, which is $0 in unified mode.
const availableBalance =
perpsAccount?.availableToTradeBalance ??
perpsAccount?.availableBalance ??
'0';
const balanceInSelectedCurrency = formatFiat(
new BigNumber(availableBalance),
);
Expand Down Expand Up @@ -135,6 +142,7 @@ export function usePerpsBalanceTokenFilter(): (
onPerpsPaymentTokenChange,
isPerpsBalanceSelected,
perpsAccount?.availableBalance,
perpsAccount?.availableToTradeBalance,
transactionMeta,
],
);
Expand Down
20 changes: 20 additions & 0 deletions app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,26 @@ describe('usePerpsPaymentTokens', () => {
expect(hyperliquidUsdc.balanceFiat).toBe('$0.00');
});

it('uses availableToTradeBalance for Unified Account users', () => {
// Unified Account / Portfolio Margin: collateral lives in spot, so HL's
// `clearinghouseState.withdrawable` is $0. The Pay-with sheet must read
// `availableToTradeBalance` (perps + folded spot USDC) instead.
mockUsePerpsLiveAccount.mockReturnValue({
account: {
...mockAccountState,
availableBalance: '0',
availableToTradeBalance: '2500.00',
},
isInitialLoading: false,
});

const { result } = renderHook(() => usePerpsPaymentTokens());

const hyperliquidUsdc = result.current[0];
expect(hyperliquidUsdc.balance).toBe('2500000000');
expect(hyperliquidUsdc.balanceFiat).toBe('$2500.00');
});

it('handles null account state', () => {
mockUsePerpsLiveAccount.mockReturnValue({
account: null,
Expand Down
9 changes: 7 additions & 2 deletions app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ export function usePerpsPaymentTokens(): PerpsToken[] {
// Use ref to store previous token array
const previousTokensRef = useRef<PerpsToken[]>([]);

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

// Get all chain IDs to search for tokens (exclude Hyperliquid chains)
Expand Down
17 changes: 17 additions & 0 deletions app/components/UI/Perps/hooks/useWithdrawValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ describe('useWithdrawValidation', () => {
expect(result.current.availableBalance).toBe('1000');
});

it('prefers availableToTradeBalance for Unified Account target state', () => {
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
account: {
availableBalance: '$0.00',
availableToTradeBalance: '$2500.00',
},
isInitialLoading: false,
});

const { result } = renderHook(() =>
useWithdrawValidation({ withdrawAmount: '100' }),
);

expect(result.current.availableBalance).toBe('2500');
expect(result.current.hasInsufficientBalance).toBe(false);
});

it('should handle empty balance', () => {
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
account: {
Expand Down
7 changes: 5 additions & 2 deletions app/components/UI/Perps/hooks/useWithdrawValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ export const useWithdrawValidation = ({
const perpsNetwork = usePerpsNetwork();
const isTestnet = perpsNetwork === 'testnet';

// Truncate to 2 decimal places so validation matches the displayed balance.
// Release-branch bridge for Unified Account: availableToTradeBalance includes
// collateral HL can use in target mode. The full balance contract will replace
// this with an explicit withdrawableBalance field.
const availableBalance = useMemo(() => {
const balance = account?.availableBalance || '0';
const balance =
account?.availableToTradeBalance ?? account?.availableBalance ?? '0';
Comment thread
chloeYue marked this conversation as resolved.
return truncateToTwoDecimals(parseCurrencyString(balance)).toString();
}, [account]);

Expand Down
20 changes: 10 additions & 10 deletions app/components/UI/Perps/services/PerpsConnectionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jest.mock('@metamask/perps-controller', () => {
TradingReadinessCache: {
clear: jest.fn(),
clearAll: jest.fn(),
clearDexAbstraction: jest.fn(),
clearUnifiedAccount: jest.fn(),
clearBuilderFee: jest.fn(),
clearReferral: jest.fn(),
get: jest.fn(),
Expand Down Expand Up @@ -919,27 +919,27 @@ describe('PerpsConnectionManager', () => {
});
});

describe('DEX Abstraction Cache Clearing (PR 25334)', () => {
describe('Unified Account Cache Clearing (PR 25334)', () => {
beforeEach(() => {
jest.clearAllMocks();
});

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

// Act
PerpsConnectionManager.clearDexAbstractionCache(network, userAddress);
PerpsConnectionManager.clearUnifiedAccountCache(network, userAddress);

// Assert - should call clearDexAbstraction, NOT clear (which deletes entire entry)
// Assert - should call clearUnifiedAccount, NOT clear (which deletes entire entry)
expect(
mockTradingReadinessCache.clearDexAbstraction,
mockTradingReadinessCache.clearUnifiedAccount,
).toHaveBeenCalledWith(network, userAddress);
expect(mockTradingReadinessCache.clear).not.toHaveBeenCalled();
expect(mockDevLogger.log).toHaveBeenCalledWith(
'PerpsConnectionManager: DEX abstraction cache cleared',
'PerpsConnectionManager: Unified Account cache cleared',
{ network, userAddress },
);
});
Expand All @@ -950,11 +950,11 @@ describe('PerpsConnectionManager', () => {
const userAddress = '0xTestnetUser12345678901234567890123456';

// Act
PerpsConnectionManager.clearDexAbstractionCache(network, userAddress);
PerpsConnectionManager.clearUnifiedAccountCache(network, userAddress);

// Assert
expect(
mockTradingReadinessCache.clearDexAbstraction,
mockTradingReadinessCache.clearUnifiedAccount,
).toHaveBeenCalledWith(network, userAddress);
});
});
Expand Down
10 changes: 5 additions & 5 deletions app/components/UI/Perps/services/PerpsConnectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1441,16 +1441,16 @@ class PerpsConnectionManagerClass {
}

/**
* Clear DEX abstraction cache for a specific address
* Clear unified account cache for a specific address
* Useful for debugging or allowing user to retry after rejecting signature
* Note: This only clears DEX abstraction state, preserving builder fee and referral states
* Note: This only clears unified account state, preserving builder fee and referral states
*/
clearDexAbstractionCache(
clearUnifiedAccountCache(
network: 'mainnet' | 'testnet',
userAddress: string,
): void {
TradingReadinessCache.clearDexAbstraction(network, userAddress);
DevLogger.log('PerpsConnectionManager: DEX abstraction cache cleared', {
TradingReadinessCache.clearUnifiedAccount(network, userAddress);
DevLogger.log('PerpsConnectionManager: Unified Account cache cleared', {
network,
userAddress,
});
Expand Down
21 changes: 21 additions & 0 deletions app/components/UI/Perps/utils/formatUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,27 @@ export const parseCurrencyString = (formattedValue: string): number => {
return isNegative ? -parsed : parsed;
};

/**
* Formats a perps balance (availableBalance, totalBalance, etc.) as fiat,
* truncating down to 2 decimals first so the displayed value never exceeds
* the actual withdrawable amount. Use this for any balance that a user might
* try to act on (withdraw Max, insufficient-balance comparisons), so the
* display matches what the underlying flow can actually transact.
*
* Accepts raw numeric strings (e.g. "50.389"), formatted strings
* (e.g. "$1,232.39"), or numbers. See `parseCurrencyString`.
*/
export const formatPerpsBalance = (
balance: string | number | null | undefined,
): string => {
if (balance === null || balance === undefined || balance === '') {
return formatPerpsFiat(0);
}
const numeric =
typeof balance === 'string' ? parseCurrencyString(balance) : balance;
return formatPerpsFiat(truncateToTwoDecimals(numeric));
};

/**
* Parses formatted percentage strings back to numeric values
* @param formattedValue - Formatted percentage string (handles %, +/- signs)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { strings } from '../../../../../../../locales/i18n';
import Text, {
TextColor,
Expand All @@ -8,27 +8,22 @@ import { useStyles } from '../../../../../../component-library/hooks';
import { Box } from '../../../../../UI/Box/Box';
import { AlignItems } from '../../../../../UI/Box/box.types';
import { usePerpsLiveAccount } from '../../../../../UI/Perps/hooks/stream/usePerpsLiveAccount';
import {
formatPerpsFiat,
parseCurrencyString,
} from '../../../../../UI/Perps/utils/formatUtils';
import { formatPerpsBalance } from '../../../../../UI/Perps/utils/formatUtils';
import styleSheet from './perps-withdraw-balance.styles';

export function PerpsWithdrawBalance() {
const { styles } = useStyles(styleSheet, {});
const { account } = usePerpsLiveAccount();

const balanceFormatted = useMemo(() => {
if (!account?.availableBalance) return formatPerpsFiat(0);
return formatPerpsFiat(parseCurrencyString(account.availableBalance));
}, [account?.availableBalance]);
const availableBalance =
account?.availableToTradeBalance ?? account?.availableBalance;

return (
<Box alignItems={AlignItems.center} style={styles.container}>
<Text
variant={TextVariant.BodyMDMedium}
color={TextColor.Alternative}
>{`${strings('confirm.available_balance')}${balanceFormatted}`}</Text>
>{`${strings('confirm.available_balance')}${formatPerpsBalance(availableBalance)}`}</Text>
</Box>
);
}
Loading
Loading