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**
## **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 && (
-
- )}
- {userHasUnderlyingTokensAvailableToLend &&
- !isAssetReceiptToken &&
- userHasLendingPositions && (
+ {userHasLendingPositions && (
+
+ {Boolean(receiptToken) && (
+
+ )}
+ {userHasUnderlyingTokensAvailableToLend && !isAssetReceiptToken && (
+
+ )}
{isAssetReceiptToken && (
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionCta.styles.ts b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts
similarity index 100%
rename from app/components/UI/Earn/components/Musd/MusdConversionCta.styles.ts
rename to app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx
similarity index 66%
rename from app/components/UI/Earn/components/Musd/MusdConversionCta.test.tsx
rename to app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx
index c5e8171fa12f..ab72753b9515 100644
--- a/app/components/UI/Earn/components/Musd/MusdConversionCta.test.tsx
+++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx
@@ -1,22 +1,24 @@
import React from 'react';
import { fireEvent, waitFor, act } from '@testing-library/react-native';
-jest.mock('../../hooks/useMusdConversionTokens');
-jest.mock('../../hooks/useMusdConversion');
-jest.mock('../../../Ramp/hooks/useRampNavigation');
-jest.mock('../../../../../util/Logger');
+jest.mock('../../../hooks/useMusdConversionTokens');
+jest.mock('../../../hooks/useMusdConversion');
+jest.mock('../../../../Ramp/hooks/useRampNavigation');
+jest.mock('../../../../../../util/Logger');
+
+const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useNavigation: () => ({
- navigate: jest.fn(),
+ navigate: mockNavigate,
}),
};
});
-jest.mock('../../../../../../locales/i18n', () => ({
+jest.mock('../../../../../../../locales/i18n', () => ({
strings: (key: string) => {
const map: Record = {
'earn.musd_conversion.buy_musd': 'Buy mUSD',
@@ -27,18 +29,19 @@ jest.mock('../../../../../../locales/i18n', () => ({
},
}));
-import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import MusdConversionCta from './MusdConversionCta';
-import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens';
-import { useMusdConversion } from '../../hooks/useMusdConversion';
-import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import MusdConversionAssetListCta from '.';
+import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens';
+import { useMusdConversion } from '../../../hooks/useMusdConversion';
+import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation';
import {
MUSD_CONVERSION_DEFAULT_CHAIN_ID,
MUSD_TOKEN_ASSET_ID_BY_CHAIN,
-} from '../../constants/musd';
-import { EARN_TEST_IDS } from '../../constants/testIds';
-import initialRootState from '../../../../../util/test/initial-root-state';
-import Logger from '../../../../../util/Logger';
+} from '../../../constants/musd';
+import { EARN_TEST_IDS } from '../../../constants/testIds';
+import initialRootState from '../../../../../../util/test/initial-root-state';
+import Logger from '../../../../../../util/Logger';
+import Routes from '../../../../../../constants/navigation/Routes';
const mockToken = {
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
@@ -53,7 +56,7 @@ const mockToken = {
isETH: false,
};
-describe('MusdConversionCta', () => {
+describe('MusdConversionAssetListCta', () => {
const mockGoToBuy = jest.fn();
const mockInitiateConversion = jest.fn();
const mockLoggerError = jest.spyOn(Logger, 'error');
@@ -95,11 +98,16 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByTestId } = renderWithProvider(, {
- state: initialRootState,
- });
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: initialRootState,
+ },
+ );
- expect(getByTestId(EARN_TEST_IDS.MUSD.CONVERSION_CTA)).toBeOnTheScreen();
+ expect(
+ getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
+ ).toBeOnTheScreen();
});
it('displays MetaMask USD text', () => {
@@ -113,7 +121,7 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -131,7 +139,7 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -139,8 +147,8 @@ describe('MusdConversionCta', () => {
});
});
- describe('CTA text', () => {
- it('displays buy_musd when no tokens available', () => {
+ describe('CTA button text', () => {
+ it('displays "Buy mUSD" when no tokens available', () => {
(
useMusdConversionTokens as jest.MockedFunction<
typeof useMusdConversionTokens
@@ -151,14 +159,14 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
expect(getByText('Buy mUSD')).toBeOnTheScreen();
});
- it('displays get_musd when tokens available', () => {
+ it('displays "Get mUSD" when tokens available', () => {
(
useMusdConversionTokens as jest.MockedFunction<
typeof useMusdConversionTokens
@@ -169,7 +177,7 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -191,7 +199,7 @@ describe('MusdConversionCta', () => {
});
it('calls goToBuy with correct ramp intent', () => {
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -203,7 +211,7 @@ describe('MusdConversionCta', () => {
});
it('does not call initiateConversion when no tokens', () => {
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -225,7 +233,7 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -260,7 +268,7 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -290,7 +298,7 @@ describe('MusdConversionCta', () => {
isConversionToken: jest.fn(),
});
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -302,6 +310,63 @@ describe('MusdConversionCta', () => {
expect(mockGoToBuy).not.toHaveBeenCalled();
});
});
+
+ describe('education screen redirect', () => {
+ beforeEach(() => {
+ (
+ useMusdConversionTokens as jest.MockedFunction<
+ typeof useMusdConversionTokens
+ >
+ ).mockReturnValue({
+ tokens: [mockToken],
+ tokenFilter: jest.fn(),
+ isConversionToken: jest.fn(),
+ });
+
+ (
+ useMusdConversion as jest.MockedFunction
+ ).mockReturnValue({
+ initiateConversion: mockInitiateConversion,
+ error: null,
+ hasSeenConversionEducationScreen: false,
+ });
+ });
+
+ it('navigates to education screen when user has not seen it', async () => {
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('Get mUSD'));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.EARN.ROOT, {
+ screen: Routes.EARN.MUSD.CONVERSION_EDUCATION,
+ params: {
+ preferredPaymentToken: {
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ chainId: '0x1',
+ },
+ outputChainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID,
+ },
+ });
+ });
+
+ it('does not call initiateConversion when navigating to education screen', async () => {
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('Get mUSD'));
+ });
+
+ expect(mockInitiateConversion).not.toHaveBeenCalled();
+ });
+ });
});
describe('error handling', () => {
@@ -321,7 +386,7 @@ describe('MusdConversionCta', () => {
const testError = new Error('Network error');
mockInitiateConversion.mockRejectedValue(testError);
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
@@ -341,7 +406,7 @@ describe('MusdConversionCta', () => {
const nonErrorValue = 'string error';
mockInitiateConversion.mockRejectedValue(nonErrorValue);
- const { getByText } = renderWithProvider(, {
+ const { getByText } = renderWithProvider(, {
state: initialRootState,
});
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionCta.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx
similarity index 73%
rename from app/components/UI/Earn/components/Musd/MusdConversionCta.tsx
rename to app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx
index 4e36e2d03818..f15894653004 100644
--- a/app/components/UI/Earn/components/Musd/MusdConversionCta.tsx
+++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx
@@ -1,10 +1,10 @@
import React, { View } from 'react-native';
-import { useStyles } from '../../../../hooks/useStyles';
-import styleSheet from './MusdConversionCta.styles';
+import { useStyles } from '../../../../../hooks/useStyles';
+import styleSheet from './MusdConversionAssetListCta.styles';
import Text, {
TextVariant,
TextColor,
-} from '../../../../../component-library/components/Texts/Text';
+} from '../../../../../../component-library/components/Texts/Text';
import {
Button,
ButtonSize,
@@ -14,22 +14,22 @@ import {
MUSD_CONVERSION_DEFAULT_CHAIN_ID,
MUSD_TOKEN,
MUSD_TOKEN_ASSET_ID_BY_CHAIN,
-} from '../../constants/musd';
-import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
-import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
+} from '../../../constants/musd';
+import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
+import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar';
import { useMemo } from 'react';
-import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens';
-import { useMusdConversion } from '../../hooks/useMusdConversion';
+import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens';
+import { useMusdConversion } from '../../../hooks/useMusdConversion';
import { toHex } from '@metamask/controller-utils';
-import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation';
-import { RampIntent } from '../../../Ramp/types';
-import { strings } from '../../../../../../locales/i18n';
-import { EARN_TEST_IDS } from '../../constants/testIds';
+import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation';
+import { RampIntent } from '../../../../Ramp/types';
+import { strings } from '../../../../../../../locales/i18n';
+import { EARN_TEST_IDS } from '../../../constants/testIds';
import { useNavigation } from '@react-navigation/native';
-import Routes from '../../../../../constants/navigation/Routes';
-import Logger from '../../../../../util/Logger';
+import Routes from '../../../../../../constants/navigation/Routes';
+import Logger from '../../../../../../util/Logger';
-const MusdConversionCta = () => {
+const MusdConversionAssetListCta = () => {
const { styles } = useStyles(styleSheet, {});
const { goToBuy } = useRampNavigation();
@@ -98,7 +98,10 @@ const MusdConversionCta = () => {
};
return (
-
+
{
);
};
-export default MusdConversionCta;
+export default MusdConversionAssetListCta;
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.styles.ts b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.styles.ts
new file mode 100644
index 000000000000..99ea70809f69
--- /dev/null
+++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.styles.ts
@@ -0,0 +1,36 @@
+import { Platform, StyleSheet } from 'react-native';
+import { Theme } from '../../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) => {
+ const { colors } = params.theme;
+
+ return StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ paddingVertical: 8,
+ paddingHorizontal: 16,
+ borderWidth: 1,
+ borderRadius: 12,
+ borderColor: colors.border.muted,
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ text: {
+ fontFamily:
+ Platform.OS === 'android' ? 'MM Sans Regular' : 'MMSans-Regular',
+ fontWeight: 600,
+ },
+ linkText: {
+ fontFamily:
+ Platform.OS === 'android' ? 'MM Sans Regular' : 'MMSans-Regular',
+ color: colors.primary.default,
+ fontWeight: 600,
+ },
+ musdIcon: {
+ width: 84,
+ height: 84,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx
new file mode 100644
index 000000000000..c4c71d2d0839
--- /dev/null
+++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx
@@ -0,0 +1,341 @@
+import React from 'react';
+import { fireEvent, waitFor, act } from '@testing-library/react-native';
+import { CHAIN_IDS } from '@metamask/transaction-controller';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import MusdConversionAssetOverviewCta from '.';
+import { useMusdConversion } from '../../../hooks/useMusdConversion';
+import { EARN_TEST_IDS } from '../../../constants/testIds';
+import initialRootState from '../../../../../../util/test/initial-root-state';
+import Logger from '../../../../../../util/Logger';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { TokenI } from '../../../../Tokens/types';
+
+jest.mock('../../../hooks/useMusdConversion');
+jest.mock('../../../../../../util/Logger');
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+const createMockToken = (overrides: Partial = {}): TokenI => ({
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ chainId: '0x1',
+ symbol: 'USDC',
+ aggregators: [],
+ decimals: 6,
+ image: 'https://example.com/usdc.png',
+ name: 'USD Coin',
+ balance: '1000000000',
+ logo: 'https://example.com/usdc.png',
+ isETH: false,
+ ...overrides,
+});
+
+describe('MusdConversionAssetOverviewCta', () => {
+ const mockInitiateConversion = jest.fn();
+ const mockLoggerError = jest.mocked(Logger.error);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ jest.mocked(useMusdConversion).mockReturnValue({
+ initiateConversion: mockInitiateConversion,
+ error: null,
+ hasSeenConversionEducationScreen: true,
+ });
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders container with default testID', () => {
+ const mockToken = createMockToken();
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ expect(
+ getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders container with custom testID', () => {
+ const mockToken = createMockToken();
+ const customTestId = 'custom-test-id';
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ expect(getByTestId(customTestId)).toBeOnTheScreen();
+ });
+
+ it('displays CTA text correctly', () => {
+ const mockToken = createMockToken();
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ expect(getByText(/Earn rewards when/)).toBeOnTheScreen();
+ expect(getByText(/you convert to/)).toBeOnTheScreen();
+ expect(getByText('mUSD')).toBeOnTheScreen();
+ });
+ });
+
+ describe('press handler - education screen path', () => {
+ beforeEach(() => {
+ jest.mocked(useMusdConversion).mockReturnValue({
+ initiateConversion: mockInitiateConversion,
+ error: null,
+ hasSeenConversionEducationScreen: false,
+ });
+ });
+
+ it('navigates to education screen when user has not seen it', async () => {
+ const mockToken = createMockToken();
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.EARN.ROOT,
+ expect.objectContaining({
+ screen: Routes.EARN.MUSD.CONVERSION_EDUCATION,
+ }),
+ );
+ });
+
+ it('passes correct route params to education screen', async () => {
+ const mockToken = createMockToken({
+ address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ chainId: '0x1',
+ });
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.EARN.ROOT, {
+ screen: Routes.EARN.MUSD.CONVERSION_EDUCATION,
+ params: {
+ preferredPaymentToken: {
+ address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ chainId: '0x1',
+ },
+ outputChainId: CHAIN_IDS.MAINNET,
+ },
+ });
+ });
+
+ it('does not call initiateConversion when navigating to education screen', async () => {
+ const mockToken = createMockToken();
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ expect(mockInitiateConversion).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('press handler - conversion path', () => {
+ beforeEach(() => {
+ jest.mocked(useMusdConversion).mockReturnValue({
+ initiateConversion: mockInitiateConversion,
+ error: null,
+ hasSeenConversionEducationScreen: true,
+ });
+ });
+
+ it('calls initiateConversion when user has seen education screen', async () => {
+ const mockToken = createMockToken();
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ expect(mockInitiateConversion).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('passes correct config to initiateConversion', async () => {
+ const mockToken = createMockToken({
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ chainId: '0x1',
+ });
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ expect(mockInitiateConversion).toHaveBeenCalledWith({
+ outputChainId: CHAIN_IDS.MAINNET,
+ preferredPaymentToken: {
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ chainId: '0x1',
+ },
+ navigationStack: Routes.EARN.ROOT,
+ });
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ beforeEach(() => {
+ jest.mocked(useMusdConversion).mockReturnValue({
+ initiateConversion: mockInitiateConversion,
+ error: null,
+ hasSeenConversionEducationScreen: true,
+ });
+ });
+
+ it('logs error when asset address is missing', async () => {
+ const mockToken = createMockToken({ address: '' });
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ expect.any(Error),
+ '[mUSD Conversion] Failed to initiate conversion from asset overview CTA',
+ );
+ });
+
+ expect(mockInitiateConversion).not.toHaveBeenCalled();
+ });
+
+ it('logs error with correct message when asset address is missing', async () => {
+ const mockToken = createMockToken({ address: '' });
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ const errorArg = mockLoggerError.mock.calls[0][0] as Error;
+ expect(errorArg.message).toBe('Asset address or chain ID is not set');
+ });
+ });
+
+ it('logs error when asset chainId is missing', async () => {
+ const mockToken = createMockToken({ chainId: '' });
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ expect.any(Error),
+ '[mUSD Conversion] Failed to initiate conversion from asset overview CTA',
+ );
+ });
+
+ expect(mockInitiateConversion).not.toHaveBeenCalled();
+ });
+
+ it('logs error when asset chainId is undefined', async () => {
+ const mockToken = createMockToken({ chainId: undefined });
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ const errorArg = mockLoggerError.mock.calls[0][0] as Error;
+ expect(errorArg.message).toBe('Asset address or chain ID is not set');
+ });
+ });
+
+ it('logs error when initiateConversion fails with Error instance', async () => {
+ const testError = new Error('Conversion failed');
+ mockInitiateConversion.mockRejectedValue(testError);
+
+ const mockToken = createMockToken();
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialRootState },
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('mUSD'));
+ });
+
+ await waitFor(() => {
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ testError,
+ '[mUSD Conversion] Failed to initiate conversion from asset overview CTA',
+ );
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx
new file mode 100644
index 000000000000..e973683284f1
--- /dev/null
+++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/index.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { View, Image } from 'react-native';
+import stylesheet from './MusdConversionAssetOverviewCta.styles';
+import { useStyles } from '../../../../../hooks/useStyles';
+import Text from '../../../../../../component-library/components/Texts/Text';
+import musdIcon from '../../../../../../images/musd-icon-no-background-2x.png';
+import { useMusdConversion } from '../../../hooks/useMusdConversion';
+import { MUSD_CONVERSION_DEFAULT_CHAIN_ID } from '../../../constants/musd';
+import { toHex } from '@metamask/controller-utils';
+import { TokenI } from '../../../../Tokens/types';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { useNavigation } from '@react-navigation/native';
+import Logger from '../../../../../../util/Logger';
+import { strings } from '../../../../../../../locales/i18n';
+import { EARN_TEST_IDS } from '../../../constants/testIds';
+
+interface MusdConversionAssetOverviewCtaProps {
+ asset: TokenI;
+ testId?: string;
+}
+
+const MusdConversionAssetOverviewCta = ({
+ asset,
+ testId = EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA,
+}: MusdConversionAssetOverviewCtaProps) => {
+ const { styles } = useStyles(stylesheet, {});
+
+ const navigation = useNavigation();
+
+ const { initiateConversion, hasSeenConversionEducationScreen } =
+ useMusdConversion();
+
+ const handlePress = async () => {
+ try {
+ if (!asset?.address || !asset?.chainId) {
+ throw new Error('Asset address or chain ID is not set');
+ }
+
+ const config = {
+ outputChainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID,
+ preferredPaymentToken: {
+ address: toHex(asset.address),
+ chainId: toHex(asset.chainId),
+ },
+ navigationStack: Routes.EARN.ROOT,
+ };
+
+ if (!hasSeenConversionEducationScreen) {
+ navigation.navigate(config.navigationStack, {
+ screen: Routes.EARN.MUSD.CONVERSION_EDUCATION,
+ params: {
+ preferredPaymentToken: config.preferredPaymentToken,
+ outputChainId: config.outputChainId,
+ },
+ });
+ return;
+ }
+
+ await initiateConversion(config);
+ } catch (error) {
+ Logger.error(
+ error as Error,
+ '[mUSD Conversion] Failed to initiate conversion from asset overview CTA',
+ );
+ }
+ };
+
+ return (
+
+
+
+ {strings('earn.musd_conversion.earn_rewards_when')}
+ {`\n`}
+ {strings('earn.musd_conversion.you_convert_to')}{' '}
+
+
+ mUSD
+
+
+
+
+ );
+};
+
+export default MusdConversionAssetOverviewCta;
diff --git a/app/components/UI/Earn/constants/testIds.ts b/app/components/UI/Earn/constants/testIds.ts
index 26be7c42faf8..306b18913e9c 100644
--- a/app/components/UI/Earn/constants/testIds.ts
+++ b/app/components/UI/Earn/constants/testIds.ts
@@ -1,5 +1,6 @@
export const EARN_TEST_IDS = {
MUSD: {
- CONVERSION_CTA: 'musd-conversion-cta',
+ ASSET_LIST_CONVERSION_CTA: 'musd-conversion-asset-list-conversion-cta',
+ ASSET_OVERVIEW_CONVERSION_CTA: 'musd-conversion-asset-overview-cta',
},
};
diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx
index c0f02cbe5087..b69a6a22de9e 100644
--- a/app/components/UI/Stake/components/StakeButton/index.tsx
+++ b/app/components/UI/Stake/components/StakeButton/index.tsx
@@ -285,12 +285,26 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
if (
areEarnExperiencesDisabled ||
(!isConvertibleStablecoin && // Show for convertible stablecoins even with 0 balance
+ primaryExperienceType !== EARN_EXPERIENCES.STABLECOIN_LENDING &&
!earnToken?.isETH &&
earnToken?.balanceMinimalUnit === '0') ||
(earnToken?.isETH && !isPooledStakingEnabled)
)
return <>>;
+ const renderEarnButtonText = () => {
+ if (isConvertibleStablecoin) {
+ return strings('asset_overview.convert_to_musd');
+ }
+
+ const aprNumber = Number(earnToken?.experience?.apr);
+ const aprText =
+ Number.isFinite(aprNumber) && aprNumber > 0
+ ? ` ${aprNumber.toFixed(1)}%`
+ : '';
+ return `${strings('stake.earn')}${aprText}`;
+ };
+
return (
{
{' • '}
- {(() => {
- if (isConvertibleStablecoin) {
- return strings('asset_overview.convert_to_musd');
- }
-
- const aprNumber = Number(earnToken?.experience?.apr);
- const aprText =
- Number.isFinite(aprNumber) && aprNumber > 0
- ? ` ${aprNumber.toFixed(1)}%`
- : '';
- return `${strings('stake.earn')}${aprText}`;
- })()}
+ {renderEarnButtonText()}
);
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.styles.tsx b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.styles.tsx
index 20a92e22bc43..086678a4267f 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.styles.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.styles.tsx
@@ -7,6 +7,10 @@ const styleSheet = () =>
flexWrap: 'wrap',
borderRadius: 12,
marginBottom: 8,
+ alignSelf: 'center',
+ },
+ text: {
+ textAlign: 'center',
},
});
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx
index ff727485de6e..c4dc23ebce29 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx
@@ -51,7 +51,7 @@ const StakingCta = ({
return (
-
+
{strings('stake.stake_your_eth_cta.base')}
{estimatedRewardRate}
{` ${strings('stake.stake_your_eth_cta.annually')} `}
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingCta/__snapshots__/StakingCta.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/StakingCta/__snapshots__/StakingCta.test.tsx.snap
index 23d4c4d69c03..b30470f35079 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingCta/__snapshots__/StakingCta.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingBalance/StakingCta/__snapshots__/StakingCta.test.tsx.snap
@@ -5,6 +5,7 @@ exports[`StakingCta render matches snapshot 1`] = `
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
index a9071a954bdf..0f998e874c8e 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
@@ -399,6 +399,7 @@ export const TokenListItem = React.memo(
const shouldShowStablecoinLendingCta =
earnToken && isStablecoinLendingEnabled;
+
const shouldShowMusdConvertCta = isConvertibleStablecoin;
if (
diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx
index 853670c2c908..647c18f7a556 100644
--- a/app/components/UI/Tokens/index.test.tsx
+++ b/app/components/UI/Tokens/index.test.tsx
@@ -24,13 +24,15 @@ jest.mock('./TokensBottomSheet', () => ({
createTokensBottomSheetNavDetails: jest.fn(() => ['BottomSheetScreen', {}]),
}));
-jest.mock('../Earn/components/Musd/MusdConversionCta', () => {
+jest.mock('../Earn/components/Musd/MusdConversionAssetListCta', () => {
const { View } = jest.requireActual('react-native');
- const MockMusdConversionCta = () => ;
+ const MusdConversionAssetListCta = () => (
+
+ );
return {
__esModule: true,
- default: MockMusdConversionCta,
+ default: MusdConversionAssetListCta,
};
});
diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx
index 25b2e6e0447b..a3288332f60f 100644
--- a/app/components/UI/Tokens/index.tsx
+++ b/app/components/UI/Tokens/index.tsx
@@ -7,7 +7,7 @@ import React, {
useEffect,
useMemo,
} from 'react';
-import { InteractionManager } from 'react-native';
+import { InteractionManager, View } from 'react-native';
import ActionSheet from '@metamask/react-native-actionsheet';
import { useSelector } from 'react-redux';
import { useMetrics } from '../../../components/hooks/useMetrics';
@@ -43,7 +43,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { isNonEvmChainId } from '../../../core/Multichain/utils';
import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage';
import { TokensEmptyState } from '../TokensEmptyState';
-import MusdConversionCta from '../Earn/components/Musd/MusdConversionCta';
+import MusdConversionAssetListCta from '../Earn/components/Musd/MusdConversionAssetListCta';
import { selectIsMusdConversionFlowEnabledFlag } from '../Earn/selectors/featureFlags';
interface TokenListNavigationParamList {
@@ -225,7 +225,11 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => {
) : sortedTokenKeys.length > 0 ? (
<>
- {isMusdConversionFlowEnabled && }
+ {isMusdConversionFlowEnabled && (
+
+
+
+ )}
@@ -3656,7 +3656,7 @@ exports[`Asset Multichain Functionality should exclude transactions with empty a
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
@@ -5412,7 +5412,7 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
@@ -7271,7 +7271,7 @@ exports[`Asset Multichain Functionality should filter native SOL transactions co
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
@@ -9058,7 +9058,7 @@ exports[`Asset Multichain Functionality should handle state with no multichain t
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
@@ -10814,7 +10814,7 @@ exports[`Asset Multichain Functionality should handle unknown SPL token filterin
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
@@ -13202,7 +13202,7 @@ exports[`Asset Multichain Functionality should render non-EVM assets with Multic
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
@@ -14989,7 +14989,7 @@ exports[`Asset Multichain Functionality should sort filtered transactions by tim
style={
{
"gap": 24,
- "marginTop": 24,
+ "marginTop": 16,
}
}
>
diff --git a/app/components/Views/confirmations/components/hero-token/hero-token.test.tsx b/app/components/Views/confirmations/components/hero-token/hero-token.test.tsx
index b2f446f7cc0a..7283f9e34504 100644
--- a/app/components/Views/confirmations/components/hero-token/hero-token.test.tsx
+++ b/app/components/Views/confirmations/components/hero-token/hero-token.test.tsx
@@ -14,6 +14,7 @@ import { RootState } from '../../../../../reducers';
import { decGWEIToHexWEI } from '../../../../../util/conversions';
jest.mock('../../../../../util/navigation/navUtils', () => ({
+ ...jest.requireActual('../../../../../util/navigation/navUtils'),
useParams: jest.fn().mockReturnValue({
params: {
maxValueMode: false,
diff --git a/app/images/musd-icon-no-background-2x.png b/app/images/musd-icon-no-background-2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..d9dd977f72daf582a89df1d9d9ed7bd73cd149be
GIT binary patch
literal 24102
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
literal 0
HcmV?d00001
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 85a55d8e9153..c90349f23fb8 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -5677,7 +5677,9 @@
},
"earn_points_daily": "Earn points daily",
"buy_musd": "Buy mUSD",
- "get_musd": "Get mUSD"
+ "get_musd": "Get mUSD",
+ "earn_rewards_when": "Earn rewards when",
+ "you_convert_to": "you convert to"
},
"rewards": {
"rewards_tag_label": "Rewards",
From 6897d7b1b2608c12db84f93dae110c010c1f5b31 Mon Sep 17 00:00:00 2001
From: Salim TOUBAL
Date: Thu, 4 Dec 2025 17:37:33 +0100
Subject: [PATCH 7/9] chore: bump assets controllers to v93 (#23606)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
bump assets controllers to v93
## **Changelog**
CHANGELOG entry: bump assets controllers to v93
## **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]
> Updates `@metamask/assets-controllers` to v93 (with patched
spot-prices support) and has `TokenBalancesController` listen for
`AccountsController:selectedEvmAccountChange`.
>
> - **Engine**
> - `TokenBalancesController` messenger now listens to
`AccountsController:selectedEvmAccountChange` in
`app/core/Engine/messengers/token-balances-controller-messenger.ts`.
> - **Dependencies**
> - Bump `@metamask/assets-controllers` to `v93` via patch; updates
lockfile (includes indirect bumps like `network-controller` and
`transaction-controller`).
> - Patch content adjusts `SPOT_PRICES_SUPPORT_INFO` in
`@metamask/assets-controllers` (many chains set to `null`) at
`.yarn/patches/@metamask-assets-controllers-npm-93.0.0-*.patch`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ef1d683c8a97a680871ca6fa2e8d82f59b1a64fe. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Jorge Carrasco
---
...s-controllers-npm-93.0.0-ea998cb0bd.patch} | 0
.../token-balances-controller-messenger.ts | 1 +
package.json | 2 +-
yarn.lock | 74 ++++++++++++++++---
4 files changed, 66 insertions(+), 11 deletions(-)
rename .yarn/patches/{@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch => @metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch} (100%)
diff --git a/.yarn/patches/@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch b/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch
similarity index 100%
rename from .yarn/patches/@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch
rename to .yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch
diff --git a/app/core/Engine/messengers/token-balances-controller-messenger.ts b/app/core/Engine/messengers/token-balances-controller-messenger.ts
index 4676bc816043..cf27fa76f53b 100644
--- a/app/core/Engine/messengers/token-balances-controller-messenger.ts
+++ b/app/core/Engine/messengers/token-balances-controller-messenger.ts
@@ -47,6 +47,7 @@ export function getTokenBalancesControllerMessenger(
'KeyringController:accountRemoved',
'AccountActivityService:balanceUpdated',
'AccountActivityService:statusChanged',
+ 'AccountsController:selectedEvmAccountChange',
],
messenger,
});
diff --git a/package.json b/package.json
index b2bfb41593ee..bc3c4e866453 100644
--- a/package.json
+++ b/package.json
@@ -197,7 +197,7 @@
"@metamask/address-book-controller": "^7.0.0",
"@metamask/app-metadata-controller": "^2.0.0",
"@metamask/approval-controller": "^8.0.0",
- "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A92.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch",
+ "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch",
"@metamask/base-controller": "^9.0.0",
"@metamask/bitcoin-wallet-snap": "^1.8.0",
"@metamask/bridge-controller": "^61.0.0",
diff --git a/yarn.lock b/yarn.lock
index 082c993fd5e2..152e545ef97b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7393,9 +7393,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/assets-controllers@npm:92.0.0, @metamask/assets-controllers@npm:^92.0.0":
- version: 92.0.0
- resolution: "@metamask/assets-controllers@npm:92.0.0"
+"@metamask/assets-controllers@npm:93.0.0":
+ version: 93.0.0
+ resolution: "@metamask/assets-controllers@npm:93.0.0"
dependencies:
"@ethereumjs/util": "npm:^9.1.0"
"@ethersproject/abi": "npm:^5.7.0"
@@ -7417,7 +7417,7 @@ __metadata:
"@metamask/messenger": "npm:^0.3.0"
"@metamask/metamask-eth-abis": "npm:^3.1.1"
"@metamask/multichain-account-service": "npm:^4.0.0"
- "@metamask/network-controller": "npm:^26.0.0"
+ "@metamask/network-controller": "npm:^27.0.0"
"@metamask/permission-controller": "npm:^12.1.1"
"@metamask/phishing-controller": "npm:^16.1.0"
"@metamask/polling-controller": "npm:^16.0.0"
@@ -7427,7 +7427,7 @@ __metadata:
"@metamask/snaps-controllers": "npm:^14.0.1"
"@metamask/snaps-sdk": "npm:^9.0.0"
"@metamask/snaps-utils": "npm:^11.0.0"
- "@metamask/transaction-controller": "npm:^62.3.0"
+ "@metamask/transaction-controller": "npm:^62.4.0"
"@metamask/utils": "npm:^11.8.1"
"@types/bn.js": "npm:^5.1.5"
"@types/uuid": "npm:^8.3.0"
@@ -7443,7 +7443,7 @@ __metadata:
peerDependencies:
"@metamask/providers": ^22.0.0
webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
- checksum: 10/fa6d43e9397654ed504f76d19f74a343bf937171dcf639cf203a6135d7e7e31617ff98d302283d3cabcb2d39767632cbad5717580bb40fcd63e2aa0f948d6bb4
+ checksum: 10/35b4294bacc2a2123d99af181c2482674386f21d89ae102934573810101ba97b71e5ff5a24038a09f3dd8ad07d12b5cabe7a29c9aa04bdd5847a83284240d217
languageName: node
linkType: hard
@@ -7499,9 +7499,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A92.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch":
+"@metamask/assets-controllers@npm:^92.0.0":
version: 92.0.0
- resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A92.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch::version=92.0.0&hash=e4c733"
+ resolution: "@metamask/assets-controllers@npm:92.0.0"
dependencies:
"@ethereumjs/util": "npm:^9.1.0"
"@ethersproject/abi": "npm:^5.7.0"
@@ -7549,7 +7549,61 @@ __metadata:
peerDependencies:
"@metamask/providers": ^22.0.0
webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
- checksum: 10/e7bffd46456812744ae4038e767fa7a168fc993641d6506ff51a619cf88f780a183e0e1628fd13cc2c835af37afefdc245b7fdbffb69ea0f5b295f410c090de3
+ checksum: 10/fa6d43e9397654ed504f76d19f74a343bf937171dcf639cf203a6135d7e7e31617ff98d302283d3cabcb2d39767632cbad5717580bb40fcd63e2aa0f948d6bb4
+ languageName: node
+ linkType: hard
+
+"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch":
+ version: 93.0.0
+ resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch::version=93.0.0&hash=e4c733"
+ dependencies:
+ "@ethereumjs/util": "npm:^9.1.0"
+ "@ethersproject/abi": "npm:^5.7.0"
+ "@ethersproject/address": "npm:^5.7.0"
+ "@ethersproject/bignumber": "npm:^5.7.0"
+ "@ethersproject/contracts": "npm:^5.7.0"
+ "@ethersproject/providers": "npm:^5.7.0"
+ "@metamask/abi-utils": "npm:^2.0.3"
+ "@metamask/account-tree-controller": "npm:^4.0.0"
+ "@metamask/accounts-controller": "npm:^35.0.0"
+ "@metamask/approval-controller": "npm:^8.0.0"
+ "@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/contract-metadata": "npm:^2.4.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/core-backend": "npm:^5.0.0"
+ "@metamask/eth-query": "npm:^4.0.0"
+ "@metamask/keyring-api": "npm:^21.0.0"
+ "@metamask/keyring-controller": "npm:^25.0.0"
+ "@metamask/messenger": "npm:^0.3.0"
+ "@metamask/metamask-eth-abis": "npm:^3.1.1"
+ "@metamask/multichain-account-service": "npm:^4.0.0"
+ "@metamask/network-controller": "npm:^27.0.0"
+ "@metamask/permission-controller": "npm:^12.1.1"
+ "@metamask/phishing-controller": "npm:^16.1.0"
+ "@metamask/polling-controller": "npm:^16.0.0"
+ "@metamask/preferences-controller": "npm:^22.0.0"
+ "@metamask/profile-sync-controller": "npm:^27.0.0"
+ "@metamask/rpc-errors": "npm:^7.0.2"
+ "@metamask/snaps-controllers": "npm:^14.0.1"
+ "@metamask/snaps-sdk": "npm:^9.0.0"
+ "@metamask/snaps-utils": "npm:^11.0.0"
+ "@metamask/transaction-controller": "npm:^62.4.0"
+ "@metamask/utils": "npm:^11.8.1"
+ "@types/bn.js": "npm:^5.1.5"
+ "@types/uuid": "npm:^8.3.0"
+ async-mutex: "npm:^0.5.0"
+ bitcoin-address-validation: "npm:^2.2.3"
+ bn.js: "npm:^5.2.1"
+ immer: "npm:^9.0.6"
+ lodash: "npm:^4.17.21"
+ multiformats: "npm:^9.9.0"
+ reselect: "npm:^5.1.1"
+ single-call-balance-checker-abi: "npm:^1.0.0"
+ uuid: "npm:^8.3.2"
+ peerDependencies:
+ "@metamask/providers": ^22.0.0
+ webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
+ checksum: 10/9409c903dcdb56e1126a7f8777de82a88bc64b0470d73ddfffd0d165649df5191597c3bdbead31466dcccc023803c7fbecdbbdc9c027f45044ba5a278a5f1c82
languageName: node
linkType: hard
@@ -35961,7 +36015,7 @@ __metadata:
"@metamask/address-book-controller": "npm:^7.0.0"
"@metamask/app-metadata-controller": "npm:^2.0.0"
"@metamask/approval-controller": "npm:^8.0.0"
- "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A92.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-92.0.0-ea998cb0bd.patch"
+ "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch"
"@metamask/auto-changelog": "npm:^5.3.0"
"@metamask/base-controller": "npm:^9.0.0"
"@metamask/bitcoin-wallet-snap": "npm:^1.8.0"
From faca80990a04e6b18feec3337c332266cbd37f50 Mon Sep 17 00:00:00 2001
From: CW
Date: Thu, 4 Dec 2025 08:38:51 -0800
Subject: [PATCH 8/9] chore: remove cucumber and dependencies (#23644)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR removes the legacy Cucumber/WebDriverIO/Appium test
infrastructure from the codebase, completing the migration to Detox for
E2E functional testing and Appwright for performance testing.
### Changes
Test Infrastructure Removal:
- Deleted all WebDriverIO step definitions (wdio/step-definitions/*.js)
- Deleted all Cucumber feature files (wdio/features/**/*.feature)
- Deleted wdio config files (wdio.conf.js, wdio/config/*.js)
- Retained wdio/screen-objects/ and wdio/helpers/ as they may still be
referenced
Dependencies Cleanup:
- Removed wdio/Cucumber packages: @wdio/cli, @wdio/cucumber-framework,
@wdio/appium-service, @wdio/browserstack-service, @wdio/junit-reporter,
@wdio/local-runner, @wdio/spec-reporter
- Removed Cucumber packages: @cucumber/message-streams,
@cucumber/messages
- Removed related tooling: @rpii/wdio-html-reporter,
prettier-plugin-gherkin, wdio-cucumberjs-json-reporter
- Added xml2js to devDependencies (used by .github/scripts/ for E2E
report processing)
CI/CD Updates:
- Bitrise: Removed wdio_android_e2e_test,
run_android_app_launch_times_appium_test, run_tag_upgrade_android,
app_upgrade_pipeline workflows
- GitHub Actions: Removed wdio/** and wdio.conf.js from path filters in
needs-e2e-build.yml
- depcheck: Updated .depcheckrc.yml to remove obsolete ignores and add
xml2js
Documentation:
- docs/readme/e2e-testing.md: Marked Appium section as deprecated,
documented Appwright as the current performance testing solution,
wrapped legacy content in collapsible block
- app/features/SampleFeature/README.md: Removed "with Cucumber/Gherkin"
reference
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes:
https://consensyssoftware.atlassian.net/browse/MMQA-1119?atlOrigin=eyJpIjoiOTlmMjI5YzkzYmI5NDI3OWIzODZkMWFmYjQ5OWFkOGUiLCJwIjoiaiJ9
## **Manual testing steps**
```gherkin
Feature: Remove cucumber test framework from mobile project
Scenario: Mobile app behaves as expected after removing cucumber
Given cucumber has been removed from repository
When user uses mobile app
Then mobile app behaves as expected
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] 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]
> Remove legacy Cucumber/WebdriverIO/Appium stack, deleting all related
tests/config/deps and updating docs/CI to rely on Detox/Appwright only.
>
> - **Testing/E2E**:
> - Remove `wdio/**`, `wdio.conf.js`, Cucumber feature files, step
definitions, mocks, and report tooling (e.g., `generateTestReports.js`).
> - Drop wdio/Appium/Cucumber scripts from `package.json` and purge
related dependencies.
> - **Docs**:
> - Mark Appium as deprecated; document Appwright for performance tests
and simplify SampleFeature E2E bullet.
> - **CI/CD**:
> - Prune Bitrise workflows/steps tied to Appium/WebdriverIO
(upgrade/performance/wdio jobs) and references in pipelines.
> - Update GH workflow path filters (remove `wdio/*`), keep Appwright
paths.
> - **Tooling**:
> - Update `.depcheckrc.yml` (add `xml2js` ignore) and lint-staged
(remove `*.feature`).
> - Yarn lock cleanup reflecting dependency removals.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c9bf93b3b48aec55fad1ce8cee65357fd79783df. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.depcheckrc.yml | 11 +-
.github/workflows/needs-e2e-build.yml | 2 -
app/features/SampleFeature/README.md | 2 +-
bitrise.yml | 171 -
docs/readme/e2e-testing.md | 19 +-
package.json | 27 +-
wdio.conf.js | 474 ---
wdio/config/android.config.browserstack.js | 107 -
wdio/config/android.config.debug.js | 22 -
wdio/config/ios.config.browserstack.js | 96 -
wdio/config/ios.config.debug.js | 26 -
wdio/features/Accounts/AccountActions.feature | 25 -
.../Accounts/CreatingWalletAccount.feature | 17 -
.../Accounts/ImportingAccount.feature | 41 -
wdio/features/BrowserFlow/AddFavorite.feature | 39 -
wdio/features/BrowserFlow/AddressBar.feature | 24 -
wdio/features/BrowserFlow/ENSWebsite.feature | 16 -
wdio/features/BrowserFlow/InvalidURL.feature | 16 -
.../BrowserFlow/NavigationControls.feature | 26 -
wdio/features/BrowserFlow/OptionMenu.feature | 44 -
.../BrowserFlow/PhishingDetection.feature | 17 -
...portedAccountAfterConnectingToDapp.feature | 59 -
.../BrowserFlow/RevokingSingleAccount.feature | 21 -
.../Confirmations/ApproveCustomERC20.feature | 20 -
.../Confirmations/ApproveDefaultERC20.feature | 20 -
wdio/features/Confirmations/SendERC20.feature | 21 -
.../features/Confirmations/SendERC721.feature | 21 -
.../features/Confirmations/SendEthEOA.feature | 25 -
.../Confirmations/SendEthGasApiDown.feature | 30 -
.../Confirmations/SendEthMultisig.feature | 25 -
.../Networks/ConnectTestNetwork.feature | 34 -
wdio/features/Networks/NetworkFlow.feature | 98 -
.../Onboarding/CreateNewWallet.feature | 23 -
wdio/features/Onboarding/ImportWallet.feature | 59 -
.../Onboarding/OnboardingCarousel.feature | 19 -
wdio/features/Onboarding/TermsOfUse.feature | 30 -
.../ColdStartLoginToWalletScreen.feature | 15 -
.../ColdStartToLoginScreen.feature | 14 -
...ColdStartToOnboardingScreenAndroid.feature | 10 -
.../ColdStartToOnboardingScreenIOS.feature | 11 -
.../WarmStartLoginToWalletScreen.feature | 19 -
.../WarmStartToLoginScreen.feature | 15 -
.../SecurityAndPrivacy/DeleteWallet.feature | 20 -
.../SecurityAndPrivacy/RememberMe.feature | 24 -
wdio/features/Settings/ChangePassword.feature | 39 -
.../Upgrading/ConnectedTestNetwork.feature | 40 -
.../Upgrading/MultipleAccounts.feature | 43 -
wdio/features/Wallet/AddressFlow.feature | 86 -
.../features/Wallet/ImportCustomToken.feature | 49 -
wdio/features/Wallet/LockResetWallet.feature | 29 -
wdio/features/Wallet/RequestTokenFlow.feature | 74 -
wdio/features/Wallet/SendToken.feature | 74 -
wdio/helpers/Accounts.js | 1 +
wdio/helpers/Gestures.js | 408 ---
wdio/helpers/Selectors.js | 64 -
wdio/step-definitions/activity-steps.js | 6 -
wdio/step-definitions/add-networks.steps.js | 262 --
.../app-launch-times.steps.js | 74 -
wdio/step-definitions/browser-steps.js | 508 ---
.../step-definitions/change-password.steps.js | 44 -
wdio/step-definitions/common-steps.js | 308 --
.../connect-test-network.step.js | 20 -
wdio/step-definitions/contacts.steps.js | 89 -
.../create-new-wallet-account.steps.js | 22 -
.../delete-wallet-modal-view.steps.js | 14 -
wdio/step-definitions/drawer-view.steps.js | 10 -
wdio/step-definitions/import-tokens.steps.js | 31 -
.../import-wallet-via-private-key.steps.js | 40 -
.../lock-reset-wallet.steps.js | 6 -
wdio/step-definitions/login-view.steps.js | 10 -
wdio/step-definitions/onboarding.steps.js | 155 -
wdio/step-definitions/request-token.steps.js | 42 -
.../reveal-private-credential.steps.js | 14 -
.../revoke-single-account.steps.js | 9 -
...ecurity-and-privacy-delete-wallet.steps.js | 7 -
.../security-and-privacy-remember-me.steps.js | 17 -
.../security-and-privacy.steps.js | 20 -
wdio/step-definitions/send-flow.steps.js | 163 -
wdio/step-definitions/terms-of-use.steps.js | 9 -
wdio/step-definitions/wallet-view.steps.js | 84 -
wdio/utils/ganache.js | 45 -
wdio/utils/generateTestId.js | 1 +
wdio/utils/generateTestReports.js | 54 -
wdio/utils/mocks.js | 13 -
yarn.lock | 2827 +----------------
85 files changed, 86 insertions(+), 7580 deletions(-)
delete mode 100644 wdio.conf.js
delete mode 100644 wdio/config/android.config.browserstack.js
delete mode 100644 wdio/config/android.config.debug.js
delete mode 100644 wdio/config/ios.config.browserstack.js
delete mode 100644 wdio/config/ios.config.debug.js
delete mode 100644 wdio/features/Accounts/AccountActions.feature
delete mode 100644 wdio/features/Accounts/CreatingWalletAccount.feature
delete mode 100644 wdio/features/Accounts/ImportingAccount.feature
delete mode 100644 wdio/features/BrowserFlow/AddFavorite.feature
delete mode 100644 wdio/features/BrowserFlow/AddressBar.feature
delete mode 100644 wdio/features/BrowserFlow/ENSWebsite.feature
delete mode 100644 wdio/features/BrowserFlow/InvalidURL.feature
delete mode 100644 wdio/features/BrowserFlow/NavigationControls.feature
delete mode 100644 wdio/features/BrowserFlow/OptionMenu.feature
delete mode 100644 wdio/features/BrowserFlow/PhishingDetection.feature
delete mode 100644 wdio/features/BrowserFlow/RemovingImportedAccountAfterConnectingToDapp.feature
delete mode 100644 wdio/features/BrowserFlow/RevokingSingleAccount.feature
delete mode 100644 wdio/features/Confirmations/ApproveCustomERC20.feature
delete mode 100644 wdio/features/Confirmations/ApproveDefaultERC20.feature
delete mode 100644 wdio/features/Confirmations/SendERC20.feature
delete mode 100644 wdio/features/Confirmations/SendERC721.feature
delete mode 100644 wdio/features/Confirmations/SendEthEOA.feature
delete mode 100644 wdio/features/Confirmations/SendEthGasApiDown.feature
delete mode 100644 wdio/features/Confirmations/SendEthMultisig.feature
delete mode 100644 wdio/features/Networks/ConnectTestNetwork.feature
delete mode 100644 wdio/features/Networks/NetworkFlow.feature
delete mode 100644 wdio/features/Onboarding/CreateNewWallet.feature
delete mode 100644 wdio/features/Onboarding/ImportWallet.feature
delete mode 100644 wdio/features/Onboarding/OnboardingCarousel.feature
delete mode 100644 wdio/features/Onboarding/TermsOfUse.feature
delete mode 100644 wdio/features/Performance/ColdStartLoginToWalletScreen.feature
delete mode 100644 wdio/features/Performance/ColdStartToLoginScreen.feature
delete mode 100644 wdio/features/Performance/ColdStartToOnboardingScreenAndroid.feature
delete mode 100644 wdio/features/Performance/ColdStartToOnboardingScreenIOS.feature
delete mode 100644 wdio/features/Performance/WarmStartLoginToWalletScreen.feature
delete mode 100644 wdio/features/Performance/WarmStartToLoginScreen.feature
delete mode 100644 wdio/features/SecurityAndPrivacy/DeleteWallet.feature
delete mode 100644 wdio/features/SecurityAndPrivacy/RememberMe.feature
delete mode 100644 wdio/features/Settings/ChangePassword.feature
delete mode 100644 wdio/features/Upgrading/ConnectedTestNetwork.feature
delete mode 100644 wdio/features/Upgrading/MultipleAccounts.feature
delete mode 100644 wdio/features/Wallet/AddressFlow.feature
delete mode 100644 wdio/features/Wallet/ImportCustomToken.feature
delete mode 100644 wdio/features/Wallet/LockResetWallet.feature
delete mode 100644 wdio/features/Wallet/RequestTokenFlow.feature
delete mode 100644 wdio/features/Wallet/SendToken.feature
delete mode 100644 wdio/helpers/Gestures.js
delete mode 100644 wdio/helpers/Selectors.js
delete mode 100644 wdio/step-definitions/activity-steps.js
delete mode 100644 wdio/step-definitions/add-networks.steps.js
delete mode 100644 wdio/step-definitions/app-launch-times.steps.js
delete mode 100644 wdio/step-definitions/browser-steps.js
delete mode 100644 wdio/step-definitions/change-password.steps.js
delete mode 100644 wdio/step-definitions/common-steps.js
delete mode 100644 wdio/step-definitions/connect-test-network.step.js
delete mode 100644 wdio/step-definitions/contacts.steps.js
delete mode 100644 wdio/step-definitions/create-new-wallet-account.steps.js
delete mode 100644 wdio/step-definitions/delete-wallet-modal-view.steps.js
delete mode 100644 wdio/step-definitions/drawer-view.steps.js
delete mode 100644 wdio/step-definitions/import-tokens.steps.js
delete mode 100644 wdio/step-definitions/import-wallet-via-private-key.steps.js
delete mode 100644 wdio/step-definitions/lock-reset-wallet.steps.js
delete mode 100644 wdio/step-definitions/login-view.steps.js
delete mode 100644 wdio/step-definitions/onboarding.steps.js
delete mode 100644 wdio/step-definitions/request-token.steps.js
delete mode 100644 wdio/step-definitions/reveal-private-credential.steps.js
delete mode 100644 wdio/step-definitions/revoke-single-account.steps.js
delete mode 100644 wdio/step-definitions/security-and-privacy-delete-wallet.steps.js
delete mode 100644 wdio/step-definitions/security-and-privacy-remember-me.steps.js
delete mode 100644 wdio/step-definitions/security-and-privacy.steps.js
delete mode 100644 wdio/step-definitions/send-flow.steps.js
delete mode 100644 wdio/step-definitions/terms-of-use.steps.js
delete mode 100644 wdio/step-definitions/wallet-view.steps.js
delete mode 100644 wdio/utils/ganache.js
delete mode 100644 wdio/utils/generateTestReports.js
delete mode 100644 wdio/utils/mocks.js
diff --git a/.depcheckrc.yml b/.depcheckrc.yml
index 8bc400aa92a1..c389cbeb6378 100644
--- a/.depcheckrc.yml
+++ b/.depcheckrc.yml
@@ -35,6 +35,8 @@ ignores:
# ESBuild is used for AI E2E script compilation
- 'esbuild'
- 'esbuild-register'
+ # xml2js is used in .github/scripts/ for E2E test report processing
+ - 'xml2js'
# Used in scripts/repack for CI optimization
- '@expo/repack-app'
@@ -44,19 +46,11 @@ ignores:
## Unused dependencies to investigate
- '@babel/preset-env'
- '@babel/runtime'
- - '@cucumber/message-streams'
- - '@cucumber/messages'
- '@metamask/mobile-provider'
- - '@rpii/wdio-html-reporter'
- '@testing-library/react'
- '@testing-library/react-hooks'
- '@types/jest'
- '@types/react-native-video'
- - '@wdio/appium-service'
- - '@wdio/browserstack-service'
- - '@wdio/junit-reporter'
- - '@wdio/local-runner'
- - '@wdio/spec-reporter'
- 'appium'
- 'assert'
- 'babel-core'
@@ -68,7 +62,6 @@ ignores:
- 'execa'
- 'jetifier'
- 'metro-react-native-babel-preset'
- - 'prettier-plugin-gherkin'
- 'react-native-svg-asset-plugin'
- 'regenerator-runtime'
- 'prettier-2'
diff --git a/.github/workflows/needs-e2e-build.yml b/.github/workflows/needs-e2e-build.yml
index 77df2ca36d5b..035619cd97c1 100644
--- a/.github/workflows/needs-e2e-build.yml
+++ b/.github/workflows/needs-e2e-build.yml
@@ -98,8 +98,6 @@ jobs:
- 'app/**' # Shared app files
- 'e2e/**' # E2E test files (separate from mobile builds)
- 'sentry*.properties*' # Sentry configs
- - 'wdio/**' # WebDriver test files
- - 'wdio.conf.js' # WebDriver config
- 'appwright/**' # Appwright test files
- 'scripts/build.sh' # Build script changes
- 'scripts/setup.mjs' # Setup script changes
diff --git a/app/features/SampleFeature/README.md b/app/features/SampleFeature/README.md
index 533d489a72d2..0d63d1210fdd 100644
--- a/app/features/SampleFeature/README.md
+++ b/app/features/SampleFeature/README.md
@@ -44,7 +44,7 @@ This feature demonstrates:
- MetaMetrics tracking implementation
- Performance tracing patterns and monitoring
- Comprehensive unit testing
-- End-to-end testing with Cucumber/Gherkin
+- End-to-end testing
## Architecture
diff --git a/bitrise.yml b/bitrise.yml
index 830b8826c74c..c3b9791e9c55 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -148,11 +148,6 @@ pipelines:
expo_qa_pipeline:
stages:
- create_build_qa_expo: {}
- #App Upgrade pipeline. Runs on browserstack
- app_upgrade_pipeline:
- stages:
- - create_build_qa_android: {}
- - app_upgrade_test_stage: {}
# multichain_permissions_e2e_pipeline:
# stages:
# - build_multichain_permissions_e2e_ios_android_stage: {}
@@ -434,8 +429,6 @@ stages:
- run_wallet_platform_swimlane_android_smoke: {}
- run_tag_smoke_confirmations_redesigned_android: {}
- run_tag_smoke_confirmations_redesigned_ios: {}
- - run_tag_upgrade_android: {}
- - run_android_app_launch_times_appium_test: {}
- run_tag_smoke_multichain_api_ios: {}
- run_tag_smoke_accounts_ios: {}
- run_tag_smoke_accounts_android: {}
@@ -472,13 +465,6 @@ stages:
notify:
workflows:
- notify_success: {}
- app_launch_times_test_stage:
- workflows:
- - run_android_app_launch_times_appium_test: {}
- # - run_ios_app_launch_times_appium_test: {}
- app_upgrade_test_stage:
- workflows:
- - run_tag_upgrade_android: {}
release_notify:
workflows:
- release_announcing_stores: {}
@@ -1062,54 +1048,6 @@ workflows:
- METAMASK_BUILD_TYPE: 'flask'
after_run:
- ios_e2e_test
- run_tag_upgrade_android:
- meta:
- bitrise.io:
- stack: linux-docker-android-22.04
- machine_type_id: elite-xl
- before_run:
- - setup
- - prep_environment
- - download_production_qa_apk
- after_run:
- - wdio_android_e2e_test
- envs:
- - CUCUMBER_TAG_EXPRESSION: '@upgrade and @androidApp'
- - TEST_TYPE: 'upgrade'
- - NEW_BUILD_STRING: 'MetaMask v$VERSION_NAME ($VERSION_NUMBER)' # this is the build string for the new build that was generated by build_android_qa
- steps:
- - script@1:
- title: Set Build Strings
- inputs:
- - content: |
- envman add --key PRODUCTION_BUILD_STRING --value "MetaMask v${PRODUCTION_BUILD_NAME} (${PRODUCTION_BUILD_NUMBER})"
-
- BITRISE_GIT_BRANCH="qa-release"
- VERSION_NAME="$PRODUCTION_BUILD_NAME" # This value (PRODUCTION_BUILD_NAME) comes from the script to download the production build
- VERSION_NUMBER="$PRODUCTION_BUILD_NUMBER" # This value (PRODUCTION_BUILD_NUMBER) comes from the script to download the production build
- echo "Using qa-release with production version: $VERSION_NAME ($VERSION_NUMBER)"
-
- CUSTOM_ID="$BITRISE_GIT_BRANCH-$VERSION_NAME-$VERSION_NUMBER"
- CUSTOM_ID=${CUSTOM_ID////-}
-
- #### The UPLOAD_APK_PATH is the path to the apk file that was downloaded by the download_production_qa_apk step
- echo "apk path: $UPLOAD_APK_PATH"
- RESPONSE=$(curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \
- -X POST "https://api-cloud.browserstack.com/app-automate/upload" \
- -F "file=@$UPLOAD_APK_PATH" \
- -F 'data={"custom_id": "'$CUSTOM_ID'"}')
-
- # Extract app_url
- APP_URL=$(echo "$RESPONSE" | jq -r '.app_url')
-
- # Set the environment variable
- envman add --key PRODUCTION_APP_URL --value "$APP_URL"
-
- # Debug output
- echo "Response: $RESPONSE"
- echo "APP_URL: $APP_URL"
- echo "PRODUCTION_APP_URL: $PRODUCTION_APP_URL"
-
build_ios_multichain_permissions_e2e:
after_run:
- ios_e2e_build
@@ -1121,17 +1059,6 @@ workflows:
machine_type_id: elite-xl
after_run:
- android_e2e_build
- run_android_app_launch_times_appium_test:
- envs:
- - TEST_SUITE_FOLDER: './wdio/features/Performance/*'
- - TEST_TYPE: 'performance'
- meta:
- bitrise.io:
- stack: linux-docker-android-22.04
- machine_type_id: elite-xl
- after_run:
- - wdio_android_e2e_test
-
### Report automated test results to TestRail
run_testrail_update_automated_test_results:
before_run:
@@ -1145,13 +1072,6 @@ workflows:
echo 'REPORT AUTOMATED TEST RESULTS TO TESTRAIL'
node ./scripts/testrail/testrail.api.js
- run_ios_app_launch_times_appium_test:
- envs:
- - TEST_SUITE_FOLDER: './wdio/features/Performance/*'
- - TEST_TYPE: 'performance'
- after_run:
- - wdio_ios_e2e_test
-
### Separating workflows so they run concurrently during smoke runs
run_tag_smoke_multichain_api_ios:
envs:
@@ -2272,7 +2192,6 @@ workflows:
inputs:
- workflows: |-
ios_e2e_test
- wdio_android_e2e_test
- wait_for_builds: 'true'
- access_token: $BITRISE_START_BUILD_ACCESS_TOKEN
- build-router-wait@0:
@@ -2937,96 +2856,6 @@ workflows:
inputs:
- pipeline_intermediate_files: $BITRISE_SOURCE_DIR/browserstack_uploaded_apps.json:BROWSERSTACK_UPLOADED_APPS_LIST
title: Save Browserstack uploaded apps JSON
- wdio_android_e2e_test:
- before_run:
- - code_setup
- after_run:
- - notify_failure
- steps:
- - script@1:
- title: Debug Env Variables
- inputs:
- - content: |
- echo "PRODUCTION_BUILD_NAME: $PRODUCTION_BUILD_NAME"
- echo "PRODUCTION_BUILD_NUMBER: $PRODUCTION_BUILD_NUMBER"
- echo "PRODUCTION_APP_URL from tag upgrade workflow: $PRODUCTION_APP_URL"
- echo "BROWSERSTACK_ANDROID_APP_URL: $BROWSERSTACK_ANDROID_APP_URL"
- - script@1:
- title: Run Android E2E tests on Browserstack
- is_always_run: true
- inputs:
- - content: |-
- #!/usr/bin/env bash
-
- # Check if TEST_TYPE is set to upgrade
- if [ "$TEST_TYPE" = "upgrade" ]; then
- TEST_TYPE="--upgrade"
-
- # Check if TEST_TYPE is set to performance
- elif [ "$TEST_TYPE" = "performance" ]; then
- TEST_TYPE="--performance"
- fi
- yarn test:wdio:android:browserstack "$TEST_SUITE_FOLDER" "$TEST_TYPE"
- - script@1:
- is_always_run: true
- is_skippable: false
- title: Package test reports
- inputs:
- - content: |-
- #!/usr/bin/env bash
- cd $BITRISE_SOURCE_DIR/wdio/reports/
- zip -r test-report.zip html/
- mv test-report.zip $BITRISE_DEPLOY_DIR/
- - deploy-to-bitrise-io@2.2.3:
- is_always_run: true
- is_skippable: false
- inputs:
- - deploy_path: $BITRISE_DEPLOY_DIR/test-report.zip
- title: Deploy test report
- meta:
- bitrise.io:
- stack: linux-docker-android-22.04
- machine_type_id: standard
- wdio_ios_e2e_test:
- before_run:
- - code_setup
- after_run:
- - notify_failure
- steps:
- - script@1:
- title: Run iOS E2E tests on Browserstack
- is_always_run: true
- inputs:
- - content: |-
- #!/usr/bin/env bash
- # Check if TEST_TYPE is set to upgrade
- if [ "$TEST_TYPE" = "upgrade" ]; then
- TEST_TYPE="--upgrade"
- # Check if TEST_TYPE is set to performance
- elif [ "$TEST_TYPE" = "performance" ]; then
- TEST_TYPE="--performance"
- fi
- yarn test:wdio:ios:browserstack "$TEST_SUITE_FOLDER" "$TEST_TYPE"
- - script@1:
- is_always_run: true
- is_skippable: false
- title: Package test reports
- inputs:
- - content: |-
- #!/usr/bin/env bash
- cd $BITRISE_SOURCE_DIR/wdio/reports/
- zip -r test-report.zip html/
- mv test-report.zip $BITRISE_DEPLOY_DIR/
- - deploy-to-bitrise-io@2.2.3:
- is_always_run: true
- is_skippable: false
- inputs:
- - deploy_path: $BITRISE_DEPLOY_DIR/test-report.zip
- title: Deploy test report
- meta:
- bitrise.io:
- stack: linux-docker-android-22.04
- machine_type_id: standard
deploy_android_to_store:
steps:
- pull-intermediate-files@1:
diff --git a/docs/readme/e2e-testing.md b/docs/readme/e2e-testing.md
index 224a41a05cbe..806ae4095340 100644
--- a/docs/readme/e2e-testing.md
+++ b/docs/readme/e2e-testing.md
@@ -6,7 +6,7 @@
>
> E2E tests are significantly slower, more brittle, and resource-intensive than unit and integration tests. Always prioritize unit and integration tests over E2E ones.
-Our end-to-end (E2E) testing strategy leverages a combination of technologies to ensure robust test coverage for our mobile applications. We use [Wix/Detox](https://github.com/wix/Detox) for the majority of our automation tests, and for specific non-functional testing like app upgrades and launch times. All tests are written in TypeScript, and use jest and cucumber as test runners.
+Our end-to-end (E2E) testing strategy leverages a combination of technologies to ensure robust test coverage for our mobile applications. We use [Wix/Detox](https://github.com/wix/Detox) for the majority of our automation tests, and for specific non-functional testing like app upgrades and launch times. All tests are written in TypeScript, and use jest test runners.
- [Local environment setup](#local-environment-setup)
- [Tooling setup](#tooling-setup)
@@ -335,9 +335,20 @@ yarn test:e2e:android:flask:run
- on the metro server hit 'a' on the keyboard as indicated by metro for launching emulator
- you don't need to repeat these steps unless emulator or metro server is restarted
-## Appium
+## ~~Appium~~ (Deprecated)
-We currently utilize [Appium](https://appium.io/), [Webdriver.io](http://webdriver.io/), and [Cucumber](https://cucumber.io/) to test the application launch times and the upgrade between different versions. As a brief explanation, webdriver.io is the test framework that uses Appium Server as a service. This is responsible for communicating between our tests and devices, and cucumber as the test framework.
+> **⚠️ DEPRECATED**: The Appium/WebDriver.io/Cucumber test infrastructure has been removed. This section is kept for historical reference only.
+
+~~We currently utilize [Appium](https://appium.io/), [Webdriver.io](http://webdriver.io/), and [Cucumber](https://cucumber.io/) to test the application launch times and the upgrade between different versions. As a brief explanation, webdriver.io is the test framework that uses Appium Server as a service. This is responsible for communicating between our tests and devices, and cucumber as the test framework.~~
+
+**Current approach**: Performance testing is now handled by [Appwright](https://github.com/nickmaxwell10/appwright), a Playwright-based mobile testing framework. See the `appwright/` directory for performance tests including app launch times and feature-specific performance measurements.
+
+**Test Location**: `appwright/tests/performance/`
+
+---
+
+
+Legacy Appium Documentation (for reference only)
**Supported Platform**: Android
**Test Location**: `wdio`
@@ -502,6 +513,8 @@ You can also run Appium tests on CI using Bitrise pipelines:
For more details on our CI pipelines, see the [Bitrise Pipelines Overview](#bitrise-pipelines-overview).
+
+
### API Spec Tests
**Platform**: iOS
diff --git a/package.json b/package.json
index bc3c4e866453..8bba1446335d 100644
--- a/package.json
+++ b/package.json
@@ -97,12 +97,6 @@
"test:e2e:ios:flask:build": "IS_TEST='true' detox build -c ios.sim.flask",
"test:e2e:android:flask:run": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c android.emu.flask",
"test:e2e:ios:flask:run": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c ios.sim.flask",
- "test:wdio:ios": "yarn wdio ./wdio/config/ios.config.debug.js",
- "test:wdio:ios:browserstack": "yarn wdio ./wdio/config/ios.config.browserstack.js",
- "test:wdio:ios:browserstack:local": "yarn wdio ./wdio/config/ios.config.browserstack.local.js",
- "test:wdio:android": "yarn wdio ./wdio/config/android.config.debug.js",
- "test:wdio:android:browserstack": "yarn wdio ./wdio/config/android.config.browserstack.js",
- "test:wdio:android:browserstack:local": "yarn wdio ./wdio/config/android.config.browserstack.local.js",
"test:reassure:baseline": "REASSURE=true yarn reassure --baseline",
"test:reassure:branch": "REASSURE=true yarn reassure --branch && node -e \"const r=require('./.reassure/output.json'); if ((r.significant||[]).length>0) { console.error('Reassure: significant regressions detected'); process.exit(1);} else { process.exit(0);} \"",
"run-appwright:android-bs": "yarn appwright test --project browserstack-android --config appwright/appwright.config.ts",
@@ -132,7 +126,7 @@
"prettier --write",
"eslint --fix"
],
- "*.{json,md,feature}": [
+ "*.{json,md}": [
"prettier --write"
]
},
@@ -336,7 +330,6 @@
"@walletconnect/react-native-compat": "2.19.2",
"@walletconnect/utils": "^2.19.2",
"@xmldom/xmldom": "^0.8.10",
- "appium-adb": "^9.11.4",
"asyncstorage-down": "4.2.0",
"axios": "^1.8.2",
"bignumber.js": "^9.0.1",
@@ -490,8 +483,6 @@
"@babel/preset-env": "^7.25.3",
"@babel/register": "^7.24.6",
"@babel/runtime": "^7.25.0",
- "@cucumber/message-streams": "^4.0.1",
- "@cucumber/messages": "^22.0.0",
"@ethersproject/contracts": "^5.7.0",
"@ethersproject/providers": "^5.7.2",
"@lavamoat/allow-scripts": "^3.0.4",
@@ -514,7 +505,6 @@
"@open-rpc/schema-utils-js": "^1.16.2",
"@open-rpc/test-coverage": "^2.2.2",
"@react-native/metro-config": "0.76.9",
- "@rpii/wdio-html-reporter": "^7.7.1",
"@storybook/addon-controls": "^7.5.1",
"@storybook/addon-ondevice-controls": "^6.5.6",
"@storybook/builder-webpack5": "^7.5.1",
@@ -549,16 +539,8 @@
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@typescript-eslint/parser": "^7.10.0",
"@walletconnect/types": "^2.19.2",
- "@wdio/appium-service": "^7.19.1",
- "@wdio/browserstack-service": "^7.26.0",
- "@wdio/cli": "^7.19.1",
- "@wdio/cucumber-framework": "^7.19.1",
- "@wdio/junit-reporter": "^7.25.4",
- "@wdio/local-runner": "^7.19.1",
- "@wdio/spec-reporter": "^7.19.1",
"@welldone-software/why-did-you-render": "^8.0.1",
"appium": "^2.12.1",
- "appium-adb": "^9.11.4",
"appium-uiautomator2-driver": "4.2.7",
"appium-xcuitest-driver": "5.16.1",
"appwright": "patch:appwright@npm%3A0.1.45#~/.yarn/patches/appwright-npm-0.1.45-f282bc1c1b.patch",
@@ -571,7 +553,6 @@
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-remove-console": "6.9.4",
"base64-js": "^1.5.1",
- "browserstack-local": "^1.5.1",
"chromedriver": "^123.0.1",
"depcheck": "^1.4.7",
"deprecated-react-native-prop-types": "^5.0.0",
@@ -595,7 +576,6 @@
"eslint-plugin-react-native": "^4.0.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"execa": "^8.0.1",
- "fs-extra": "^10.1.0",
"ganache": "^7.9.2",
"husky": "^9.1.7",
"jest": "^29.7.0",
@@ -606,13 +586,10 @@
"listr2": "^8.0.2",
"metro-react-native-babel-preset": "~0.76.9",
"metro-react-native-babel-transformer": "~0.76.9",
- "multiple-cucumber-html-reporter": "^3.0.1",
- "nock": "^13.3.1",
"nyc": "^15.1.0",
"patch-package": "^6.2.2",
"prettier": "^3.6.2",
"prettier-2": "npm:prettier@^2.8.8",
- "prettier-plugin-gherkin": "^1.1.1",
"react-compiler-runtime": "^19.1.0-rc.2",
"react-dom": "18.2.0",
"react-native-launch-arguments": "^4.0.1",
@@ -631,7 +608,6 @@
"tailwindcss": "^3.4.0",
"ts-node": "^10.9.2",
"typescript": "~5.4.5",
- "wdio-cucumberjs-json-reporter": "^4.4.3",
"webextension-polyfill": "^0.12.0",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
@@ -665,7 +641,6 @@
"@sentry/react-native>@sentry/cli": true,
"@storybook/manager-webpack5>@storybook/core-common>webpack>watchpack>watchpack-chokidar2>chokidar>fsevents": false,
"@storybook/addon-controls>@storybook/core-common>esbuild": false,
- "@wdio/cucumber-framework>@cucumber/cucumber>duration>es5-ext": false,
"appium-adb>@appium/support>sharp": true,
"appium>appium-android-driver>appium-chromedriver": false,
"appium>appium-base-driver>webdriverio>@types/puppeteer-core>@types/puppeteer>puppeteer": false,
diff --git a/wdio.conf.js b/wdio.conf.js
deleted file mode 100644
index 92114e1ab6b6..000000000000
--- a/wdio.conf.js
+++ /dev/null
@@ -1,474 +0,0 @@
-const dotenv = require('dotenv');
-dotenv.config({ path: '.e2e.env' });
-
-import generateTestReports from './wdio/utils/generateTestReports';
-import ADB from 'appium-adb';
-import { gasApiDown, cleanAllMocks } from './wdio/utils/mocks';
-import {
- startGanache,
- stopGanache,
- deployMultisig,
- deployErc20,
- deployErc721,
-} from './wdio/utils/ganache';
-import FixtureBuilder from './e2e/framework/fixtures/FixtureBuilder';
-import { loadFixture, startFixtureServer, stopFixtureServer } from './e2e/framework/fixtures/FixtureHelper';
-import FixtureServer from './e2e/framework/fixtures/FixtureServer';
-const { removeSync } = require('fs-extra');
-
-const fixtureServer = new FixtureServer();
-
-// cucumber tags
-const GANACHE = '@ganache';
-const MULTISIG = '@multisig';
-const ERC20 = '@erc20';
-const ERC721 = '@erc721';
-const GAS_API_DOWN = '@gasApiDown';
-const MOCK = '@mock';
-const FIXTURES_SKIP_ONBOARDING = '@fixturesSkipOnboarding'
-
-export const config = {
- //
- // ====================
- // Runner Configuration
- // ====================
- //
- port: 4723,
- path: 'wd/hub',
- //
- // ==================
- // Specify Test Files
- // ==================
- // Define which test specs should run. The pattern is relative to the directory
- // from which `wdio` was called.
- //
- // The specs are defined as an array of spec files (optionally using wildcards
- // that will be expanded). The test for each spec file will be run in a separate
- // worker process. In order to have a group of spec files run in the same worker
- // process simply enclose them in an array within the specs array.
- //
- // If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script),
- // then the current working directory is where your `package.json` resides, so `wdio`
- // will be called from there.
- //
- specs: ['./wdio/features/**/*.feature'],
-
- suites: {
- confirmations: ['./wdio/features/Confirmations/*.feature'],
- },
-
- // Patterns to exclude.
- exclude: [
- './wdio/features/Wallet/*',
- './wdio/features/Accounts/*',
- './wdio/features/BrowserFlow/*',
- './wdio/features/Confirmations/*',
- './wdio/features/Networks/*',
- './wdio/features/Settings/*',
- './wdio/features/SecurityAndPrivacy/*',
- './wdio/features/Onboarding/*',
- ],
- //
- // ============
- // Capabilities
- // ============
- // Define your capabilities here. WebdriverIO can run multiple capabilities at the same
- // time. Depending on the number of capabilities, WebdriverIO launches several test
- // sessions. Within your capabilities you can overwrite the spec and exclude options in
- // order to group specific specs to a specific capability.
- //
- // First, you can define how many instances should be started at the same time. Let's
- // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have
- // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec
- // files and you set maxInstances to 10, all spec files will get tested at the same time
- // and 30 processes will get spawned. The property handles how many capabilities
- // from the same test should run tests.
- //
- maxInstances: 10,
- specFileRetries: 1,
- //
- // If you have trouble getting all important capabilities together, check out the
- // Sauce Labs platform configurator - a great tool to configure your capabilities:
- // https://saucelabs.com/platform/platform-configurator
- //
- capabilities: [
- {
- /***
- // maxInstances can get overwritten per capability. So if you have an in-house Selenium
- // grid with only 5 firefox instances available you can make sure that not more than
- // 5 instances get started at a time.
- maxInstances: 5,
- //
- browserName: 'chrome',
- acceptInsecureCerts: true
- // If outputDir is provided WebdriverIO can capture driver session logs
- // it is possible to configure which logTypes to include/exclude.
- // excludeDriverLogs: ['*'], // pass '*' to exclude all driver session logs
- // excludeDriverLogs: ['bugreport', 'server'],
- platformName: "Android",
- platformVersion: "10",
- deviceName: "Pixel 3 API 29",
- app: "/Users/chriswilcox/projects/wdio/resources/ApiDemos-debug.apk",
- // app: __dirname + "/projects/wdio/resources/ApiDemos-debug.apk",
- appPackage: "io.appium.android.apis",
- appActivity: ".view.TextFields",
- automationName: "UiAutomator2"
- ***/
- },
- ],
- //
- // ===================
- // Test Configurations
- // ===================
- // Define all options that are relevant for the WebdriverIO instance here
- //
- // Level of logging verbosity: trace | debug | info | warn | error | silent
- logLevel: 'info',
- //
- // Set specific log levels per logger
- // loggers:
- // - webdriver, webdriverio
- // - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service
- // - @wdio/mocha-framework, @wdio/jasmine-framework
- // - @wdio/local-runner
- // - @wdio/sumologic-reporter
- // - @wdio/cli, @wdio/config, @wdio/utils
- // Level of logging verbosity: trace | debug | info | warn | error | silent
- // logLevels: {
- // webdriver: 'info',
- // '@wdio/appium-service': 'info'
- // },
- //
- // If you only want to run your tests until a specific amount of tests have failed use
- // bail (default is 0 - don't bail, run all tests).
- bail: 0,
- //
- // Set a base URL in order to shorten url command calls. If your `url` parameter starts
- // with `/`, the base url gets prepended, not including the path portion of your baseUrl.
- // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
- // gets prepended directly.
- baseUrl: 'http://localhost',
- //
- // Default timeout for all waitFor* commands.
- waitforTimeout: 40000,
- //
- // Default timeout in milliseconds for request
- // if browser driver or grid doesn't send response
- connectionRetryTimeout: 120000,
- //
- // Default request retries count
- connectionRetryCount: 3,
- //
- // Test runner services
- // Services take over a specific job you don't want to take care of. They enhance
- // your test setup with almost no effort. Unlike plugins, they don't add new
- // commands. Instead, they hook themselves up into the test process.
- /** services: ['chromedriver','appium'], ***/
- services: [
- [
- 'appium',
- {
- args: {
- address: 'localhost',
- port: 4723
- },
- logPath: './'
- }
- ]
- ],
-
- // Appium service with custom chrome driver path
- /*services: [
- ['appium', {
- args: {
- chromedriverExecutable: '',
- }
- }]
- ],*/
- // Framework you want to run your specs with.
- // The following are supported: Mocha, Jasmine, and Cucumber
- // see also: https://webdriver.io/docs/frameworks
- //
- // Make sure you have the wdio adapter package for the specific framework installed
- // before running any tests.
- framework: 'cucumber',
- //
- // The number of times to retry the entire specfile when it fails as a whole
- // specFileRetries: 1,
- //
- // Delay in seconds between the spec file retry attempts
- // specFileRetriesDelay: 0,
- //
- // Whether or not retried specfiles should be retried immediately or deferred to the end of the queue
- // specFileRetriesDeferred: false,
- //
- // Test reporter for stdout.
- // The only one supported by default is 'dot'
- // see also: https://webdriver.io/docs/dot-reporter
- reporters: [
- 'spec',
- [
- 'cucumberjs-json',
- {
- jsonFolder: './wdio/reports/json',
- language: 'en',
- },
- ],
- [
- 'junit',
- {
- outputDir: './wdio/reports/junit-results',
- outputFileFormat: function (options) {
- // optional
- return `results-${options.cid}.${options.capabilities.platformName}.xml`;
- },
- },
- ],
- ],
-
- //
- // If you are using Cucumber you need to specify the location of your step definitions.
- cucumberOpts: {
- // (file/dir) require files before executing features
- require: ['./wdio/step-definitions/*.js'],
- // show full backtrace for errors
- backtrace: false,
- // ("extension:module") require files with the given EXTENSION after requiring MODULE (repeatable)
- requireModule: [],
- // invoke formatters without executing steps
- dryRun: false,
- // abort the run on first failure
- failFast: false,
- // hide step definition snippets for pending steps
- snippets: true,
- // hide source uris
- source: true,
- // fail if there are any undefined or pending steps
- strict: false,
- // (expression) only execute the features or scenarios with tags matching the expression
- tagExpression: '',
- // timeout for step definitions
- timeout: 200000,
- // Enable this config to treat undefined definitions as warnings.
- ignoreUndefinedDefinitions: false,
- },
-
- //
- // =====
- // Hooks
- // =====
- // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance
- // it and to build services around it. You can either apply a single function or an array of
- // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got
- // resolved to continue.
- /**
- * Gets executed once before all workers get launched.
- * @param {Object} config wdio configuration object
- * @param {Array.