diff --git a/.depcheckrc.yml b/.depcheckrc.yml
index 8bc400aa92a..c389cbeb637 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 77df2ca36d5..035619cd97c 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/.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/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx
index 2d4b7023f4e..94c39b9a09d 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 efa97f86db5..22c68e0ca69 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 1b81b50867e..85b9d2cd203 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/Bridge/components/TransactionDetails/BridgeStepDescription.test.tsx b/app/components/UI/Bridge/components/TransactionDetails/BridgeStepDescription.test.tsx
index f5558c31627..1a668267ffe 100644
--- a/app/components/UI/Bridge/components/TransactionDetails/BridgeStepDescription.test.tsx
+++ b/app/components/UI/Bridge/components/TransactionDetails/BridgeStepDescription.test.tsx
@@ -12,6 +12,7 @@ import {
TransactionStatus,
CHAIN_IDS,
} from '@metamask/transaction-controller';
+import { fontStyles } from '../../../../../styles/common';
describe('BridgeStepDescription', () => {
const mockStep = {
@@ -128,7 +129,7 @@ describe('BridgeStepDescription', () => {
const textElement = getByText(/ETH/);
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Medium',
+ fontStyles.medium.fontFamily,
);
});
@@ -157,7 +158,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#121314');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Regular',
+ fontStyles.normal.fontFamily,
);
});
@@ -175,7 +176,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#121314');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Regular',
+ fontStyles.normal.fontFamily,
);
});
@@ -194,7 +195,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#121314');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Regular',
+ fontStyles.normal.fontFamily,
);
});
@@ -215,7 +216,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#121314');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Medium',
+ fontStyles.medium.fontFamily,
);
});
@@ -236,7 +237,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#121314');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Medium',
+ fontStyles.medium.fontFamily,
);
});
@@ -285,7 +286,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#686e7d');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Regular',
+ fontStyles.normal.fontFamily,
);
});
@@ -299,7 +300,7 @@ describe('BridgeStepDescription', () => {
expect(textElement.props.style).toHaveProperty('color', '#686e7d');
expect(textElement.props.style).toHaveProperty(
'fontFamily',
- 'Geist Regular',
+ fontStyles.normal.fontFamily,
);
});
diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts
index 7b30e08cdc1..0f2ebbc2001 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 e4a77e0a45e..54d3ad26757 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 2303e80d5eb..a0e40a85bbc 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 670eac0a82c..1ae6b64c5d8 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 c5e8171fa12..ab72753b951 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 4e36e2d0381..f1589465300 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 00000000000..99ea70809f6
--- /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 00000000000..c4c71d2d083
--- /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 00000000000..e973683284f
--- /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 26be7c42faf..306b18913e9 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 c0f02cbe508..b69a6a22de9 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 20a92e22bc4..086678a4267 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 ff727485de6..c4dc23ebce2 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 23d4c4d69c0..b30470f3507 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 a9071a954bd..0f998e874c8 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 853670c2c90..647c18f7a55 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 25b2e6e0447..a3288332f60 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/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 ed8b6aabc7d..d7de15e069a 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 5ef0eb32e8c..0997bbef87d 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 f67ba77cfcc..72503efbbbd 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/hero-token/hero-token.test.tsx b/app/components/Views/confirmations/components/hero-token/hero-token.test.tsx
index b2f446f7cc0..7283f9e3450 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/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 e4f6a33d0a2..7fa241a66f6 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/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts
index 3139e4b42d9..f5258fef935 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 53b0172095d..93a59885ea7 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 ad4649c61c3..c725e8e0d7c 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 ee2db025f1f..d31d74ea0af 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 00000000000..4ca79571368
--- /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 00000000000..b2c465f19f1
--- /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/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts
index 23e09681d9d..f512d0e6c68 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 bd71f48fd2f..45fc5145140 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 1e9795b9932..85e26b8fbd9 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 d7fcdfc14f2..2a8316a0f24 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/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
index 61a953fb3a1..920620e67fd 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/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts
index 11dd534d402..8c8b5a7e95e 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 1856f736134..f9df235e06d 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 c370037aa25..3f9ab81eed1 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 00000000000..394eb8fc465
--- /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 00000000000..40e0b0b82b4
--- /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 };
+}
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 3ba1afd9f63..0cca95b02e1 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 00000000000..ddc5526fac2
--- /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 00000000000..8332a1b4a56
--- /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 };
+};
diff --git a/app/core/Engine/messengers/token-balances-controller-messenger.ts b/app/core/Engine/messengers/token-balances-controller-messenger.ts
index 4676bc81604..cf27fa76f53 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/app/features/SampleFeature/README.md b/app/features/SampleFeature/README.md
index 533d489a72d..0d63d1210fd 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/app/images/musd-icon-no-background-2x.png b/app/images/musd-icon-no-background-2x.png
new file mode 100644
index 00000000000..d9dd977f72d
Binary files /dev/null and b/app/images/musd-icon-no-background-2x.png differ
diff --git a/app/selectors/featureFlagController/assetsTrendingTokens/index.test.ts b/app/selectors/featureFlagController/assetsTrendingTokens/index.test.ts
index cdfa1b0ab56..3f49e367ff2 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 faca6c271dc..e931a1017f6 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,
);
},
diff --git a/bitrise.yml b/bitrise.yml
index 830b8826c74..c3b9791e9c5 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 224a41a05cb..806ae409534 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/locales/languages/en.json b/locales/languages/en.json
index b9af5a48765..c90349f23fb 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",
@@ -5673,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",
diff --git a/package.json b/package.json
index 47a647c7b24..8bba1446335 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"
]
},
@@ -197,7 +191,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",
@@ -281,7 +275,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",
@@ -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 92114e1ab6b..00000000000
--- 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.