Skip to content

Commit dcad69f

Browse files
authored
fix(perps): replace 90% with Max button on Perps Withdraw (#29257)
## **Description** Wires up the Max button on the Perps Withdraw confirmation (replacing the `90%` button) and fixes three bugs uncovered while doing so: - `!isNativePayToken` in `CustomAmountInfo` was inverted for post-quote withdraws (pay token is the destination, not the source) — picking a native destination (BNB/ETH) silently fell back to `90%`. Now gated on `isTransactionPayWithdraw`, which is also synchronous so there's no `90% → Max` flicker on mount. - `useTransactionCustomAmount` was overriding the input fiat with `totals.targetAmount.usd` while `isMaxAmount=true`. For post-quote withdraws that's the destination-chain received value (e.g. BNB after bridge fees, ~$6 for a $50 withdraw), not the withdraw amount — now skipped for withdraw flows. - TPC's `calculatePostQuoteSourceAmounts` substitutes `token.balanceRaw` (the **Arbitrum USDC wallet** balance, not the HyperLiquid balance) when `isMaxAmount=true`, which stranded most of the HL balance on Max. Don't set `isMaxAmount=true` for perps withdraw; route the typed amount through instead. Also added a shared `formatPerpsBalance` helper that truncates down to 2 decimals before formatting, and used it in both `PerpsWithdrawBalance` and `PerpsMarketBalanceActions` (Perps home) so the two surfaces never disagree by a cent. ## **Changelog** CHANGELOG entry: Fixed Perps Withdraw Max so users actually withdraw their full HyperLiquid balance; replaced 90% with a Max button; Perps home and Withdraw now show the same balance. ## **Related issues** Fixes: [CONF-1161](https://consensyssoftware.atlassian.net/browse/CONF-1161) ## **Manual testing steps** ~~~gherkin Feature: Perps Withdraw Max Scenario: Max button replaces 90% Given user is on the Perps Withdraw confirmation Then the percentage row shows 10% / 25% / 50% / Max And Max is visible on first render (no 90% flash) Scenario: Max withdraws the full HyperLiquid balance Given user has $40.40 available on HyperLiquid When user taps Max Then the input field shows $40.40 (not a BNB-denominated fiat value) And "Available balance: $40.40" is shown below the input When user taps Withdraw and submits Then the HyperLiquid balance after the withdraw is 0 And no Arbitrum USDC wallet amount is used Scenario: Max with a native destination token Given user is on the Perps Withdraw confirmation And user selects BNB (or ETH) as the Receive token Then the percentage row still shows Max (not 90%) Scenario: Perps home matches Withdraw page balance Given a HyperLiquid balance of $40.489 Then Perps home shows "$40.48" (total) and "$40.48 available" And the Withdraw page shows "Available balance: $40.48" ~~~ ## **Screenshots/Recordings** ### **Before** ### **After** Max button stable, full balance withdrawn, home and Withdraw match. ## **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. [CONF-1161]: https://consensyssoftware.atlassian.net/browse/CONF-1161?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches confirmation amount entry and Transaction Pay max-handling for withdraw flows, which can affect how much users withdraw/send. Changes are scoped and covered by new unit tests, but regressions could impact displayed/entered amounts and max behavior. > > **Overview** > Enables a true **Max** path for Perps Withdraw confirmations by passing `hasMax` through `PerpsWithdrawInfo`/`CustomAmountInfo` and allowing Max to render even when the (post-quote) selected token is native. > > Fixes withdraw amount correctness in `useTransactionCustomAmount` by skipping the `totals.targetAmount.usd` override for *withdraw* flows and by **not setting `isMaxAmount`** for `perpsWithdraw` when 100% is selected (to avoid Transaction Pay using the wrong source balance). Perps withdraw Max is also truncated down to 2 decimals. > > Adds `formatPerpsBalance` (truncate-then-format) and switches Perps home (`PerpsMarketBalanceActions`) and withdraw balance display to use it, ensuring displayed balances never round above what can actually be withdrawn. Also hardens a Trending hook test by waiting for the state update to avoid flakiness. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 18e60cf. 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 70f8823 commit dcad69f

13 files changed

Lines changed: 324 additions & 26 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ jest.mock('../../../../../images/image-icons', () => ({
200200
// Mock format utils
201201
jest.mock('../../utils/formatUtils', () => ({
202202
formatPerpsFiat: jest.fn((amount) => `$${amount}`),
203+
formatPerpsBalance: jest.fn((amount) => `$${amount}`),
203204
}));
204205

205206
// Mock PerpsBottomSheetTooltip to avoid SafeArea issues

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@ import { useColorPulseAnimation, useBalanceComparison } from '../../hooks';
2222
import { usePerpsHomeActions } from '../../hooks/usePerpsHomeActions';
2323
import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip';
2424
import { usePerpsLiveAccount } from '../../hooks/stream';
25-
import {
26-
formatPerpsFiat,
27-
PRICE_RANGES_MINIMAL_VIEW,
28-
} from '../../utils/formatUtils';
25+
import { formatPerpsBalance } from '../../utils/formatUtils';
2926
import { PerpsMarketBalanceActionsSelectorsIDs } from '../../Perps.testIds';
3027
import { BigNumber } from 'bignumber.js';
3128
import {
@@ -255,7 +252,7 @@ const PerpsMarketBalanceActions: React.FC<PerpsMarketBalanceActionsProps> = ({
255252
isHidden={privacyMode}
256253
length={SensitiveTextLength.Medium}
257254
>
258-
{formatPerpsFiat(totalBalance)}
255+
{formatPerpsBalance(totalBalance)}
259256
</SensitiveText>
260257
</Animated.View>
261258
<Box
@@ -271,10 +268,7 @@ const PerpsMarketBalanceActions: React.FC<PerpsMarketBalanceActionsProps> = ({
271268
isHidden={privacyMode}
272269
length={SensitiveTextLength.Short}
273270
>
274-
{formatPerpsFiat(availableBalance, {
275-
ranges: PRICE_RANGES_MINIMAL_VIEW,
276-
stripTrailingZeros: false,
277-
})}
271+
{formatPerpsBalance(availableBalance)}
278272
</SensitiveText>
279273
<Text variant={TextVariant.BodyMD} color={TextColor.Alternative}>
280274
{' '}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import {
6+
formatPerpsBalance,
67
formatPerpsFiat,
78
formatPnl,
89
formatPercentage,
@@ -967,6 +968,37 @@ describe('formatUtils', () => {
967968
});
968969
});
969970

971+
describe('formatPerpsBalance', () => {
972+
it('truncates values that would otherwise round up under halfExpand', () => {
973+
// Without truncation, Intl.NumberFormat would render $50.39 for 50.389.
974+
// formatPerpsBalance must show $50.38 so the Max button and
975+
// insufficient-balance comparisons stay consistent.
976+
expect(formatPerpsBalance('50.389')).toBe('$50.38');
977+
expect(formatPerpsBalance('50.385')).toBe('$50.38');
978+
expect(formatPerpsBalance('50.399')).toBe('$50.39');
979+
});
980+
981+
it('preserves values that already have two decimals', () => {
982+
expect(formatPerpsBalance('50.39')).toBe('$50.39');
983+
});
984+
985+
it('accepts numeric input', () => {
986+
expect(formatPerpsBalance(50.389)).toBe('$50.38');
987+
expect(formatPerpsBalance(0)).toBe('$0');
988+
});
989+
990+
it('strips currency formatting from input strings', () => {
991+
expect(formatPerpsBalance('$1,232.39')).toBe('$1,232.39');
992+
expect(formatPerpsBalance('$50.389')).toBe('$50.38');
993+
});
994+
995+
it('returns zero for null, undefined, or empty input', () => {
996+
expect(formatPerpsBalance(null)).toBe('$0');
997+
expect(formatPerpsBalance(undefined)).toBe('$0');
998+
expect(formatPerpsBalance('')).toBe('$0');
999+
});
1000+
});
1001+
9701002
describe('parsePercentageString', () => {
9711003
it('should parse formatted percentage strings', () => {
9721004
expect(parsePercentageString('+2.50%')).toBe(2.5);

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,27 @@ export const parseCurrencyString = (formattedValue: string): number => {
334334
return isNegative ? -parsed : parsed;
335335
};
336336

337+
/**
338+
* Formats a perps balance (availableBalance, totalBalance, etc.) as fiat,
339+
* truncating down to 2 decimals first so the displayed value never exceeds
340+
* the actual withdrawable amount. Use this for any balance that a user might
341+
* try to act on (withdraw Max, insufficient-balance comparisons), so the
342+
* display matches what the underlying flow can actually transact.
343+
*
344+
* Accepts raw numeric strings (e.g. "50.389"), formatted strings
345+
* (e.g. "$1,232.39"), or numbers. See `parseCurrencyString`.
346+
*/
347+
export const formatPerpsBalance = (
348+
balance: string | number | null | undefined,
349+
): string => {
350+
if (balance === null || balance === undefined || balance === '') {
351+
return formatPerpsFiat(0);
352+
}
353+
const numeric =
354+
typeof balance === 'string' ? parseCurrencyString(balance) : balance;
355+
return formatPerpsFiat(truncateToTwoDecimals(numeric));
356+
};
357+
337358
/**
338359
* Parses formatted percentage strings back to numeric values
339360
* @param formattedValue - Formatted percentage string (handles %, +/- signs)

app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,15 @@ describe('useTrendingRequest', () => {
239239
chainIds: ['eip155:1', 'eip155:137'],
240240
}),
241241
);
242-
expect(result.current.results).toEqual(mockResults);
243-
expect(result.current.isLoading).toBe(false);
242+
243+
// The `waitFor` above only confirms the mock was invoked. Under load
244+
// (full suite run) the resolved-promise setState has not yet committed
245+
// by the time we check `result.current.results` synchronously — flaky.
246+
// Wait on the actual state transition instead.
247+
await waitFor(() => {
248+
expect(result.current.results).toEqual(mockResults);
249+
expect(result.current.isLoading).toBe(false);
250+
});
244251

245252
spyGetTrendingTokens.mockRestore();
246253
},

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,94 @@ describe('CustomAmountInfo', () => {
533533
});
534534
});
535535

536+
describe('hasMax percentage button', () => {
537+
beforeEach(() => {
538+
// Percentage buttons only render when hasInput is false.
539+
useTransactionCustomAmountMock.mockReturnValue({
540+
amountFiat: '0',
541+
amountHuman: '0',
542+
amountHumanDebounced: '0',
543+
hasInput: false,
544+
isInputChanged: false,
545+
updatePendingAmount: noop,
546+
updatePendingAmountPercentage: noop,
547+
updateTokenAmount: noop,
548+
});
549+
});
550+
551+
it('renders Max when hasMax=true and pay token is non-native', () => {
552+
const { getByText, queryByText } = render({ hasMax: true });
553+
554+
expect(getByText('Max')).toBeOnTheScreen();
555+
expect(queryByText('90%')).not.toBeOnTheScreen();
556+
});
557+
558+
it('falls back to 90% when pay token is native and the flow is not a withdraw (safeguard against sending entire native balance with no gas reserve)', () => {
559+
useTransactionPayTokenMock.mockReturnValue({
560+
payToken: {
561+
address: '0x123',
562+
balanceHuman: '0',
563+
balanceFiat: '0',
564+
balanceRaw: '0',
565+
balanceUsd: '0',
566+
chainId: '0x1',
567+
decimals: 18,
568+
symbol: 'TST',
569+
},
570+
setPayToken: noop as never,
571+
isNative: true,
572+
});
573+
574+
const { getByText, queryByText } = render({ hasMax: true });
575+
576+
expect(getByText('90%')).toBeOnTheScreen();
577+
expect(queryByText('Max')).not.toBeOnTheScreen();
578+
});
579+
580+
it.each([
581+
TransactionType.perpsWithdraw,
582+
TransactionType.predictWithdraw,
583+
TransactionType.moneyAccountWithdraw,
584+
])(
585+
'renders Max for %s even with a native destination token (pay token is destination in post-quote mode, native-gas-reserve safeguard does not apply)',
586+
(transactionType) => {
587+
useTransactionMetadataRequestMock.mockReturnValue({
588+
type: transactionType,
589+
txParams: { from: '0x123' },
590+
} as never);
591+
useTransactionPayTokenMock.mockReturnValue({
592+
payToken: {
593+
address: '0x123',
594+
balanceHuman: '0',
595+
balanceFiat: '0',
596+
balanceRaw: '0',
597+
balanceUsd: '0',
598+
chainId: '0x1',
599+
decimals: 18,
600+
symbol: 'TST',
601+
},
602+
setPayToken: noop as never,
603+
isNative: true,
604+
});
605+
606+
const { getByText, queryByText } = render({
607+
hasMax: true,
608+
transactionType,
609+
});
610+
611+
expect(getByText('Max')).toBeOnTheScreen();
612+
expect(queryByText('90%')).not.toBeOnTheScreen();
613+
},
614+
);
615+
616+
it('renders 90% when hasMax is not provided', () => {
617+
const { getByText, queryByText } = render();
618+
619+
expect(getByText('90%')).toBeOnTheScreen();
620+
expect(queryByText('Max')).not.toBeOnTheScreen();
621+
});
622+
});
623+
536624
describe('showPaymentDetails', () => {
537625
async function pressDone(
538626
getByText: ReturnType<typeof render>['getByText'],

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ import { useRampNavigation } from '../../../../../UI/Ramp/hooks/useRampNavigatio
4646
import { useAccountTokens } from '../../../hooks/send/useAccountTokens';
4747
import { AlignItems } from '../../../../../UI/Box/box.types';
4848
import { strings } from '../../../../../../../locales/i18n';
49-
import { hasTransactionType } from '../../../utils/transaction';
49+
import {
50+
hasTransactionType,
51+
isTransactionPayWithdraw,
52+
} from '../../../utils/transaction';
5053
import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
5154
import {
5255
Button,
@@ -125,6 +128,7 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
125128
const isMoneyAccountDeposit = hasTransactionType(transactionMeta, [
126129
TransactionType.moneyAccountDeposit,
127130
]);
131+
const isWithdraw = isTransactionPayWithdraw(transactionMeta);
128132
const [selectedRecipientAddress, setSelectedRecipientAddress] = useState<
129133
string | undefined
130134
>(undefined);
@@ -289,7 +293,7 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
289293
onDonePress={handleDone}
290294
onPercentagePress={updatePendingAmountPercentage}
291295
hasInput={hasInput}
292-
hasMax={hasMax && !isNativePayToken}
296+
hasMax={hasMax && (isWithdraw || !isNativePayToken)}
293297
/>
294298
)}
295299
{!hasTokens && <BuySection />}

app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,13 @@ describe('PerpsWithdrawInfo', () => {
7171
tokenAddress: ARBITRUM_USDC.address,
7272
});
7373
});
74+
75+
it('passes hasMax=true to CustomAmountInfo so the percentage row shows Max instead of 90%', () => {
76+
render(<PerpsWithdrawInfo />);
77+
78+
expect(mockCustomAmountInfo).toHaveBeenCalledWith(
79+
expect.objectContaining({ hasMax: true }),
80+
expect.anything(),
81+
);
82+
});
7483
});

app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function PerpsWithdrawInfo() {
2525
<CustomAmountInfo
2626
currency={PERPS_CURRENCY}
2727
disablePay={!canSelectWithdrawToken}
28+
hasMax
2829
>
2930
<PerpsWithdrawBalance />
3031
</CustomAmountInfo>

app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ describe('PerpsWithdrawBalance', () => {
4646
).toBeOnTheScreen();
4747
});
4848

49+
it('truncates 3+ decimal balances down to 2 decimals so the displayed value matches the Max button', () => {
50+
// Without truncation, Intl.NumberFormat rounds half-up and would show
51+
// $50.39 for an underlying 50.389 balance, one cent higher than the
52+
// Max button computed via BigNumber.ROUND_DOWN.
53+
mockUsePerpsLiveAccount.mockReturnValue({
54+
account: { availableBalance: '50.389' },
55+
isInitialLoading: false,
56+
} as never);
57+
58+
const { getByText } = renderComponent();
59+
60+
expect(
61+
getByText(`${strings('confirm.available_balance')}$50.38`),
62+
).toBeOnTheScreen();
63+
});
64+
4965
it('renders a zero balance when the live account has no available balance', () => {
5066
mockUsePerpsLiveAccount.mockReturnValue({
5167
account: null,

0 commit comments

Comments
 (0)