From 818e5b710c621cdd4b19d963fac9d45af043c719 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:15:33 +0000 Subject: [PATCH 1/9] fix: prevent flickering of insufficient balance alert until gas station checks complete (#23361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes an issue where the insufficient gas critical alert briefly appears and then disappears once gas-station checks determine that the transaction can be paid with a token. Previously, the UI would render the alert immediately and replace it a moment later, causing a flicker. With this update, the insufficient balance alert is held back until gas-station checks fully complete. ## **Changelog** CHANGELOG entry: Fixed a flicker where the insufficient balance alert appeared before gas-station checks completed ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/6211 ## **Manual testing steps** ```gherkin Feature: Gas UI stability during confirmation Scenario: user observes flickering on the gas UI when ETH balance is zero Given the user has no ETH on Base And the user has a positive balance in stablecoins When the user starts a send transaction And the user proceeds to the confirmation screen Then the gas section of the UI should not flicker ``` ## **Screenshots/Recordings** [mm-mobile-insuficient.webm](https://github.com/user-attachments/assets/c8795448-a364-4d69-9877-30e8c9e79125) [mm-not-enough-funds.webm](https://github.com/user-attachments/assets/c69943ba-d3b5-498d-9c9c-af02b9274844) ### **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 - [x] 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. --- > [!NOTE] > Prevents insufficient balance alert flicker by waiting for gasless support and simulation readiness, adds a new balance-check hook, exposes pending state in gasless support, and updates related UI/tests. > > - **Alerts/UX**: > - Refine `useInsufficientBalanceAlert` to wait for gasless support check (`pending`) and simulation readiness before showing alerts; respect sponsored gas and selected pay/gas tokens to avoid flicker. > - **Hooks**: > - New `useHasInsufficientBalance` centralizes native-balance vs total-fee calculation and exposes `nativeCurrency`. > - Extend `useIsGaslessSupported` to return `pending`; include relay check pending state; update consumers. > - Update `useAutomaticGasFeeTokenSelect` and `SelectedGasFeeToken` to use new balance hook. > - Require `transactionMetadata` in `GasFeeTokenToast` effect deps to gate toast. > - **Tests**: > - Add tests for `useHasInsufficientBalance` and adjust existing tests across gas, alerts, rows, and confirm flows to account for `pending` and new logic. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 99a3df214c143e0bfc35c6754b352d146eacd5e0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../gas-fee-token-toast.tsx | 10 +- .../selected-gas-fee-token.test.tsx | 1 + .../selected-gas-fee-token.tsx | 16 +-- .../gas-fee-details-row.test.tsx | 1 + .../useInsufficientBalanceAlert.test.ts | 45 ++++--- .../alerts/useInsufficientBalanceAlert.ts | 77 +++++------ .../hooks/gas/useIsGaslessSupported.test.ts | 25 ++-- .../hooks/gas/useIsGaslessSupported.ts | 22 +-- .../useTransactionConfirm.test.ts | 1 + .../useAutomaticGasFeeTokenSelect.test.ts | 29 ++-- .../hooks/useAutomaticGasFeeTokenSelect.ts | 4 +- .../hooks/useHasInsufficientBalance.test.ts | 127 ++++++++++++++++++ .../hooks/useHasInsufficientBalance.ts | 46 +++++++ 13 files changed, 294 insertions(+), 110 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/useHasInsufficientBalance.test.ts create mode 100644 app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx index ed8b6aabc7df..d7de15e069a3 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx @@ -36,7 +36,7 @@ export function GasFeeTokenToast() { }); useEffect(() => { - if (!toast || !gasFeeToken) return; + if (!toast || !gasFeeToken || !transactionMetadata) return; if (gasFeeToken.tokenAddress === prevRef.current) return; prevRef.current = gasFeeToken.tokenAddress; @@ -68,7 +68,13 @@ export function GasFeeTokenToast() { }, }, }); - }, [gasFeeToken, tokenSelected, toast, networkImageSource]); + }, [ + gasFeeToken, + tokenSelected, + toast, + networkImageSource, + transactionMetadata, + ]); return null; } diff --git a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx index 5ef0eb32e8c9..0997bbef87d1 100644 --- a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx +++ b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx @@ -59,6 +59,7 @@ describe('SelectedGasFeeToken', () => { mockUseIsGaslessSupported.mockReturnValue({ isSupported: gaslessSupported, isSmartTransaction, + pending: false, }); mockUseNetworkInfo.mockReturnValue({ networkNativeCurrency: 'ETH', diff --git a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx index f67ba77cfcc8..72503efbbbd1 100644 --- a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx +++ b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx @@ -1,21 +1,21 @@ -import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; -import Text from '../../../../../../component-library/components/Texts/Text/Text'; +import React, { useCallback, useState } from 'react'; +import { TouchableOpacity } from 'react-native'; import Icon, { IconName, IconSize, } from '../../../../../../component-library/components/Icons/Icon'; -import styleSheet from './selected-gas-fee-token.styles'; +import Text from '../../../../../../component-library/components/Texts/Text/Text'; import { useStyles } from '../../../../../hooks/useStyles'; import { NATIVE_TOKEN_ADDRESS } from '../../../constants/tokens'; -import React, { useCallback, useState } from 'react'; -import { TouchableOpacity } from 'react-native'; -import { GasFeeTokenIcon, GasFeeTokenIconSize } from '../gas-fee-token-icon'; -import useNetworkInfo from '../../../hooks/useNetworkInfo'; import { useSelectedGasFeeToken } from '../../../hooks/gas/useGasFeeToken'; import { useIsGaslessSupported } from '../../../hooks/gas/useIsGaslessSupported'; -import { GasFeeTokenModal } from '../gas-fee-token-modal'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { useIsInsufficientBalance } from '../../../hooks/useIsInsufficientBalance'; import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; +import useNetworkInfo from '../../../hooks/useNetworkInfo'; +import { GasFeeTokenIcon, GasFeeTokenIconSize } from '../gas-fee-token-icon'; +import { GasFeeTokenModal } from '../gas-fee-token-modal'; +import styleSheet from './selected-gas-fee-token.styles'; export function SelectedGasFeeToken() { const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx index e4f6a33d0a22..7fa241a66f64 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx @@ -161,6 +161,7 @@ describe('GasFeesDetailsRow', () => { mockUseIsGaslessSupported.mockReturnValue({ isSupported: true, isSmartTransaction: false, + pending: false, }); mockUseInsufficientBalanceAlert.mockReturnValue([]); mockUseHideFiatForTestnet.mockReturnValue(false); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index 23e09681d9d0..f512d0e6c68d 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -10,7 +10,6 @@ import { strings } from '../../../../../../locales/i18n'; import { AlertKeys } from '../../constants/alerts'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { Severity } from '../../types/alerts'; -import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; import { useConfirmActions } from '../useConfirmActions'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; import { noop } from 'lodash'; @@ -23,6 +22,8 @@ import { TransactionPaymentToken, } from '@metamask/transaction-pay-controller'; import { Hex } from '@metamask/utils'; +import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; +import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; jest.mock('../../../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../../../util/navigation/navUtils'), @@ -48,6 +49,8 @@ jest.mock('@react-navigation/native', () => { }; }); +jest.mock('../../../../../selectors/preferencesController'); +jest.mock('../useHasInsufficientBalance'); jest.mock('../useConfirmActions'); jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('../pay/useTransactionPayToken'); @@ -71,8 +74,8 @@ describe('useInsufficientBalanceAlert', () => { ); const mockUseAccountNativeBalance = jest.mocked(useAccountNativeBalance); const mockUseConfirmActions = jest.mocked(useConfirmActions); - const mockSelectNetworkConfigurations = jest.mocked( - selectNetworkConfigurations, + const mockSelectUseTransactionSimulations = jest.mocked( + selectUseTransactionSimulations, ); const mockUseTransactionPayToken = jest.mocked(useTransactionPayToken); const mockUseConfirmationContext = jest.mocked(useConfirmationContext); @@ -83,6 +86,7 @@ describe('useInsufficientBalanceAlert', () => { useTransactionPayRequiredTokens, ); const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useHasInsufficientBalanceMock = jest.mocked(useHasInsufficientBalance); const mockChainId = '0x1' as Hex; const mockFromAddress = '0x123'; @@ -103,16 +107,13 @@ describe('useInsufficientBalanceAlert', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: false, + pending: false, }); mockUseAccountNativeBalance.mockReturnValue({ balanceWeiInHex: '0x8', // 8 wei } as unknown as ReturnType); mockUseTransactionMetadataRequest.mockReturnValue(mockTransaction); - mockSelectNetworkConfigurations.mockReturnValue({ - [mockChainId]: { - nativeCurrency: mockNativeCurrency, - }, - } as unknown as ReturnType); + mockSelectUseTransactionSimulations.mockReturnValue(false); mockUseTransactionPayToken.mockReturnValue({ payToken: undefined, setPayToken: noop as never, @@ -150,6 +151,11 @@ describe('useInsufficientBalanceAlert', () => { payToken: undefined, setPayToken: jest.fn(), }); + + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: true, + nativeCurrency: mockNativeCurrency, + }); }); it('return empty array when no transaction metadata is available', () => { @@ -220,17 +226,6 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current[0].key).toBe(AlertKeys.InsufficientBalance); }); - it('return empty array when balance is sufficient for value and gas', () => { - // Transaction needs: value (5) + (maxFeePerGas (3) * gas (2)) = 11 wei - // Balance is 12 wei, no alert - mockUseAccountNativeBalance.mockReturnValue({ - balanceWeiInHex: '0xC', - } as unknown as ReturnType); - - const { result } = renderHook(() => useInsufficientBalanceAlert()); - expect(result.current).toEqual([]); - }); - it('handle transaction with no value but with gas fees', () => { const txWithNoValue = { ...mockTransaction, @@ -297,9 +292,10 @@ describe('useInsufficientBalanceAlert', () => { describe('when ignoreGasFeeToken is true', () => { it('returns empty array', () => { - mockUseAccountNativeBalance.mockReturnValue({ - balanceWeiInHex: '0xC', - } as unknown as ReturnType); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: false, + nativeCurrency: mockNativeCurrency, + }); const { result } = renderHook(() => useInsufficientBalanceAlert({ ignoreGasFeeToken: true }), @@ -332,6 +328,11 @@ describe('useInsufficientBalanceAlert', () => { describe('when isGasFeeSponsored is true', () => { it('returns empty array', () => { + useIsGaslessSupportedMock.mockReturnValue({ + isSmartTransaction: true, + isSupported: true, + pending: false, + }); mockUseAccountNativeBalance.mockReturnValue({ balanceWeiInHex: '0xC', } as unknown as ReturnType); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index bd71f48fd2fd..45fc5145140b 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -1,20 +1,11 @@ import { useMemo } from 'react'; -import { Hex, add0x } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { useSelector } from 'react-redux'; -import { - addHexes, - decimalToHex, - multiplyHexes, -} from '../../../../../util/conversions'; import { strings } from '../../../../../../locales/i18n'; -import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; import { useRampNavigation } from '../../../../UI/Ramp/hooks/useRampNavigation'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { AlertKeys } from '../../constants/alerts'; import { Alert, Severity } from '../../types/alerts'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; -import { useAccountNativeBalance } from '../useAccountNativeBalance'; import { useConfirmActions } from '../useConfirmActions'; import { useConfirmationContext } from '../../context/confirmation-context'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; @@ -22,11 +13,11 @@ import { TransactionType } from '@metamask/transaction-controller'; import { hasTransactionType } from '../../utils/transaction'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; import { useTransactionPayRequiredTokens } from '../pay/useTransactionPayData'; +import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; +import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; const IGNORE_TYPES = [TransactionType.predictWithdraw]; -const HEX_ZERO = '0x0'; - export const useInsufficientBalanceAlert = ({ ignoreGasFeeToken, }: { @@ -34,16 +25,15 @@ export const useInsufficientBalanceAlert = ({ } = {}): Alert[] => { const { goToBuy } = useRampNavigation(); const transactionMetadata = useTransactionMetadataRequest(); - const networkConfigurations = useSelector(selectNetworkConfigurations); - const { balanceWeiInHex } = useAccountNativeBalance( - transactionMetadata?.chainId as Hex, - transactionMetadata?.txParams?.from as string, - ); const { isTransactionValueUpdating } = useConfirmationContext(); const { onReject } = useConfirmActions(); - const { isSupported: isGaslessSupported } = useIsGaslessSupported(); + const { isSupported: isGaslessSupported, pending: isGaslessCheckPending } = + useIsGaslessSupported(); const { payToken } = useTransactionPayToken(); const requiredTokens = useTransactionPayRequiredTokens(); + const isSimulationEnabled = useSelector(selectUseTransactionSimulations); + const { hasInsufficientBalance, nativeCurrency } = + useHasInsufficientBalance(); const primaryRequiredToken = (requiredTokens ?? []).find( (token) => !token.skipIfBalance, @@ -64,34 +54,35 @@ export const useInsufficientBalanceAlert = ({ return []; } - const { txParams, selectedGasFeeToken, isGasFeeSponsored } = + const { selectedGasFeeToken, isGasFeeSponsored, gasFeeTokens } = transactionMetadata; - const { maxFeePerGas, gas, gasPrice } = txParams; - const { nativeCurrency } = - networkConfigurations[transactionMetadata.chainId as Hex]; - const maxFeeNativeInHex = multiplyHexes( - maxFeePerGas ? (decimalToHex(maxFeePerGas) as Hex) : (gasPrice as Hex), - gas as Hex, - ); + const isGasFeeTokensEmpty = gasFeeTokens?.length === 0; - const transactionValue = txParams?.value || HEX_ZERO; - const totalTransactionValue = addHexes(maxFeeNativeInHex, transactionValue); - const totalTransactionInHex = add0x(totalTransactionValue as string); + // Check if gasless check has completed (regardless of result) + const isGaslessCheckComplete = !isGaslessCheckPending; - const balanceWeiInHexBN = new BigNumber(balanceWeiInHex); - const totalTransactionValueBN = new BigNumber(totalTransactionInHex); + // Transaction is sponsored only if it's marked as sponsored AND gasless is supported + const isSponsoredTransaction = isGasFeeSponsored && isGaslessSupported; - const hasInsufficientBalance = balanceWeiInHexBN.lt( - totalTransactionValueBN, - ); + // Simulation is complete if it's disabled, or if enabled and gasFeeTokens is loaded + const isSimulationComplete = !isSimulationEnabled || Boolean(gasFeeTokens); - const isSponsoredTransaction = isGasFeeSponsored && isGaslessSupported; + // Check if user has selected a gas fee token (or we're ignoring that check) + const hasNoGasFeeTokenSelected = ignoreGasFeeToken || !selectedGasFeeToken; + + // Show alert when gasless check is done and either: + // - Gasless is NOT supported (user needs native currency for gas) + // - Gasless IS supported but gasFeeTokens is empty (no alternative tokens available) + const shouldCheckGaslessConditions = + isGaslessCheckComplete && (!isGaslessSupported || isGasFeeTokensEmpty); const showAlert = hasInsufficientBalance && - (ignoreGasFeeToken || !selectedGasFeeToken) && + isSimulationComplete && + hasNoGasFeeTokenSelected && !hasTransactionType(transactionMetadata, IGNORE_TYPES) && + shouldCheckGaslessConditions && !isSponsoredTransaction; if (!showAlert) { @@ -121,15 +112,17 @@ export const useInsufficientBalanceAlert = ({ }, ]; }, [ - balanceWeiInHex, - ignoreGasFeeToken, - isGaslessSupported, - isPayTokenTarget, + transactionMetadata, isTransactionValueUpdating, - networkConfigurations, - onReject, payToken, - transactionMetadata, + isPayTokenTarget, + isGaslessCheckPending, + isGaslessSupported, + isSimulationEnabled, + ignoreGasFeeToken, + hasInsufficientBalance, + nativeCurrency, goToBuy, + onReject, ]); }; diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts index 1e9795b9932e..85e26b8fbd9f 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts @@ -89,6 +89,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: true, isSmartTransaction: true, + pending: false, }), ); }); @@ -102,6 +103,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }), ); }); @@ -127,6 +129,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: true, + pending: false, }), ); }); @@ -145,6 +148,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: true, isSmartTransaction: false, + pending: false, }); }); }); @@ -161,6 +165,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }); }); }); @@ -181,22 +186,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: false, - }); - }); - }); - - it('returns isSupported false and isSmartTransaction: false when no matching chain support in atomicBatch', async () => { - isRelaySupportedMock.mockResolvedValue(true); - - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, - }); - - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + pending: false, }); }); }); @@ -215,6 +205,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }); }); }); @@ -232,6 +223,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }), ); }); @@ -247,6 +239,7 @@ describe('useIsGaslessSupported', () => { expect(result.current).toEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }), ); }); diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts index d7fcdfc14f23..2a8316a0f24f 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts @@ -14,6 +14,7 @@ import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmart * @returns An object containing: * - `isSupported`: `true` if gasless transactions are supported via either 7702 or smart transactions with sendBundle. * - `isSmartTransaction`: `true` if smart transactions are enabled for the current chain. + * - `pending`: `true` if the support check is still in progress. */ export function useIsGaslessSupported() { const transactionMeta = useTransactionMetadataRequest(); @@ -23,19 +24,20 @@ export function useIsGaslessSupported() { const { isSmartTransaction, isSupported: isSmartTransactionAndBundleSupported, - pending, + pending: smartTransactionPending, } = useGaslessSupportedSmartTransactions(); const shouldCheck7702Eligibility = - !pending && !isSmartTransactionAndBundleSupported; + !smartTransactionPending && !isSmartTransactionAndBundleSupported; - const { value: relaySupportsChain } = useAsyncResult(async () => { - if (!shouldCheck7702Eligibility) { - return undefined; - } + const { value: relaySupportsChain, pending: relayPending } = + useAsyncResult(async () => { + if (!shouldCheck7702Eligibility) { + return undefined; + } - return isRelaySupported(chainId as Hex); - }, [chainId, shouldCheck7702Eligibility]); + return isRelaySupported(chainId as Hex); + }, [chainId, shouldCheck7702Eligibility]); const is7702Supported = Boolean( relaySupportsChain && @@ -47,8 +49,12 @@ export function useIsGaslessSupported() { isSmartTransactionAndBundleSupported || is7702Supported, ); + const isPending = + smartTransactionPending || (shouldCheck7702Eligibility && relayPending); + return { isSupported, isSmartTransaction, + pending: isPending, }; } diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 11dd534d402f..8c8b5a7e95ef 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -88,6 +88,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useGaslessSupportedSmartTransactionsMock.mockReturnValue({ diff --git a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts index 1856f7361347..f9df235e06d8 100644 --- a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts +++ b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts @@ -13,9 +13,9 @@ import { } from '../../../../util/test/renderWithProvider'; import { updateSelectedGasFeeToken } from '../../../../util/transaction-controller'; import { NATIVE_TOKEN_ADDRESS } from '../constants/tokens'; -import { useIsInsufficientBalance } from './useIsInsufficientBalance'; +import { useHasInsufficientBalance } from './useHasInsufficientBalance'; -jest.mock('./useIsInsufficientBalance'); +jest.mock('./useHasInsufficientBalance'); jest.mock('../../../../util/transaction-controller'); jest.mock('./gas/useIsGaslessSupported'); @@ -89,21 +89,26 @@ describe('useAutomaticGasFeeTokenSelect', () => { const updateSelectedGasFeeTokenMock = jest.mocked(updateSelectedGasFeeToken); const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported); - const useIsInsufficientBalanceMock = jest.mocked(useIsInsufficientBalance); + const useHasInsufficientBalanceMock = jest.mocked(useHasInsufficientBalance); beforeEach(() => { jest.resetAllMocks(); - useIsInsufficientBalanceMock.mockReturnValue(false); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: false, + }); updateSelectedGasFeeTokenMock.mockReturnValue(); useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: true, + pending: false, }); }); it('selects first gas fee token', () => { - useIsInsufficientBalanceMock.mockReturnValue(true); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: true, + }); runHook(); expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledTimes(1); @@ -144,6 +149,7 @@ describe('useAutomaticGasFeeTokenSelect', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: false, isSmartTransaction: false, + pending: false, }); runHook(); @@ -152,15 +158,14 @@ describe('useAutomaticGasFeeTokenSelect', () => { }); it('does not select first gas fee token if sufficient balance', () => { - useIsInsufficientBalanceMock.mockReturnValue(false); - runHook(); - expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledTimes(0); }); it('does not select first gas fee token after firstCheck is set to false', () => { - useIsInsufficientBalanceMock.mockReturnValue(true); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: true, + }); const { rerender, state } = runHook(); // Simulate a rerender with new state that would otherwise trigger selection act(() => { @@ -182,6 +187,7 @@ describe('useAutomaticGasFeeTokenSelect', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: false, + pending: false, }); runHook({ @@ -200,8 +206,11 @@ describe('useAutomaticGasFeeTokenSelect', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: false, + pending: false, + }); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: true, }); - useIsInsufficientBalanceMock.mockReturnValue(true); runHook({ gasFeeTokens: [ diff --git a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts index c370037aa259..3f9ab81eed1c 100644 --- a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts +++ b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts @@ -4,12 +4,12 @@ import { updateSelectedGasFeeToken } from '../../../../util/transaction-controll import { NATIVE_TOKEN_ADDRESS } from '../constants/tokens'; import { useIsGaslessSupported } from './gas/useIsGaslessSupported'; import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; -import { useIsInsufficientBalance } from './useIsInsufficientBalance'; +import { useHasInsufficientBalance } from './useHasInsufficientBalance'; export function useAutomaticGasFeeTokenSelect() { const { isSupported: isGaslessSupported, isSmartTransaction } = useIsGaslessSupported(); - const hasInsufficientBalance = useIsInsufficientBalance(); + const { hasInsufficientBalance } = useHasInsufficientBalance(); const transactionMeta = (useTransactionMetadataRequest() as TransactionMeta) ?? ({} as TransactionMeta); diff --git a/app/components/Views/confirmations/hooks/useHasInsufficientBalance.test.ts b/app/components/Views/confirmations/hooks/useHasInsufficientBalance.test.ts new file mode 100644 index 000000000000..394eb8fc465a --- /dev/null +++ b/app/components/Views/confirmations/hooks/useHasInsufficientBalance.test.ts @@ -0,0 +1,127 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Hex } from '@metamask/utils'; +import { useHasInsufficientBalance } from './useHasInsufficientBalance'; + +import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; +import { selectNetworkConfigurations } from '../../../../selectors/networkController'; +import { useAccountNativeBalance } from './useAccountNativeBalance'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +jest.mock('./transactions/useTransactionMetadataRequest'); +jest.mock('../../../../selectors/networkController'); +jest.mock('./useAccountNativeBalance'); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn().mockImplementation((selector) => selector()), +})); + +describe('useHasInsufficientBalance', () => { + const mockUseTransactionMetadataRequest = jest.mocked( + useTransactionMetadataRequest, + ); + const mockSelectNetworkConfigurations = jest.mocked( + selectNetworkConfigurations, + ); + const mockUseAccountNativeBalance = jest.mocked(useAccountNativeBalance); + + const mockChainId = '0x1' as Hex; + const mockFromAddress = '0xabc'; + const nativeCurrency = 'ETH'; + + const baseTx = { + chainId: mockChainId, + txParams: { + from: mockFromAddress, + value: '0x5', + gas: '0x2', + maxFeePerGas: '0x3', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseTransactionMetadataRequest.mockReturnValue( + baseTx as unknown as TransactionMeta, + ); + + mockSelectNetworkConfigurations.mockReturnValue({ + [mockChainId]: { + nativeCurrency, + }, + } as unknown as ReturnType); + + mockUseAccountNativeBalance.mockReturnValue({ + balanceWeiInHex: '0xA', + } as unknown as ReturnType); + }); + + it('returns insufficient = false when balance is enough', () => { + mockUseAccountNativeBalance.mockReturnValue({ + balanceWeiInHex: '0xC', + } as unknown as ReturnType); + + const { result } = renderHook(() => useHasInsufficientBalance()); + expect(result.current.hasInsufficientBalance).toBe(false); + expect(result.current.nativeCurrency).toBe(nativeCurrency); + }); + + it('returns insufficient = true when balance is too low', () => { + mockUseAccountNativeBalance.mockReturnValue({ + balanceWeiInHex: '0xA', + } as unknown as ReturnType); + + const { result } = renderHook(() => useHasInsufficientBalance()); + expect(result.current.hasInsufficientBalance).toBe(true); + }); + + it('uses gasPrice when maxFeePerGas is missing', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + ...baseTx, + txParams: { + ...baseTx.txParams, + maxFeePerGas: undefined, + gasPrice: '0x2', + }, + } as unknown as TransactionMeta); + + mockUseAccountNativeBalance.mockReturnValue({ + balanceWeiInHex: '0x8', + } as unknown as ReturnType); + + const { result } = renderHook(() => useHasInsufficientBalance()); + expect(result.current.hasInsufficientBalance).toBe(true); + }); + + it('returns true when balance is missing', () => { + mockUseAccountNativeBalance.mockReturnValue({ + balanceWeiInHex: undefined, + } as unknown as ReturnType); + + const { result } = renderHook(() => useHasInsufficientBalance()); + expect(result.current.hasInsufficientBalance).toBe(true); + }); + + it('returns false when transaction has no value and gas is covered', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + ...baseTx, + txParams: { + ...baseTx.txParams, + value: '0x0', + }, + } as unknown as TransactionMeta); + + mockUseAccountNativeBalance.mockReturnValue({ + balanceWeiInHex: '0xA', + } as unknown as ReturnType); + + const { result } = renderHook(() => useHasInsufficientBalance()); + expect(result.current.hasInsufficientBalance).toBe(false); + }); + + it('returns nativeCurrency correctly', () => { + const { result } = renderHook(() => useHasInsufficientBalance()); + expect(result.current.nativeCurrency).toBe('ETH'); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts b/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts new file mode 100644 index 000000000000..40e0b0b82b4e --- /dev/null +++ b/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts @@ -0,0 +1,46 @@ +import { add0x, Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; +import { useSelector } from 'react-redux'; +import { selectNetworkConfigurations } from '../../../../selectors/networkController'; +import { + addHexes, + decimalToHex, + multiplyHexes, +} from '../../../../util/conversions'; +import { useAccountNativeBalance } from './useAccountNativeBalance'; + +const HEX_ZERO = '0x0'; + +export function useHasInsufficientBalance(): { + hasInsufficientBalance: boolean; + nativeCurrency?: string; +} { + const transactionMetadata = useTransactionMetadataRequest(); + const networkConfigurations = useSelector(selectNetworkConfigurations); + const { balanceWeiInHex } = useAccountNativeBalance( + transactionMetadata?.chainId as Hex, + transactionMetadata?.txParams?.from as string, + ); + + const { txParams } = transactionMetadata ?? {}; + const { maxFeePerGas, gas, gasPrice } = txParams || {}; + const { nativeCurrency } = + networkConfigurations[transactionMetadata?.chainId as Hex] ?? {}; + + const maxFeeNativeInHex = multiplyHexes( + maxFeePerGas ? (decimalToHex(maxFeePerGas) as Hex) : (gasPrice as Hex), + gas as Hex, + ); + + const transactionValue = txParams?.value || HEX_ZERO; + const totalTransactionValue = addHexes(maxFeeNativeInHex, transactionValue); + const totalTransactionInHex = add0x(totalTransactionValue as string); + + const balanceWeiInHexBN = new BigNumber(balanceWeiInHex ?? '0x0'); + const totalTransactionValueBN = new BigNumber(totalTransactionInHex ?? '0x0'); + + const hasInsufficientBalance = balanceWeiInHexBN.lt(totalTransactionValueBN); + + return { hasInsufficientBalance, nativeCurrency }; +} From 70d69d0a5e4ea2811bb5f1bff53ca6d4b7f3b821 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Thu, 4 Dec 2025 14:09:32 +0100 Subject: [PATCH 2/9] fix: solana bump 2.5.1 (#23666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bumping Solana to 2.5.1 which fixed snap confirmation UI bug https://github.com/MetaMask/snap-solana-wallet/releases/tag/v2.5.1 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] 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. --- > [!NOTE] > Update `@metamask/solana-wallet-snap` dependency from `^2.5.0` to `^2.5.1` in `package.json` and `yarn.lock`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c5efd1f6837489402370b8f01509e56fa864286c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 47a647c7b24d..b2bfb41593ee 100644 --- a/package.json +++ b/package.json @@ -281,7 +281,7 @@ "@metamask/snaps-rpc-methods": "^14.1.1", "@metamask/snaps-sdk": "^10.1.0", "@metamask/snaps-utils": "^11.6.1", - "@metamask/solana-wallet-snap": "^2.5.0", + "@metamask/solana-wallet-snap": "^2.5.1", "@metamask/solana-wallet-standard": "^0.6.0", "@metamask/stake-sdk": "^3.2.0", "@metamask/swappable-obj-proxy": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index df4cae3b1974..082c993fd5e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9762,10 +9762,10 @@ __metadata: languageName: node linkType: hard -"@metamask/solana-wallet-snap@npm:^2.5.0": - version: 2.5.0 - resolution: "@metamask/solana-wallet-snap@npm:2.5.0" - checksum: 10/cee4cbece192269fb02a59a90cbb8369dd6af3dab33eaecbb40fdb9723568c2da1dcd98b214063f34268696074a438a895cff40a421231e05cfab0afb1c71ea6 +"@metamask/solana-wallet-snap@npm:^2.5.1": + version: 2.5.1 + resolution: "@metamask/solana-wallet-snap@npm:2.5.1" + checksum: 10/822365b69be8e9e1d5a9569b2eedd1b0226e7da971e54ab4607cd16745c51861d7083bb3a90c0195ed665de9264d22cc2741b1934f86285fabc506688f784637 languageName: node linkType: hard @@ -36054,7 +36054,7 @@ __metadata: "@metamask/snaps-rpc-methods": "npm:^14.1.1" "@metamask/snaps-sdk": "npm:^10.1.0" "@metamask/snaps-utils": "npm:^11.6.1" - "@metamask/solana-wallet-snap": "npm:^2.5.0" + "@metamask/solana-wallet-snap": "npm:^2.5.1" "@metamask/solana-wallet-standard": "npm:^0.6.0" "@metamask/stake-sdk": "npm:^3.2.0" "@metamask/swappable-obj-proxy": "npm:^2.1.0" From de12ad698f1d0ffefc63b21e3225cc1660ea784f Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:31:46 +0100 Subject: [PATCH 3/9] feat: Add "Inaccurate fee" alert in Confirmations (#23588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds "Inaccurate fee" alert in Confirmations. ## **Changelog** CHANGELOG entry: Adds "Inaccurate fee" alert in Confirmations ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/5597 Related PR in the extension: https://github.com/MetaMask/metamask-extension/pull/25174/files ## **Manual testing steps** 1. Go to test dapp 2. Deploy failing contract 3. Trigger failing transaction 4. Notice the "Inaccurate fee" alert on mobile in the Network fee row ## **Screenshots/Recordings** ### **Before** ### **After** image image ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] 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. --- > [!NOTE] > Adds a non-blocking "Inaccurate fee" alert on confirmations when gas simulation fails, and wires up metrics, i18n, and tests. > > - **Alerts**: > - Add `useGasEstimateFailedAlert` to surface `Severity.Warning` "Inaccurate fee" (`AlertKeys.GasEstimateFailed`) on `RowAlertKey.EstimatedFee` when `simulationFails` is truthy. > - Include this alert in transaction alerts aggregation in `useConfirmationAlerts`. > - **Constants & Metrics**: > - Extend `AlertKeys` with `gas_estimate_failed`. > - Map to metrics in `useConfirmationAlertMetrics`. > - **i18n**: > - Add `alert_system.gas_estimate_failed.{title,message}` strings; normalize apostrophe in `domain_mismatch` message. > - **Tests**: > - New `useGasEstimateFailedAlert.test.ts` covering trigger and content. > - Update `useConfirmationAlerts.test.ts` to mock new hook. > - Adjust `useDomainMismatchAlerts.test.ts` expected copy. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ece41c6edf5bc93efd2b6915e4138056c23a9386. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../Views/confirmations/constants/alerts.ts | 1 + .../alerts/useConfirmationAlerts.test.ts | 3 + .../hooks/alerts/useConfirmationAlerts.ts | 4 + .../alerts/useDomainMismatchAlerts.test.ts | 3 +- .../alerts/useGasEstimateFailedAlert.test.ts | 94 +++++++++++++++++++ .../hooks/alerts/useGasEstimateFailedAlert.ts | 30 ++++++ .../metrics/useConfirmationAlertMetrics.ts | 1 + locales/languages/en.json | 6 +- 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.test.ts create mode 100644 app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.ts diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index 3139e4b42d98..f5258fef9352 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -5,6 +5,7 @@ export enum AlertKeys { Blockaid = 'blockaid', BurnAddress = 'burn_address', DomainMismatch = 'domain_mismatch', + GasEstimateFailed = 'gas_estimate_failed', InsufficientBalance = 'insufficient_balance', InsufficientPayTokenBalance = 'insufficient_pay_token_balance', InsufficientPayTokenNative = 'insufficient_pay_token_native', diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index 53b0172095d6..93a59885ea75 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -20,8 +20,10 @@ import { useBurnAddressAlert } from './useBurnAddressAlert'; import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; import { useAddressTrustSignalAlerts } from './useAddressTrustSignalAlerts'; import { useOriginTrustSignalAlerts } from './useOriginTrustSignalAlerts'; +import { useGasEstimateFailedAlert } from './useGasEstimateFailedAlert'; jest.mock('./useBlockaidAlerts'); +jest.mock('./useGasEstimateFailedAlert'); jest.mock('./useDomainMismatchAlerts'); jest.mock('./useInsufficientBalanceAlert'); jest.mock('./useAccountTypeUpgrade'); @@ -168,6 +170,7 @@ describe('useConfirmationAlerts', () => { jest.clearAllMocks(); (useBlockaidAlerts as jest.Mock).mockReturnValue([]); (useDomainMismatchAlerts as jest.Mock).mockReturnValue([]); + (useGasEstimateFailedAlert as jest.Mock).mockReturnValue([]); (useInsufficientBalanceAlert as jest.Mock).mockReturnValue([]); (useAccountTypeUpgrade as jest.Mock).mockReturnValue([]); (useSignedOrSubmittedAlert as jest.Mock).mockReturnValue([]); diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index ad4649c61c3a..c725e8e0d7c2 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import useBlockaidAlerts from './useBlockaidAlerts'; import useDomainMismatchAlerts from './useDomainMismatchAlerts'; +import { useGasEstimateFailedAlert } from './useGasEstimateFailedAlert'; import { useInsufficientBalanceAlert } from './useInsufficientBalanceAlert'; import { useAccountTypeUpgrade } from './useAccountTypeUpgrade'; import { useSignedOrSubmittedAlert } from './useSignedOrSubmittedAlert'; @@ -22,6 +23,7 @@ function useSignatureAlerts(): Alert[] { } function useTransactionAlerts(): Alert[] { + const gasEstimateFailedAlert = useGasEstimateFailedAlert(); const insufficientBalanceAlert = useInsufficientBalanceAlert(); const signedOrSubmittedAlert = useSignedOrSubmittedAlert(); const pendingTransactionAlert = usePendingTransactionAlert(); @@ -35,6 +37,7 @@ function useTransactionAlerts(): Alert[] { return useMemo( () => [ + ...gasEstimateFailedAlert, ...insufficientBalanceAlert, ...batchedUnusedApprovalsAlert, ...pendingTransactionAlert, @@ -46,6 +49,7 @@ function useTransactionAlerts(): Alert[] { ...tokenTrustSignalAlerts, ], [ + gasEstimateFailedAlert, insufficientBalanceAlert, batchedUnusedApprovalsAlert, pendingTransactionAlert, diff --git a/app/components/Views/confirmations/hooks/alerts/useDomainMismatchAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useDomainMismatchAlerts.test.ts index ee2db025f1ff..d31d74ea0af3 100755 --- a/app/components/Views/confirmations/hooks/alerts/useDomainMismatchAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useDomainMismatchAlerts.test.ts @@ -14,7 +14,8 @@ describe('useDomainMismatchAlerts', () => { const ALERT_MOCK = { field: RowAlertKey.RequestFrom, key: AlertKeys.DomainMismatch, - message: `The site making the request is not the site you’re signing into. This could be an attempt to steal your login credentials.`, + message: + "The site making the request is not the site you're signing into. This could be an attempt to steal your login credentials.", title: 'Suspicious sign-in request', severity: Severity.Danger, }; diff --git a/app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.test.ts new file mode 100644 index 000000000000..4ca79571368b --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.test.ts @@ -0,0 +1,94 @@ +import { + TransactionStatus, + TransactionType, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useGasEstimateFailedAlert } from './useGasEstimateFailedAlert'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { AlertKeys } from '../../constants/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { Severity } from '../../types/alerts'; + +const MOCK_TRANSACTION_META = { + id: '1', + status: TransactionStatus.unapproved, + type: TransactionType.contractInteraction, + chainId: '0x1', + simulationFails: undefined, +} as unknown as TransactionMeta; + +const MOCK_TRANSACTION_META_WITH_SIMULATION_FAILS = { + id: '2', + status: TransactionStatus.unapproved, + type: TransactionType.contractInteraction, + chainId: '0x1', + simulationFails: { + reason: 'execution reverted', + }, +} as unknown as TransactionMeta; + +jest.mock('../transactions/useTransactionMetadataRequest'); + +describe('useGasEstimateFailedAlert', () => { + const mockUseTransactionMetadataRequest = jest.mocked( + useTransactionMetadataRequest, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns alert when simulationFails is truthy', () => { + mockUseTransactionMetadataRequest.mockReturnValue( + MOCK_TRANSACTION_META_WITH_SIMULATION_FAILS, + ); + + const { result } = renderHookWithProvider(() => + useGasEstimateFailedAlert(), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + isBlocking: false, + key: AlertKeys.GasEstimateFailed, + field: RowAlertKey.EstimatedFee, + severity: Severity.Warning, + title: 'Inaccurate fee', + }); + }); + + it('returns empty array when simulationFails is undefined', () => { + mockUseTransactionMetadataRequest.mockReturnValue(MOCK_TRANSACTION_META); + + const { result } = renderHookWithProvider(() => + useGasEstimateFailedAlert(), + ); + + expect(result.current).toEqual([]); + }); + + it('returns empty array when transaction metadata is undefined', () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => + useGasEstimateFailedAlert(), + ); + + expect(result.current).toEqual([]); + }); + + it('returns alert with correct message content', () => { + mockUseTransactionMetadataRequest.mockReturnValue( + MOCK_TRANSACTION_META_WITH_SIMULATION_FAILS, + ); + + const { result } = renderHookWithProvider(() => + useGasEstimateFailedAlert(), + ); + + expect(result.current[0].message).toBe( + "We're unable to provide an accurate fee and this estimate might be high. We suggest you to input a custom gas limit, but there's a risk the transaction will still fail.", + ); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.ts b/app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.ts new file mode 100644 index 000000000000..b2c465f19f1a --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useGasEstimateFailedAlert.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; + +import { strings } from '../../../../../../locales/i18n'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { Alert, Severity } from '../../types/alerts'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; + +export const useGasEstimateFailedAlert = (): Alert[] => { + const transactionMeta = useTransactionMetadataRequest(); + + const estimationFailed = Boolean(transactionMeta?.simulationFails); + + return useMemo(() => { + if (!estimationFailed) { + return []; + } + + return [ + { + isBlocking: false, + key: AlertKeys.GasEstimateFailed, + field: RowAlertKey.EstimatedFee, + message: strings('alert_system.gas_estimate_failed.message'), + title: strings('alert_system.gas_estimate_failed.title'), + severity: Severity.Warning, + }, + ]; + }, [estimationFailed]); +}; diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index 61a953fb3a1a..920620e67fdc 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -112,6 +112,7 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.Blockaid]: 'blockaid', [AlertKeys.BurnAddress]: 'burn_address', [AlertKeys.DomainMismatch]: 'domain_mismatch', + [AlertKeys.GasEstimateFailed]: 'gas_estimate_failed', [AlertKeys.InsufficientBalance]: 'insufficient_balance', [AlertKeys.InsufficientPayTokenBalance]: 'insufficient_funds', [AlertKeys.InsufficientPayTokenFees]: 'insufficient_funds_for_fees', diff --git a/locales/languages/en.json b/locales/languages/en.json index b9af5a48765e..85a55d8e9153 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -52,7 +52,11 @@ }, "domain_mismatch": { "title": "Suspicious sign-in request", - "message": "The site making the request is not the site you’re signing into. This could be an attempt to steal your login credentials." + "message": "The site making the request is not the site you're signing into. This could be an attempt to steal your login credentials." + }, + "gas_estimate_failed": { + "title": "Inaccurate fee", + "message": "We're unable to provide an accurate fee and this estimate might be high. We suggest you to input a custom gas limit, but there's a risk the transaction will still fail." }, "insufficient_balance": { "title": "Insufficient funds", From f7c3f61b6ef3e8dcc59a655381ce6670f594ae8d Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:16:35 +0100 Subject: [PATCH 4/9] feat: added support for progressive rollout in trending (#23670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** I have changed the configuration in LD to have these two flags: `[Progressive Rollout] 0% of users - Minimum Version 7.61.0` ```json [ { "name": "feature is ON", "scope": { "type": "threshold", "value": 0 }, "value": { "enabled": true, "minimumVersion": "7.61.0" } }, { "name": "feature is OFF", "scope": { "type": "threshold", "value": 1 }, "value": { "enabled": false, "minimumVersion": "7.61.0" } } ] ``` `[Progressive Rollout] 100% of users - Minimum Version 7.61.0` ```json [ { "name": "feature is ON", "scope": { "type": "threshold", "value": 1 }, "value": { "enabled": true, "minimumVersion": "7.61.0" } }, { "name": "feature is OFF", "scope": { "type": "threshold", "value": 1 }, "value": { "enabled": false, "minimumVersion": "7.61.0" } } ] ``` This PR focuses on enbling the use of those FFs on the mobile app ## **Changelog** CHANGELOG entry: add support for progressive rollout FF ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1838 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] 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. --- > [!NOTE] > Adds support for progressive-rollout flags that wrap `trendingTokens` in a `value` field and updates tests accordingly. > > - **Feature Flags**: > - `selectAssetsTrendingTokensEnabled` now handles progressive rollout format by unwrapping `trendingTokens.value` when present before evaluation. > - **Tests**: > - Extend cases to cover `{ value: { enabled, minimumVersion } }` and `{ value: boolean }` inputs. > - Consolidate env override non-boolean string cases using `it.each` and remove redundant tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0242e11c299e068a5c98eeb0bab6a3426fc501c2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../assetsTrendingTokens/index.test.ts | 116 ++++++++++++------ .../assetsTrendingTokens/index.ts | 10 +- 2 files changed, 88 insertions(+), 38 deletions(-) diff --git a/app/selectors/featureFlagController/assetsTrendingTokens/index.test.ts b/app/selectors/featureFlagController/assetsTrendingTokens/index.test.ts index cdfa1b0ab56a..3f49e367ff26 100644 --- a/app/selectors/featureFlagController/assetsTrendingTokens/index.test.ts +++ b/app/selectors/featureFlagController/assetsTrendingTokens/index.test.ts @@ -20,10 +20,14 @@ afterEach(() => { jest.clearAllMocks(); }); +// Type for progressive rollout format with value property +type ProgressiveRolloutFlag = + | { value: AssetsTrendingTokensFeatureFlag | boolean } + | AssetsTrendingTokensFeatureFlag + | boolean; + // Helper function to create mock state with assetsTrendingTokensEnabled flag -function mockStateWith( - trendingTokens: AssetsTrendingTokensFeatureFlag | boolean, -) { +function mockStateWith(trendingTokens: ProgressiveRolloutFlag) { return { engine: { backgroundState: { @@ -78,6 +82,45 @@ describe('Assets Trending Tokens Feature Flag Selector', () => { expect(result).toBe(false); }); + it('returns true when flag has value property with enabled flag', () => { + const mockedState = mockStateWith({ + value: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + const result = selectAssetsTrendingTokensEnabled(mockedState); + + expect(result).toBe(true); + }); + + it('returns false when flag has value property with disabled flag', () => { + const mockedState = mockStateWith({ + value: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + const result = selectAssetsTrendingTokensEnabled(mockedState); + + expect(result).toBe(false); + }); + + it('returns false when flag has value property but version is too low', () => { + const mockedState = mockStateWith({ + value: { + enabled: true, + minimumVersion: '999.999.999', + }, + }); + + const result = selectAssetsTrendingTokensEnabled(mockedState); + + expect(result).toBe(false); + }); + it('returns false when flag is undefined', () => { const result = selectAssetsTrendingTokensEnabled( mockedUndefinedFlagsState, @@ -157,6 +200,26 @@ describe('Assets Trending Tokens Feature Flag Selector', () => { expect(result).toBe(false); }); + + it('returns true when flag has value property with boolean true', () => { + const mockedState = mockStateWith({ + value: true, + }); + + const result = selectAssetsTrendingTokensEnabled(mockedState); + + expect(result).toBe(true); + }); + + it('returns false when flag has value property with boolean false', () => { + const mockedState = mockStateWith({ + value: false, + }); + + const result = selectAssetsTrendingTokensEnabled(mockedState); + + expect(result).toBe(false); + }); }); describe('isAssetsTrendingTokensFeatureEnabled with override', () => { @@ -202,41 +265,20 @@ describe('Assets Trending Tokens Feature Flag Selector', () => { expect(result).toBe(true); }); - it('uses remote flag when envOverride is empty string', () => { - const result = isAssetsTrendingTokensFeatureEnabled( - { - enabled: true, - minimumVersion: '1.0.0', - }, - '', - ); - - expect(result).toBe(true); - }); - - it('uses remote flag when envOverride is other string value', () => { - const result = isAssetsTrendingTokensFeatureEnabled( - { - enabled: true, - minimumVersion: '1.0.0', - }, - 'something-else', - ); - - expect(result).toBe(true); - }); - - it('returns false when envOverride is "false" and remote flag would return true', () => { - const result = isAssetsTrendingTokensFeatureEnabled( - { - enabled: true, - minimumVersion: '1.0.0', - }, - 'false', - ); + it.each(['', 'something-else', 'invalid'])( + 'uses remote flag when envOverride is non-boolean string: "%s"', + (envOverride) => { + const result = isAssetsTrendingTokensFeatureEnabled( + { + enabled: true, + minimumVersion: '1.0.0', + }, + envOverride, + ); - expect(result).toBe(false); - }); + expect(result).toBe(true); + }, + ); }); describe('isAssetsTrendingTokensFeatureEnabled edge cases', () => { diff --git a/app/selectors/featureFlagController/assetsTrendingTokens/index.ts b/app/selectors/featureFlagController/assetsTrendingTokens/index.ts index faca6c271dc7..e931a1017f60 100644 --- a/app/selectors/featureFlagController/assetsTrendingTokens/index.ts +++ b/app/selectors/featureFlagController/assetsTrendingTokens/index.ts @@ -93,8 +93,16 @@ export const selectAssetsTrendingTokensEnabled = createSelector( const envOverride = process.env.OVERRIDE_REMOTE_FEATURE_FLAGS && process.env.ASSETS_TRENDING_TOKENS_ENABLED; + + const value = + assetsTrendingTokensEnabled && + typeof assetsTrendingTokensEnabled === 'object' && + 'value' in assetsTrendingTokensEnabled + ? assetsTrendingTokensEnabled.value + : assetsTrendingTokensEnabled; + return isAssetsTrendingTokensFeatureEnabled( - assetsTrendingTokensEnabled, + value, envOverride || undefined, ); }, From 0a427c5e829dec4cf60c052cc39b709247bdfa5c Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:21:25 +0100 Subject: [PATCH 5/9] chore: Add missing properties for transaction events (#23539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds missing properties for transaction events. Similar thing was implemented in the extension before, so we are just bringing it to mobile as well: https://github.com/MetaMask/metamask-extension/pull/35196 ## **Changelog** CHANGELOG entry: Adds 2 missing properties for transaction events ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/4138 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] 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. --- > [!NOTE] > Adds metrics for simulation sending/receiving fiat values and integrates them into transaction event tracking with unit tests. > > - **Transaction metrics**: > - Add `getSimulationValuesProperties` to emit `simulation_sending_assets_total_value` and `simulation_receiving_assets_total_value` from `TransactionMeta.assetsFiatValues`. > - Integrate builder into `METRICS_BUILDERS` in `event-handlers/metrics.ts` for all transaction events. > - **Tests**: > - Add unit tests in `event_properties/simulation-values.test.ts` covering presence/absence of sending/receiving values. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 34b804092b637dc2587223ea3ddcbce4c7641883. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../event-handlers/metrics.ts | 2 + .../simulation-values.test.ts | 120 ++++++++++++++++++ .../event_properties/simulation-values.ts | 34 +++++ 3 files changed, 156 insertions(+) create mode 100644 app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.test.ts create mode 100644 app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.ts diff --git a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts index 3ba1afd9f635..0cca95b02e10 100644 --- a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts +++ b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts @@ -20,6 +20,7 @@ import type { TransactionMetricsBuilder, } from '../types'; import { getMetaMaskPayProperties } from '../event_properties/metamask-pay'; +import { getSimulationValuesProperties } from '../event_properties/simulation-values'; import Engine from '../../../Engine'; import { createProjectLogger } from '@metamask/utils'; @@ -27,6 +28,7 @@ const log = createProjectLogger('transaction-metrics'); const METRICS_BUILDERS: TransactionMetricsBuilder[] = [ getMetaMaskPayProperties, + getSimulationValuesProperties, ]; // Generic handler for simple transaction events diff --git a/app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.test.ts b/app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.test.ts new file mode 100644 index 000000000000..ddc5526fac2d --- /dev/null +++ b/app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.test.ts @@ -0,0 +1,120 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { getSimulationValuesProperties } from './simulation-values'; +import { TransactionMetricsBuilder } from '../types'; + +describe('getSimulationValuesProperties', () => { + const getStateMock: jest.MockedFn< + Parameters[0]['getState'] + > = jest.fn(); + + const getUIMetricsMock: jest.MockedFn< + Parameters[0]['getUIMetrics'] + > = jest.fn(); + + let request: Parameters[0]; + + beforeEach(() => { + jest.resetAllMocks(); + + request = { + transactionMeta: { + id: 'tx-1', + txParams: { nonce: '0x1' }, + } as TransactionMeta, + allTransactions: [], + getUIMetrics: getUIMetricsMock, + getState: getStateMock, + }; + }); + + it('returns empty properties when assetsFiatValues is not present', () => { + request.transactionMeta.type = TransactionType.swap; + + const result = getSimulationValuesProperties(request); + + expect(result).toStrictEqual({ + properties: {}, + sensitiveProperties: {}, + }); + }); + + it('returns both sending and receiving values when present', () => { + request.transactionMeta = { + ...request.transactionMeta, + assetsFiatValues: { + sending: '100.50', + receiving: '99.75', + }, + } as TransactionMeta; + + const result = getSimulationValuesProperties(request); + + expect(result).toStrictEqual({ + properties: { + simulation_sending_assets_total_value: 100.5, + simulation_receiving_assets_total_value: 99.75, + }, + sensitiveProperties: {}, + }); + }); + + it('returns only sending value when receiving is not provided', () => { + request.transactionMeta = { + ...request.transactionMeta, + assetsFiatValues: { + sending: '50', + }, + } as TransactionMeta; + + const result = getSimulationValuesProperties(request); + + expect(result).toStrictEqual({ + properties: { + simulation_sending_assets_total_value: 50, + }, + sensitiveProperties: {}, + }); + }); + + it('returns only receiving value when sending is not provided', () => { + request.transactionMeta = { + ...request.transactionMeta, + assetsFiatValues: { + receiving: '75.25', + }, + } as TransactionMeta; + + const result = getSimulationValuesProperties(request); + + expect(result).toStrictEqual({ + properties: { + simulation_receiving_assets_total_value: 75.25, + }, + sensitiveProperties: {}, + }); + }); + + it('returns values regardless of transaction type', () => { + request.transactionMeta = { + ...request.transactionMeta, + type: TransactionType.simpleSend, + assetsFiatValues: { + sending: '100', + receiving: '100', + }, + } as TransactionMeta; + + const result = getSimulationValuesProperties(request); + + expect(result).toStrictEqual({ + properties: { + simulation_sending_assets_total_value: 100, + simulation_receiving_assets_total_value: 100, + }, + sensitiveProperties: {}, + }); + }); +}); diff --git a/app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.ts b/app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.ts new file mode 100644 index 000000000000..8332a1b4a56a --- /dev/null +++ b/app/core/Engine/controllers/transaction-controller/event_properties/simulation-values.ts @@ -0,0 +1,34 @@ +import { TransactionMetricsBuilder } from '../types'; +import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; + +/** + * Gets simulation asset fiat values for transaction metrics from TransactionMeta.assetsFiatValues. + * + * @param transactionMeta - The transaction metadata + * @returns Object with simulation_sending_assets_total_value and simulation_receiving_assets_total_value properties + */ +export const getSimulationValuesProperties: TransactionMetricsBuilder = ({ + transactionMeta, +}) => { + const properties: JsonMap = {}; + const sensitiveProperties: JsonMap = {}; + const { assetsFiatValues } = transactionMeta; + + if (!assetsFiatValues) { + return { properties, sensitiveProperties }; + } + + if (assetsFiatValues.sending !== undefined) { + properties.simulation_sending_assets_total_value = Number( + assetsFiatValues.sending, + ); + } + + if (assetsFiatValues.receiving !== undefined) { + properties.simulation_receiving_assets_total_value = Number( + assetsFiatValues.receiving, + ); + } + + return { properties, sensitiveProperties }; +}; From 3b968afc35c074d8d29bc03714a65a81f588843c Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:21:58 -0500 Subject: [PATCH 6/9] feat: MUSD-126 Add mUSD CTA to asset details page (#23562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the mUSD conversion CTA to the asset details page for supported conversion tokens. In the event that the asset is supported in the mUSD conversion flow **and** stablecoin lending we favor the mUSD conversion CTA. Read Cursor summary below for detailed changes. ## **Changelog** CHANGELOG entry: added mUSD CTA to asset overview screen ## **Related issues** Fixes: [MUSD-126: Add a convert to mUSD CTA card to the asset details page for supported conversion stablecoins](https://consensyssoftware.atlassian.net/browse/MUSD-126) ## **Manual testing steps** ```gherkin Feature: mUSD Conversion CTA on Asset Overview Scenario: user views mUSD conversion CTA for eligible stablecoins Given user has mainnet USDC, USDT, or DAI in their wallet And mUSD conversion feature flag is enabled When user navigates to the token details page for the eligible stablecoin Then user sees "Earn rewards when you convert to mUSD" CTA And the CTA displays the mUSD icon Scenario: user initiates mUSD conversion from asset overview Given user is on the asset overview page for USDC And user has not seen the mUSD education screen When user taps "mUSD" link in the CTA Then user is navigated to the mUSD education screen Scenario: returning user initiates mUSD conversion Given user has previously seen the mUSD education screen And user is on the asset overview page for USDC When user taps "mUSD" link in the CTA Then user is navigated directly to the mUSD conversion confirmation screen ``` ## **Screenshots/Recordings** ### **Before** N/A - CTA didn't exist ### **After** https://github.com/user-attachments/assets/9318e291-d8df-47d9-8df7-c2d53c35bf82 ## **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 - [x] 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. --- > [!NOTE] > Adds mUSD conversion CTAs on asset overview and token list, gated by feature flag and integrated with conversion/education flow; updates styles, tests, and i18n. > > - **Earn (mUSD Conversion)**: > - Add `MusdConversionAssetOverviewCta` (+ styles/tests) and render it in `EarnLendingBalance` via `renderCta`, favoring it over the lending empty state when enabled and token is convertible. > - Add `MusdConversionAssetListCta` (+ styles/tests) and display it in `UI/Tokens` when the feature flag is on. > - Wire up `useMusdConversionTokens`, `useMusdConversion`, and `selectIsMusdConversionFlowEnabledFlag`; add new test IDs in `EARN_TEST_IDS`. > - Update `StakeButton` to handle convertible stablecoins (initiate conversion/education flow) and refactor button label logic. > - **Styles/UI tweaks**: > - `TokenDetails.styles.tsx`: reduce `tokenDetailsContainer` `marginTop` to `16`. > - `EarnLendingBalance`: layout adjustments (button container, `paddingTop`, larger `AvatarToken`), add spacing for CTAs. > - `StakingCta`: center layout/text. > - **Tests**: > - Comprehensive new/updated tests and snapshots across Earn, Tokens, Asset Overview, and confirmation views to cover new CTAs, feature flag behavior, and navigation. > - **i18n**: > - Add strings: `earn.musd_conversion.earn_rewards_when`, `earn.musd_conversion.you_convert_to`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit afc5018acc02a8b55a7e8f589d586e5bbac607c8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TokenDetails/TokenDetails.styles.tsx | 2 +- .../__snapshots__/TokenDetails.test.tsx.snap | 2 +- .../__snapshots__/AssetOverview.test.tsx.snap | 2 +- .../EarnLendingBalance.styles.ts | 24 +- .../EarnLendingBalance.test.tsx | 127 +++++++ .../EarnLendingBalance.test.tsx.snap | 8 +- .../components/EarnLendingBalance/index.tsx | 90 +++-- .../MusdConversionAssetListCta.styles.ts} | 0 .../MusdConversionAssetListCta.test.tsx} | 133 +++++-- .../index.tsx} | 37 +- .../MusdConversionAssetOverviewCta.styles.ts | 36 ++ .../MusdConversionAssetOverviewCta.test.tsx | 341 ++++++++++++++++++ .../MusdConversionAssetOverviewCta/index.tsx | 85 +++++ app/components/UI/Earn/constants/testIds.ts | 3 +- .../UI/Stake/components/StakeButton/index.tsx | 27 +- .../StakingCta/StakingCta.styles.tsx | 4 + .../StakingBalance/StakingCta/StakingCta.tsx | 2 +- .../__snapshots__/StakingCta.test.tsx.snap | 2 + .../Tokens/TokenList/TokenListItem/index.tsx | 1 + app/components/UI/Tokens/index.test.tsx | 8 +- app/components/UI/Tokens/index.tsx | 10 +- .../Asset/__snapshots__/index.test.js.snap | 16 +- .../components/hero-token/hero-token.test.tsx | 1 + app/images/musd-icon-no-background-2x.png | Bin 0 -> 24102 bytes locales/languages/en.json | 4 +- 25 files changed, 839 insertions(+), 126 deletions(-) rename app/components/UI/Earn/components/Musd/{MusdConversionCta.styles.ts => MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts} (100%) rename app/components/UI/Earn/components/Musd/{MusdConversionCta.test.tsx => MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx} (66%) rename app/components/UI/Earn/components/Musd/{MusdConversionCta.tsx => MusdConversionAssetListCta/index.tsx} (73%) create mode 100644 app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.styles.ts create mode 100644 app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx create mode 100644 app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx create mode 100644 app/images/musd-icon-no-background-2x.png diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx index 2d4b7023f4e2..94c39b9a09de 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx @@ -6,7 +6,7 @@ const styleSheet = (params: { theme: Theme }) => { const { colors } = theme; return StyleSheet.create({ tokenDetailsContainer: { - marginTop: 24, + marginTop: 16, gap: 24, }, contentWrapper: { diff --git a/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap b/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap index efa97f86db50..22c68e0ca69d 100644 --- a/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap +++ b/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap @@ -5,7 +5,7 @@ exports[`TokenDetails should render correctly 1`] = ` style={ { "gap": 24, - "marginTop": 24, + "marginTop": 16, } } > diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index 1b81b50867e3..85b9d2cd2033 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -860,7 +860,7 @@ exports[`AssetOverview should render native balances when non evm network is sel style={ { "gap": 24, - "marginTop": 24, + "marginTop": 16, } } > diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts index 7b30e08cdc1c..0f2ebbc20012 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts @@ -1,18 +1,25 @@ import { StyleSheet } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; -const styleSheet = (params: { theme: Theme }) => - StyleSheet.create({ +const styleSheet = (params: { + theme: Theme; + vars: { userHasLendingPositions: boolean }; +}) => { + const { vars, theme } = params; + const { userHasLendingPositions } = vars; + + return StyleSheet.create({ container: { flexDirection: 'row', justifyContent: 'space-between', + paddingTop: 14, gap: 16, }, buttonsContainer: { marginTop: 16, padding: 16, borderRadius: 12, - backgroundColor: params.theme.colors.background.section, + backgroundColor: theme.colors.background.section, }, button: { flex: 1, @@ -26,19 +33,18 @@ const styleSheet = (params: { theme: Theme }) => marginLeft: 16, alignSelf: 'center', }, - ethLogo: { - width: 32, - height: 32, - borderRadius: 16, - overflow: 'hidden', + musdConversionCta: { + paddingTop: 16, + paddingBottom: userHasLendingPositions ? 8 : 0, }, EarnEmptyStateCta: { - paddingTop: 8, + paddingTop: 16, }, earnings: { paddingHorizontal: 16, paddingTop: 16, }, }); +}; export default styleSheet; diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index e4a77e0a45ec..54d3ad26757e 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -11,6 +11,7 @@ import { useTokenPricePercentageChange } from '../../../Tokens/hooks/useTokenPri import { TokenI } from '../../../Tokens/types'; import { EARN_EXPERIENCES } from '../../constants/experiences'; import { + selectIsMusdConversionFlowEnabledFlag, selectPooledStakingEnabledFlag, selectPooledStakingServiceInterruptionBannerEnabledFlag, selectStablecoinLendingEnabledFlag, @@ -18,6 +19,8 @@ import { } from '../../selectors/featureFlags'; import { EarnTokenDetails } from '../../types/lending.types'; import { EARN_EMPTY_STATE_CTA_TEST_ID } from '../EmptyStateCta'; +import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; +import { EARN_TEST_IDS } from '../../constants/testIds'; const mockNavigate = jest.fn(); const mockDaiMainnet: EarnTokenDetails = { @@ -121,7 +124,15 @@ jest.mock('../../hooks/useEarnings', () => ({ jest.mock('../../hooks/useEarnTokens'); jest.mock('../../../Tokens/hooks/useTokenPricePercentageChange'); +jest.mock('../../hooks/useMusdConversionTokens', () => ({ + __esModule: true, + useMusdConversionTokens: jest.fn().mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(false), + }), +})); + jest.mock('../../selectors/featureFlags', () => ({ + selectIsMusdConversionFlowEnabledFlag: jest.fn(), selectPooledStakingEnabledFlag: jest.fn(), selectStablecoinLendingEnabledFlag: jest.fn(), selectStablecoinLendingServiceInterruptionBannerEnabledFlag: jest.fn(), @@ -161,6 +172,12 @@ describe('EarnLendingBalance', () => { beforeEach(() => { jest.clearAllMocks(); + ( + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + > + ).mockReturnValue(false); + ( selectStablecoinLendingEnabledFlag as jest.MockedFunction< typeof selectStablecoinLendingEnabledFlag @@ -441,4 +458,114 @@ describe('EarnLendingBalance', () => { expect(toJSON()).toMatchSnapshot(); }); + + it('hides mUSD conversion CTA when feature flag is disabled', () => { + ( + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + > + ).mockReturnValue(false); + + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(true), + tokenFilter: jest.fn().mockReturnValue([]), + tokens: [], + }); + + const { queryByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeNull(); + }); + + it('hides mUSD conversion CTA when asset is not a conversion token', () => { + ( + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + > + ).mockReturnValue(true); + + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(false), + tokenFilter: jest.fn().mockReturnValue([]), + tokens: [], + }); + + const { queryByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeNull(); + }); + + it('favors mUSD conversion CTA over lending empty state CTA when both conditions are met', () => { + const mockEmptyReceiptToken = { + ...mockADAIMainnet, + balanceMinimalUnit: '0', + balanceFormatted: '0 ADAI', + balanceFiatNumber: 0, + }; + + ( + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + > + ).mockReturnValue(true); + + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(true), + tokenFilter: jest.fn().mockReturnValue([]), + tokens: [], + }); + + ( + earnSelectors.selectEarnToken as jest.MockedFunction< + typeof earnSelectors.selectEarnToken + > + ).mockReturnValue(mockDaiMainnet); + + ( + earnSelectors.selectEarnOutputToken as jest.MockedFunction< + typeof earnSelectors.selectEarnOutputToken + > + ).mockReturnValue(undefined); + + ( + earnSelectors.selectEarnTokenPair as jest.MockedFunction< + typeof earnSelectors.selectEarnTokenPair + > + ).mockReturnValue({ + outputToken: mockEmptyReceiptToken, + earnToken: mockDaiMainnet, + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + expect(queryByTestId(EARN_EMPTY_STATE_CTA_TEST_ID)).toBeNull(); + }); }); diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap index 2303e80d5ebc..a0e40a85bbc2 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap @@ -9,6 +9,7 @@ exports[`EarnLendingBalance does renders earnings for output tokens 1`] = ` "flexDirection": "row", "gap": 16, "justifyContent": "space-between", + "paddingTop": 14, }, { "backgroundColor": "#f3f5f9", @@ -288,10 +289,10 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } testID="receipt-token-balance-asset-logo" @@ -484,6 +485,7 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po "flexDirection": "row", "gap": 16, "justifyContent": "space-between", + "paddingTop": 14, }, { "backgroundColor": "#f3f5f9", diff --git a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx index 670eac0a82c2..1ae6b64c5d84 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx @@ -33,12 +33,16 @@ import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance'; import { useTokenPricePercentageChange } from '../../../Tokens/hooks/useTokenPricePercentageChange'; import { TokenI } from '../../../Tokens/types'; import { EARN_EXPERIENCES } from '../../constants/experiences'; -import { selectStablecoinLendingEnabledFlag } from '../../selectors/featureFlags'; +import { + selectIsMusdConversionFlowEnabledFlag, + selectStablecoinLendingEnabledFlag, +} from '../../selectors/featureFlags'; import Earnings from '../Earnings'; import EarnEmptyStateCta from '../EmptyStateCta'; import styleSheet from './EarnLendingBalance.styles'; import { trace, TraceName } from '../../../../../util/trace'; -import { useTheme } from '../../../../../util/theme'; +import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; +import MusdConversionAssetOverviewCta from '../Musd/MusdConversionAssetOverviewCta'; export const EARN_LENDING_BALANCE_TEST_IDS = { RECEIPT_TOKEN_BALANCE_ASSET_LOGO: 'receipt-token-balance-asset-logo', @@ -53,9 +57,13 @@ export interface EarnLendingBalanceProps { const { selectEarnTokenPair, selectEarnOutputToken } = earnSelectors; const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + + const { isConversionToken } = useMusdConversionTokens(); + const { trackEvent, createEventBuilder } = useMetrics(); - const theme = useTheme(); - const { styles } = useStyles(styleSheet, { theme }); const networkConfigurationByChainId = useSelector((state: RootState) => selectNetworkConfigurationByChainId(state, asset.chainId as Hex), @@ -89,6 +97,10 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { [earnToken?.balanceMinimalUnit], ); + const { styles } = useStyles(styleSheet, { + userHasLendingPositions, + }); + const emitLendingActionButtonMetaMetric = ( action: 'deposit' | 'withdrawal', ) => { @@ -166,6 +178,32 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { if (!isStablecoinLendingEnabled) return null; + const renderCta = () => { + // Favour the mUSD Conversion CTA over the lending empty state CTA + const shouldRenderMusdConversionAssetOverviewCta = + isMusdConversionFlowEnabled && isConversionToken(asset); + + if (shouldRenderMusdConversionAssetOverviewCta) { + return ( + + + + ); + } + + const shouldRenderLendingEmptyStateCta = + !isAssetReceiptToken && !userHasLendingPositions; + + if (shouldRenderLendingEmptyStateCta) { + return ( + + + + ); + } + return null; + }; + return ( // Receipt Token Balance @@ -194,7 +232,7 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { { )} - {/* Empty State CTA */} - {!isAssetReceiptToken && !userHasLendingPositions && ( - - - - )} + {renderCta()} {/* Buttons */} - - {userHasLendingPositions && receiptToken && ( -