Skip to content

Commit 11e58e2

Browse files
authored
fix: perps withdraw token selection and balance validation (#28599)
## **Description** Two fixes for the Perps Withdraw flow: - **Zero-balance token selection reverts to mUSD on first try**: When selecting a zero-balance token from the asset picker, `TokensController.addTokens` was fire-and-forget (`.catch(noop)`), so `setPayToken` ran before the controller could resolve the token's metadata. The payment token was silently cleared and the UI fell back to mUSD. Now awaits `addTokens` before calling `setPayToken`. Only reproduced on first selection after fresh install — subsequent attempts worked because the token was already tracked. - **Insufficient funds when typing full available balance**: The raw balance (e.g. `16.069`) was displayed rounded up to `$16.07` via `formatPerpsFiat`, but validation compared against the raw value — so typing `16.07` was rejected (`16.07 > 16.069`). Now truncates (floors) the balance to 2 decimal places for both display and validation, so the user sees `$16.06` and can withdraw exactly that amount. ## **Changelog** CHANGELOG entry: Fixed perps withdraw zero-balance token selection reverting to mUSD and insufficient funds error when typing full balance ## **Related issues** Fixes: CONF-1160 ## **Manual testing steps** Feature: Perps Withdraw Scenario: user selects zero-balance token on fresh install - Given user opens perps withdraw for the first time (or cleared app data) - When user selects a token with zero balance from the Receive asset picker - Then the selected token persists and does not revert to mUSD Scenario: user types full available balance - Given user has a perps balance where the raw value has more than 2 decimal places - When user looks at the displayed balance (should be truncated down, e.g. $16.06 not $16.07) - And user types that displayed balance amount - Then no insufficient funds error is shown - When user types one cent more than the displayed balance - Then insufficient funds error is shown ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches withdrawal validation and token-selection flow; risk is moderate because it changes async ordering around `TokensController.addTokens` and alters displayed/validated balances, but scope is limited and covered by unit tests. > > **Overview** > Fixes two Perps withdraw edge cases: **(1)** selecting a zero-balance receive token now reliably persists by making `pay-with-modal` *await* `TokensController.addTokens` before calling `setPayToken`, preventing fallback to the default token. > > **(2)** withdraw balance display and validation are aligned by truncating (not rounding) available balance to 2 decimals via new `truncateToTwoDecimals` in `formatUtils`, used in both `PerpsWithdrawView` and `useWithdrawValidation`; adds targeted unit tests for truncation and call ordering. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4c6f0ef. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com>
1 parent 8b1df75 commit 11e58e2

File tree

7 files changed

+104
-15
lines changed

7 files changed

+104
-15
lines changed

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ import { TraceName } from '../../../../../util/trace';
5454
import { usePerpsLiveAccount } from '../../hooks/stream';
5555
import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
5656
import { useWithdrawValidation } from '../../hooks/useWithdrawValidation';
57-
import { formatPerpsFiat, parseCurrencyString } from '../../utils/formatUtils';
57+
import {
58+
formatPerpsFiat,
59+
parseCurrencyString,
60+
truncateToTwoDecimals,
61+
} from '../../utils/formatUtils';
5862

5963
import type { Hex } from '@metamask/utils';
6064
import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar/Avatar.types';
@@ -104,11 +108,10 @@ const PerpsWithdrawView: React.FC = () => {
104108
// Get withdrawal tokens from hook
105109
const { destToken } = useWithdrawTokens();
106110

107-
// Parse available balance from perps account state
111+
// Truncate to 2 decimals so the user can withdraw exactly what they see.
108112
const availableBalance = useMemo(() => {
109113
if (!account?.availableBalance) return 0;
110-
// Use parseCurrencyString to properly parse formatted currency
111-
return parseCurrencyString(account.availableBalance);
114+
return truncateToTwoDecimals(parseCurrencyString(account.availableBalance));
112115
}, [account?.availableBalance]);
113116

114117
const formattedBalance = useMemo(

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,37 @@ describe('useWithdrawValidation', () => {
104104
expect(result.current.hasInsufficientBalance).toBe(true);
105105
});
106106

107+
it('should truncate available balance to 2 decimal places for validation', () => {
108+
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
109+
account: {
110+
availableBalance: '$16.069',
111+
},
112+
isInitialLoading: false,
113+
});
114+
115+
const { result } = renderHook(() =>
116+
useWithdrawValidation({ withdrawAmount: '16.06' }),
117+
);
118+
119+
expect(result.current.availableBalance).toBe('16.06');
120+
expect(result.current.hasInsufficientBalance).toBe(false);
121+
});
122+
123+
it('should show insufficient balance when typing more than truncated balance', () => {
124+
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
125+
account: {
126+
availableBalance: '$16.069',
127+
},
128+
isInitialLoading: false,
129+
});
130+
131+
const { result } = renderHook(() =>
132+
useWithdrawValidation({ withdrawAmount: '16.07' }),
133+
);
134+
135+
expect(result.current.hasInsufficientBalance).toBe(true);
136+
});
137+
107138
it('should detect amount below minimum', () => {
108139
const { result } = renderHook(() =>
109140
useWithdrawValidation({ withdrawAmount: '1.5' }),

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
HYPERLIQUID_ASSET_CONFIGS,
66
WITHDRAWAL_CONSTANTS,
77
} from '@metamask/perps-controller';
8-
import { parseCurrencyString } from '../utils/formatUtils';
8+
import {
9+
parseCurrencyString,
10+
truncateToTwoDecimals,
11+
} from '../utils/formatUtils';
912
import { usePerpsNetwork } from './index';
1013
import { usePerpsLiveAccount } from './stream';
1114

@@ -25,12 +28,10 @@ export const useWithdrawValidation = ({
2528
const perpsNetwork = usePerpsNetwork();
2629
const isTestnet = perpsNetwork === 'testnet';
2730

28-
// Available balance from perps account
31+
// Truncate to 2 decimal places so validation matches the displayed balance.
2932
const availableBalance = useMemo(() => {
3033
const balance = account?.availableBalance || '0';
31-
// Use parseCurrencyString to properly parse formatted currency
32-
// Return as string to maintain compatibility with components
33-
return parseCurrencyString(balance).toString();
34+
return truncateToTwoDecimals(parseCurrencyString(balance)).toString();
3435
}, [account]);
3536

3637
// Get withdrawal route for constraints

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
formatPositionSize,
1212
formatLeverage,
1313
parseCurrencyString,
14+
truncateToTwoDecimals,
1415
parsePercentageString,
1516
formatTransactionDate,
1617
formatDateSection,
@@ -941,6 +942,31 @@ describe('formatUtils', () => {
941942
});
942943
});
943944

945+
describe('truncateToTwoDecimals', () => {
946+
it('truncates without rounding up', () => {
947+
expect(truncateToTwoDecimals(16.069)).toBe(16.06);
948+
expect(truncateToTwoDecimals(16.999)).toBe(16.99);
949+
expect(truncateToTwoDecimals(0.009)).toBe(0);
950+
});
951+
952+
it('preserves values with 2 or fewer decimals', () => {
953+
expect(truncateToTwoDecimals(16.07)).toBe(16.07);
954+
expect(truncateToTwoDecimals(16)).toBe(16);
955+
expect(truncateToTwoDecimals(0)).toBe(0);
956+
});
957+
958+
it('handles IEEE 754 edge cases correctly', () => {
959+
expect(truncateToTwoDecimals(10.29)).toBe(10.29);
960+
expect(truncateToTwoDecimals(1.005)).toBe(1);
961+
expect(truncateToTwoDecimals(0.1 + 0.2)).toBe(0.3);
962+
});
963+
964+
it('handles negative values', () => {
965+
expect(truncateToTwoDecimals(-16.069)).toBe(-16.06);
966+
expect(truncateToTwoDecimals(-10.29)).toBe(-10.29);
967+
});
968+
});
969+
944970
describe('parsePercentageString', () => {
945971
it('should parse formatted percentage strings', () => {
946972
expect(parsePercentageString('+2.50%')).toBe(2.5);

app/components/UI/Perps/utils/formatUtils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export {
2323
} from '@metamask/perps-controller';
2424
export { formatPerpsFiat }; // re-export via local import (needed by formatPositiveFiat below)
2525

26+
/**
27+
* Truncates a number to 2 decimal places without rounding up.
28+
* Uses BigNumber to avoid IEEE 754 floating-point errors
29+
* (e.g. `Math.floor(10.29 * 100) / 100` = 10.28).
30+
*/
31+
export const truncateToTwoDecimals = (value: number): number =>
32+
new BigNumber(value).decimalPlaces(2, BigNumber.ROUND_DOWN).toNumber();
33+
2634
/**
2735
* Formats a fee value as USD currency with appropriate decimal places
2836
* @param fee - Raw numeric or string fee value (e.g., 1234.56, not token minimal denomination)

app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,23 @@ describe('PayWithModal', () => {
438438
expect(getAvailableTokensMock).not.toHaveBeenCalled();
439439
});
440440

441-
it('adds zero-balance token to TokensController on withdraw selection', async () => {
441+
it('awaits addTokens before calling setPayToken for zero-balance withdraw token', async () => {
442+
const callOrder: string[] = [];
443+
444+
mockAddTokens.mockImplementation(
445+
() =>
446+
new Promise<void>((resolve) => {
447+
setTimeout(() => {
448+
callOrder.push('addTokens');
449+
resolve();
450+
}, 0);
451+
}),
452+
);
453+
454+
setPayTokenMock.mockImplementation(() => {
455+
callOrder.push('setPayToken');
456+
});
457+
442458
const zeroBalanceToken = {
443459
accountType: EthAccountType.Eoa,
444460
address: '0xZeroBalanceToken',
@@ -461,8 +477,12 @@ describe('PayWithModal', () => {
461477
fireEvent.press(getByText('Zero Token'));
462478
});
463479

480+
await waitFor(() => {
481+
expect(setPayTokenMock).toHaveBeenCalled();
482+
});
483+
464484
expect(mockAddTokens).toHaveBeenCalled();
465-
expect(setPayTokenMock).toHaveBeenCalled();
485+
expect(callOrder).toStrictEqual(['addTokens', 'setPayToken']);
466486
});
467487
});
468488

app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function PayWithModal() {
9999

100100
const handleTokenSelect = useCallback(
101101
(token: AssetType) => {
102-
const onClosed = () => {
102+
const onClosed = async () => {
103103
if (
104104
hasTransactionType(transactionMeta, [TransactionType.musdConversion])
105105
) {
@@ -123,15 +123,15 @@ export function PayWithModal() {
123123

124124
// Ensure the token is tracked by TokensController so the pay
125125
// controller can resolve its metadata (symbol, decimals, balance).
126-
// This is needed for zero-balance tokens from the catalog.
126+
// Must complete before setPayToken so the controller can find the token.
127127
if (isWithdraw && token.balance === '0' && !token.isNative) {
128128
const { TokensController, NetworkController } = Engine.context;
129129
try {
130130
const networkClientId =
131131
NetworkController.findNetworkClientIdByChainId(
132132
token.chainId as Hex,
133133
);
134-
TokensController.addTokens(
134+
await TokensController.addTokens(
135135
[
136136
{
137137
address: token.address,
@@ -140,7 +140,7 @@ export function PayWithModal() {
140140
},
141141
],
142142
networkClientId,
143-
).catch(noop);
143+
);
144144
} catch {
145145
// Network not configured — skip
146146
}

0 commit comments

Comments
 (0)