Skip to content

Commit 8ab3233

Browse files
chore(runway): cherry-pick fix: hide big transaction fee on Perps withdraw when no quotes available cp-7.74.0 (#29314)
- fix: hide big transaction fee on Perps withdraw when no quotes available cp-7.74.0 (#29313) ## **Description** When no viable quote existed for a Perps withdraw (e.g. $0.1 → ETH on Ethereum), the confirmation still showed a big transaction fee and an enabled Withdraw button, letting the user submit into a failing transaction. `useNoPayTokenQuotesAlert`'s `isOptionalOnly` check was matching the post-quote `sourceAmount.targetTokenAddress` (the destination token, e.g. native ETH `0x0…0`) against a `skipIfBalance` required token on a different chain (Arbitrum native gas, also `0x0…0`). That false match suppressed the `NoPayTokenQuotes` alert. `isOptionalOnly` is only meaningful for non-post-quote flows, so this PR bypasses it for post-quote. The existing `CustomAmountInfo` logic then hides the fee rows and shows the disabled "No quotes" button as designed. ## **Changelog** CHANGELOG entry: Fixed Perps withdraw showing a transaction fee and enabled button when no route was available; the rows now hide and the button shows "No quotes". ## **Related issues** Fixes: [#29297](#29297) ## **Manual testing steps** ~~~gherkin Feature: Perps withdraw handles missing quotes Scenario: no viable quote for the amount Given user is on the Perps withdraw confirmation When user enters "0.1" and selects "ETH on Ethereum" And user presses Done Then "Transaction fee" and "You'll receive" rows are not shown And the primary button shows "No quotes" and is disabled Scenario: viable quote exists (regression) Given user is on the Perps withdraw confirmation When user enters a viable amount and selects any receive token And user presses Done Then "Transaction fee" and "You'll receive" rows are shown And the primary button shows "Withdraw" and is enabled ~~~ ## **Screenshots/Recordings** ### **Before** <img width="448" height="221" alt="image" src="https://github.com/user-attachments/assets/727d8f21-ce3a-41af-a90e-d30bcadc5c1b" /> ### **After** $0.1 <img width="460" height="220" alt="image" src="https://github.com/user-attachments/assets/05877a43-e94a-46ff-85d4-0ad7844fd622" /> $0.5 <img width="457" height="240" alt="image" src="https://github.com/user-attachments/assets/773271b5-a001-405b-b608-4c5fc6de9564" /> ## **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** > Changes blocking alert logic in transaction confirmations for post-quote flows, which can affect whether users can proceed with withdrawals; risk is moderated by targeted condition and added tests. > > **Overview** > Fixes a false-negative in the `NoPayTokenQuotes` blocking alert for *post-quote* flows by bypassing the `isOptionalOnly` (skip-if-balance) suppression when `useTransactionPayIsPostQuote` is true, preventing cross-chain native-token address collisions (e.g., `0x0…0`) from hiding the no-quotes state. > > Adds/updates unit tests to cover the non-post-quote optional-token case and a regression scenario where a post-quote destination token address would previously suppress the alert. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f3b1072. 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> [0769701](0769701) Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> Co-authored-by: Daniel <80175477+dan437@users.noreply.github.com>
1 parent 8705283 commit 8ab3233

2 files changed

Lines changed: 88 additions & 5 deletions

File tree

app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ import { strings } from '../../../../../../locales/i18n';
1313
import {
1414
useIsTransactionPayLoading,
1515
useTransactionPayFiatPayment,
16+
useTransactionPayIsPostQuote,
1617
useTransactionPayQuotes,
18+
useTransactionPayRequiredTokens,
1719
useTransactionPaySourceAmounts,
1820
} from '../pay/useTransactionPayData';
1921
import {
2022
TransactionPayQuote,
23+
TransactionPayRequiredToken,
2124
TransactionPaySourceAmount,
2225
} from '@metamask/transaction-pay-controller';
2326

@@ -50,6 +53,12 @@ describe('useNoPayTokenQuotesAlert', () => {
5053
const useIsTransactionPayLoadingMock = jest.mocked(
5154
useIsTransactionPayLoading,
5255
);
56+
const useTransactionPayIsPostQuoteMock = jest.mocked(
57+
useTransactionPayIsPostQuote,
58+
);
59+
const useTransactionPayRequiredTokensMock = jest.mocked(
60+
useTransactionPayRequiredTokens,
61+
);
5362

5463
beforeEach(() => {
5564
jest.resetAllMocks();
@@ -66,6 +75,8 @@ describe('useNoPayTokenQuotesAlert', () => {
6675
useTransactionPaySourceAmountsMock.mockReturnValue([
6776
{} as TransactionPaySourceAmount,
6877
]);
78+
useTransactionPayIsPostQuoteMock.mockReturnValue(false);
79+
useTransactionPayRequiredTokensMock.mockReturnValue([]);
6980
jest.mocked(useTransactionPayFiatPayment).mockReturnValue(undefined);
7081
});
7182

@@ -137,4 +148,65 @@ describe('useNoPayTokenQuotesAlert', () => {
137148

138149
expect(result.current).toStrictEqual([]);
139150
});
151+
152+
// Non-post-quote: `sourceAmount.targetTokenAddress` is a required-token
153+
// address, so matching it against a `skipIfBalance` required token means the
154+
// only required token is optional (gas) and no quote is needed.
155+
it('returns no alerts when all source amounts target skipIfBalance required tokens (non-post-quote)', () => {
156+
const optionalTokenAddress =
157+
'0x0000000000000000000000000000000000000000' as Hex;
158+
159+
useTransactionPaySourceAmountsMock.mockReturnValue([
160+
{
161+
targetTokenAddress: optionalTokenAddress,
162+
} as TransactionPaySourceAmount,
163+
]);
164+
165+
useTransactionPayRequiredTokensMock.mockReturnValue([
166+
{
167+
address: optionalTokenAddress,
168+
skipIfBalance: true,
169+
} as TransactionPayRequiredToken,
170+
]);
171+
172+
const { result } = runHook();
173+
174+
expect(result.current).toStrictEqual([]);
175+
});
176+
177+
// Regression for #29297: perps withdraw $0.1 → ETH on Ethereum. The
178+
// destination native token address (`0x0…0`) was false-matching the
179+
// Arbitrum native gas required token (also `0x0…0`, `skipIfBalance: true`),
180+
// making `isOptionalOnly` true and suppressing the "No quotes" alert, which
181+
// let the UI render a huge bogus `targetNetwork` fee.
182+
it('returns alert for post-quote even when sourceAmount target address false-matches a skipIfBalance required token', () => {
183+
const nativeTokenAddress =
184+
'0x0000000000000000000000000000000000000000' as Hex;
185+
186+
useTransactionPayIsPostQuoteMock.mockReturnValue(true);
187+
188+
useTransactionPaySourceAmountsMock.mockReturnValue([
189+
{
190+
targetTokenAddress: nativeTokenAddress,
191+
} as TransactionPaySourceAmount,
192+
]);
193+
194+
useTransactionPayRequiredTokensMock.mockReturnValue([
195+
{
196+
address: nativeTokenAddress,
197+
chainId: '0xa4b1' as Hex,
198+
skipIfBalance: true,
199+
} as TransactionPayRequiredToken,
200+
]);
201+
202+
const { result } = runHook();
203+
204+
expect(result.current).toEqual([
205+
expect.objectContaining({
206+
key: AlertKeys.NoPayTokenQuotes,
207+
severity: Severity.Danger,
208+
isBlocking: true,
209+
}),
210+
]);
211+
});
140212
});

app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { strings } from '../../../../../../locales/i18n';
77
import {
88
useIsTransactionPayLoading,
99
useTransactionPayFiatPayment,
10+
useTransactionPayIsPostQuote,
1011
useTransactionPayQuotes,
1112
useTransactionPayRequiredTokens,
1213
useTransactionPaySourceAmounts,
@@ -19,18 +20,28 @@ export function useNoPayTokenQuotesAlert() {
1920
const isQuotesLoading = useIsTransactionPayLoading();
2021
const sourceAmounts = useTransactionPaySourceAmounts();
2122
const requiredTokens = useTransactionPayRequiredTokens();
23+
const isPostQuote = useTransactionPayIsPostQuote();
2224

2325
const fiatAmount = Number(fiatPayment?.amountFiat);
2426
const hasValidFiatAmount = Number.isFinite(fiatAmount) && fiatAmount > 0;
2527
const hasSelectedFiatPaymentMethod = Boolean(
2628
fiatPayment?.selectedPaymentMethodId,
2729
);
2830

29-
const isOptionalOnly = (sourceAmounts ?? []).every(
30-
(t) =>
31-
requiredTokens?.find((rt) => rt.address === t.targetTokenAddress)
32-
?.skipIfBalance,
33-
);
31+
// For non-post-quote flows, sourceAmount.targetTokenAddress refers to a
32+
// required token address, so matching against `requiredTokens` is valid.
33+
// For post-quote flows (perps/predict/moneyAccount withdraw, musdConversion),
34+
// sourceAmount.targetTokenAddress is the destination token address, so this
35+
// lookup is meaningless and can false-match a skipped gas token across
36+
// chains (e.g. destination native ETH `0x0…0` vs. Arbitrum native gas
37+
// `0x0…0`). See issue #29297.
38+
const isOptionalOnly =
39+
!isPostQuote &&
40+
(sourceAmounts ?? []).every(
41+
(t) =>
42+
requiredTokens?.find((rt) => rt.address === t.targetTokenAddress)
43+
?.skipIfBalance,
44+
);
3445

3546
const shouldShowNonFiatNoQuotesAlert =
3647
payToken &&

0 commit comments

Comments
 (0)