Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 4809
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 { 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>

Check warning on line 26 in app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'Text' is deprecated.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ30ZCHt3yG1EEAjBWiZ&open=AZ30ZCHt3yG1EEAjBWiZ&pullRequest=29674
</Box>
);
}
Loading
Loading