diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9746a050c13..9663c7a2fe6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -123,10 +123,6 @@ app/components/Views/Settings/NotificationsSettings @MetaMask/notifications ses.cjs @MetaMask/supply-chain patches/react-native+0.*.patch @MetaMask/supply-chain -# Portfolio Team -app/components/hooks/useTokenSearchDiscovery @MetaMask/portfolio -app/core/Engine/controllers/TokenSearchDiscoveryController @MetaMask/portfolio - # Core Platform Team **/snaps/** @MetaMask/core-platform **/Snaps/** @MetaMask/core-platform @@ -187,6 +183,7 @@ app/components/UI/CollectibleOverview @MetaMask/metamask-assets app/components/UI/ConfirmAddAsset @MetaMask/metamask-assets app/components/UI/DeFiPositions @MetaMask/metamask-assets app/components/UI/Tokens @MetaMask/metamask-assets +app/components/UI/TokenDetails @MetaMask/metamask-assets app/components/Views/AddAsset @MetaMask/metamask-assets app/components/Views/Asset @MetaMask/metamask-assets app/components/Views/AssetDetails @MetaMask/metamask-assets diff --git a/android/app/build.gradle b/android/app/build.gradle index bd7a24a799b..38bfba42ffd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.64.0" + versionName "7.65.0" versionCode 3418 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index e4682f3a54b..f9d9d1d17ff 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -37,6 +37,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { selectAssetsTrendingTokensEnabled, ); const tabBarRef = useRef(null); + const previousTabIndexRef = useRef(state.index); const tw = useTailwind(); const renderTabBarItem = useCallback( @@ -54,6 +55,13 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { const labelKey = LABEL_BY_TAB_BAR_ICON_KEY[tabBarIconKey]; const labelText = labelKey ? strings(labelKey) : ''; const onPress = () => { + // Call onLeave callback for the previous tab before switching + if (previousTabIndexRef.current !== index) { + const previousRoute = state.routes[previousTabIndexRef.current]; + const previousOptions = descriptors[previousRoute?.key]?.options; + previousOptions?.onLeave?.(); + previousTabIndexRef.current = index; + } callback?.(); switch (rootScreenName) { case Routes.WALLET_VIEW: diff --git a/app/component-library/components/Navigation/TabBar/TabBar.types.ts b/app/component-library/components/Navigation/TabBar/TabBar.types.ts index e9a41f03581..8a69ad06270 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.types.ts +++ b/app/component-library/components/Navigation/TabBar/TabBar.types.ts @@ -39,6 +39,11 @@ export interface ExtendedBottomTabDescriptor extends BottomTabDescriptor { rootScreenName: string; isSelected?: (rootScreenName: string) => boolean; isHidden?: boolean; + /** + * Callback fired when leaving this tab (switching to another tab). + * Useful for cleanup actions like ending analytics sessions. + */ + onLeave?: () => void; }; } diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 616fcbb95a0..f0d0b3b394f 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -50,6 +50,7 @@ import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; +import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; @@ -362,7 +363,7 @@ const SettingsFlow = () => ( { MetaMetricsEvents.NAVIGATION_TAPS_TRENDING, ).build(), ); + // Re-enable AppState listener when returning to trending tab + // (it was disabled when leaving to prevent phantom sessions) + TrendingFeedSessionManager.getInstance().enableAppStateListener(); + // Start a new session when returning to trending tab + // The session manager will ignore if a session is already active + TrendingFeedSessionManager.getInstance().startSession('tab_press'); + }, + onLeave: () => { + // End trending session when user switches to another tab + TrendingFeedSessionManager.getInstance().endSession(); + // Disable AppState listener to prevent phantom sessions when app backgrounds/foregrounds + // while user is on a different tab (since TrendingView stays mounted with unmountOnBlur: false) + TrendingFeedSessionManager.getInstance().disableAppStateListener(); }, rootScreenName: Routes.TRENDING_VIEW, unmountOnBlur: false, @@ -1281,10 +1295,7 @@ const MainNavigator = () => { {process.env.METAMASK_ENVIRONMENT !== 'production' && ( @@ -593,7 +593,7 @@ exports[`MainNavigator Tab Bar Visibility shows tab bar when not in browser 1`] name="GeneralSettings" options={ { - "headerShown": true, + "headerShown": false, } } /> @@ -911,7 +911,7 @@ exports[`MainNavigator matches rendered snapshot 1`] = ` name="GeneralSettings" options={ { - "headerShown": true, + "headerShown": false, } } /> diff --git a/app/components/UI/AddressCopy/AddressCopy.test.tsx b/app/components/UI/AddressCopy/AddressCopy.test.tsx index 824212b5481..b6ea48d087a 100644 --- a/app/components/UI/AddressCopy/AddressCopy.test.tsx +++ b/app/components/UI/AddressCopy/AddressCopy.test.tsx @@ -1,11 +1,8 @@ import React from 'react'; -import { InternalAccount } from '@metamask/keyring-internal-api'; import AddressCopy from './AddressCopy'; import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; import renderWithProvider from '../../../util/test/renderWithProvider'; -import { createMockInternalAccount } from '../../../util/test/accountsControllerTestUtils'; -import { ToastContext } from '../../../component-library/components/Toast'; // Mock navigation before importing renderWithProvider jest.mock('@react-navigation/native', () => ({ @@ -15,32 +12,18 @@ jest.mock('@react-navigation/native', () => ({ }), })); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); -const mockToastRef = { - current: { showToast: mockShowToast, closeToast: mockCloseToast }, -}; - -const renderWithAddressCopy = (account: InternalAccount) => - renderWithProvider( - - - , - ); +const renderAddressCopy = () => renderWithProvider(); describe('AddressCopy', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders correctly the component', () => { - const component = renderWithAddressCopy( - createMockInternalAccount('0xaddress', 'Account 1'), - ); + it('renders the copy button', () => { + const { getByTestId } = renderAddressCopy(); - expect(component).toBeDefined(); expect( - component.getByTestId(WalletViewSelectorsIDs.ACCOUNT_COPY_BUTTON), + getByTestId(WalletViewSelectorsIDs.ACCOUNT_COPY_BUTTON), ).toBeDefined(); }); }); diff --git a/app/components/UI/AddressCopy/AddressCopy.tsx b/app/components/UI/AddressCopy/AddressCopy.tsx index 1294670f87d..20133f35c34 100644 --- a/app/components/UI/AddressCopy/AddressCopy.tsx +++ b/app/components/UI/AddressCopy/AddressCopy.tsx @@ -1,6 +1,6 @@ // Third parties dependencies -import React, { useCallback, useContext } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { AccountGroupId } from '@metamask/account-api'; @@ -11,27 +11,15 @@ import { ButtonIconSize, IconName, } from '@metamask/design-system-react-native'; -import ClipboardManager from '../../../core/ClipboardManager'; -import { protectWalletModalVisible } from '../../../actions/user'; -import { - ToastContext, - ToastVariants, -} from '../../../component-library/components/Toast'; -import { IconName as ComponentLibraryIconName } from '../../../component-library/components/Icons/Icon'; import { strings } from '../../../../locales/i18n'; -import { MetaMetricsEvents } from '../../../core/Analytics'; import { useStyles } from '../../../component-library/hooks'; import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedAccountGroupId } from '../../../selectors/multichainAccounts/accountTreeController'; import { createAddressListNavigationDetails } from '../../Views/MultichainAccounts/AddressList'; // Internal dependencies import styleSheet from './AddressCopy.styles'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import { useTheme } from '../../../util/theme'; -import { getFormattedAddressFromInternalAccount } from '../../../core/Multichain/utils'; import type { AddressCopyProps } from './AddressCopy.types'; import { endTrace, @@ -40,59 +28,13 @@ import { TraceOperation, } from '../../../util/trace'; -const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => { +const AddressCopy = ({ iconColor, hitSlop }: AddressCopyProps) => { const { styles } = useStyles(styleSheet, {}); const { navigate } = useNavigation(); - const { colors } = useTheme(); - - const dispatch = useDispatch(); - const { trackEvent, createEventBuilder } = useMetrics(); - const { toastRef } = useContext(ToastContext); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); const selectedAccountGroupId = useSelector(selectSelectedAccountGroupId); - const handleProtectWalletModalVisible = useCallback( - () => dispatch(protectWalletModalVisible()), - [dispatch], - ); - - /** - * A string that represents the selected address - */ - - const copyAccountToClipboard = useCallback(async () => { - await ClipboardManager.setString( - getFormattedAddressFromInternalAccount(account), - ); - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: ComponentLibraryIconName.CheckBold, - iconColor: colors.accent03.dark, - backgroundColor: colors.accent03.normal, - labelOptions: [ - { label: strings('account_details.account_copied_to_clipboard') }, - ], - hasNoTimeout: false, - }); - setTimeout(() => handleProtectWalletModalVisible(), 2000); - - trackEvent( - createEventBuilder(MetaMetricsEvents.WALLET_COPIED_ADDRESS).build(), - ); - }, [ - account, - colors.accent03.dark, - colors.accent03.normal, - createEventBuilder, - handleProtectWalletModalVisible, - toastRef, - trackEvent, - ]); - - const navigateToAddressList = useCallback(() => { + const handleOnPress = useCallback(() => { // Start the trace before navigating to the address list to include the // navigation and render times in the trace. trace({ @@ -116,18 +58,6 @@ const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => { ); }, [navigate, selectedAccountGroupId]); - const handleOnPress = useCallback(() => { - if (isMultichainAccountsState2Enabled) { - navigateToAddressList(); - } else { - copyAccountToClipboard(); - } - }, [ - copyAccountToClipboard, - isMultichainAccountsState2Enabled, - navigateToAddressList, - ]); - return ( ({ - ...jest.requireActual('../../../selectors/accountsController'), - selectSelectedInternalAccount: jest.fn(), -})); - jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), handleFetch: jest.fn(), })); -jest.mock( - '../../../selectors/multichainAccounts/accountTreeController', - () => ({ - ...jest.requireActual( - '../../../selectors/multichainAccounts/accountTreeController', - ), - selectSelectedAccountGroup: jest.fn(), - }), -); - jest.mock('./Balance', () => { /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ const React = require('react'); @@ -87,16 +72,6 @@ jest.mock('./Balance', () => { }; }); -jest.mock('../../../selectors/assets/assets-list', () => ({ - ...jest.requireActual('../../../selectors/assets/assets-list'), - selectTronResourcesBySelectedAccountGroup: jest.fn().mockReturnValue([]), -})); - -jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ - ...jest.requireActual('../../../selectors/multichainAccounts/accounts'), - selectSelectedInternalAccountByScope: jest.fn(), -})); - const MOCK_CHAIN_ID = '0x1'; const mockInitialState = { @@ -258,13 +233,6 @@ jest.mock('../../../components/hooks/useMetrics', () => { }; }); -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: () => false, - }), -); - const mockAddPopularNetwork = jest .fn() .mockImplementation(() => Promise.resolve()); @@ -322,6 +290,37 @@ jest.mock('../Perps', () => ({ selectPerpsEnabledFlag: () => mockSelectPerpsEnabledFlag(), })); +const mockSelectSelectedInternalAccount = jest.fn(); +jest.mock('../../../selectors/accountsController', () => ({ + ...jest.requireActual('../../../selectors/accountsController'), + selectSelectedInternalAccount: () => mockSelectSelectedInternalAccount(), +})); + +const mockSelectSelectedInternalAccountByScope = jest.fn(); +jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ + ...jest.requireActual('../../../selectors/multichainAccounts/accounts'), + selectSelectedInternalAccountByScope: () => + mockSelectSelectedInternalAccountByScope, +})); + +const mockSelectTronResourcesBySelectedAccountGroup = jest.fn(); +jest.mock('../../../selectors/assets/assets-list', () => ({ + ...jest.requireActual('../../../selectors/assets/assets-list'), + selectTronResourcesBySelectedAccountGroup: () => + mockSelectTronResourcesBySelectedAccountGroup(), +})); + +const mockSelectSelectedAccountGroup = jest.fn(); +jest.mock( + '../../../selectors/multichainAccounts/accountTreeController', + () => ({ + ...jest.requireActual( + '../../../selectors/multichainAccounts/accountTreeController', + ), + selectSelectedAccountGroup: () => mockSelectSelectedAccountGroup(), + }), +); + const asset = { balance: '400', balanceFiat: '1500', @@ -364,23 +363,18 @@ describe('AssetOverview', () => { isNonEvmAccount: false, }); - // Default selected internal account to an EVM account so token balance flow uses EVM path - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - selectSelectedInternalAccount.mockReturnValue({ + mockSelectSelectedInternalAccount.mockReturnValue({ address: MOCK_ADDRESS_2, type: 'eip155:eoa', }); - // Default mock for selectSelectedInternalAccountByScope - const { selectSelectedInternalAccountByScope } = jest.requireMock( - '../../../selectors/multichainAccounts/accounts', - ); - const mockGetAccountByScope = jest.fn().mockReturnValue({ + mockSelectSelectedInternalAccountByScope.mockReturnValue({ address: MOCK_ADDRESS_2, + type: 'eip155:eoa', }); - selectSelectedInternalAccountByScope.mockReturnValue(mockGetAccountByScope); + + // Default mock for tron resources - return empty array + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue([]); // Default mock for unified V1 flag - disabled mockUseRampsUnifiedV1Enabled.mockReturnValue(false); @@ -760,27 +754,11 @@ describe('AssetOverview', () => { }); it('should handle receive button press for EVM asset with EVM address', async () => { - // Arrange - Mock the selectors directly to ensure conditions are met - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - const { selectSelectedAccountGroup } = jest.requireMock( - '../../../selectors/multichainAccounts/accountTreeController', - ); - const { selectSelectedInternalAccountByScope } = jest.requireMock( - '../../../selectors/multichainAccounts/accounts', - ); + mockSelectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - selectSelectedInternalAccount.mockReturnValue({ - address: MOCK_ADDRESS_2, - type: 'eip155:eoa', - }); - selectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - - const mockGetAccountByScope = jest.fn().mockReturnValue({ + mockSelectSelectedInternalAccountByScope.mockReturnValue({ address: MOCK_ADDRESS_2, }); - selectSelectedInternalAccountByScope.mockReturnValue(mockGetAccountByScope); const { getByTestId } = renderWithProvider( { }), }, ); - - // Cleanup mocks for isolation - selectSelectedInternalAccount.mockReset(); - selectSelectedAccountGroup.mockReset(); - selectSelectedInternalAccountByScope.mockReset(); }); it('should track receive button click analytics with correct properties', async () => { // Arrange - Mock the selectors directly to ensure conditions are met - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - const { selectSelectedAccountGroup } = jest.requireMock( - '../../../selectors/multichainAccounts/accountTreeController', - ); - selectSelectedInternalAccount.mockReturnValue({ address: MOCK_ADDRESS_2 }); - selectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); + mockSelectSelectedInternalAccount.mockReturnValue({ + address: MOCK_ADDRESS_2, + }); + mockSelectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); const { getByTestId } = renderWithProvider( { // Verify trackEvent was called with the built event expect(mockTrackEvent).toHaveBeenCalledWith({ category: 'test' }); - - // Cleanup mocks for isolation - selectSelectedInternalAccount.mockReset(); - selectSelectedAccountGroup.mockReset(); }); it('should handle receive button press for Solana asset with Solana address', async () => { const SOLANA_ADDRESS = 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH'; const SOLANA_CHAIN_ID = SolScope.Mainnet; - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - const { selectSelectedAccountGroup } = jest.requireMock( - '../../../selectors/multichainAccounts/accountTreeController', - ); - const { selectSelectedInternalAccountByScope } = jest.requireMock( - '../../../selectors/multichainAccounts/accounts', - ); + mockSelectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - selectSelectedInternalAccount.mockReturnValue({ - address: MOCK_ADDRESS_2, - type: 'eip155:eoa', - }); - selectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - - const mockGetAccountByScope = jest.fn().mockReturnValue({ + mockSelectSelectedInternalAccountByScope.mockReturnValue({ address: SOLANA_ADDRESS, type: SolAccountType.DataAccount, }); - selectSelectedInternalAccountByScope.mockReturnValue(mockGetAccountByScope); const solanaAsset = { ...asset, @@ -929,11 +879,9 @@ describe('AssetOverview', () => { }, ); - expect(mockGetAccountByScope).toHaveBeenCalledWith(SOLANA_CHAIN_ID); - - selectSelectedInternalAccount.mockReset(); - selectSelectedAccountGroup.mockReset(); - selectSelectedInternalAccountByScope.mockReset(); + expect(mockSelectSelectedInternalAccountByScope).toHaveBeenCalledWith( + SOLANA_CHAIN_ID, + ); }); it('should not render swap button if displaySwapsButton is false', async () => { @@ -1060,11 +1008,7 @@ describe('AssetOverview', () => { }); it('renders staked TRX details when viewing TRX on Tron', () => { - const { selectTronResourcesBySelectedAccountGroup } = jest.requireMock( - '../../../selectors/assets/assets-list', - ); - - selectTronResourcesBySelectedAccountGroup.mockReturnValue([ + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue([ { symbol: 'strx-energy', balance: '10' }, { symbol: 'strx-bandwidth', balance: '20' }, ]); @@ -1623,36 +1567,49 @@ describe('AssetOverview', () => { const secondaryBalance = getByTestId(TOKEN_AMOUNT_BALANCE_TEST_ID); - // Should display formatted Solana balance - expect(secondaryBalance.props.children).toBe('123.45679 SOL'); + // Should display the balance directly (no truncation) + expect(secondaryBalance.props.children).toBe('123.456789 SOL'); }); }); it('should not render Balance component when balance is undefined', () => { - // Given an asset with undefined balance - const assetWithNoBalance = { + // Asset on a chain (0x999) that has no account data in AccountTrackerController + const assetOnUnknownChain = { ...asset, balance: undefined as unknown as string, + chainId: '0x999', // Chain not in AccountTrackerController.accountsByChainId + isETH: false, + isNative: false, }; - // Override the mock to enable state2 so balance stays undefined - const mockModule = jest.requireMock( - '../../../selectors/featureFlagController/multichainAccounts', - ); - const originalMock = mockModule.selectMultichainAccountsState2Enabled; - mockModule.selectMultichainAccountsState2Enabled = jest - .fn() - .mockReturnValue(true); + // State without any account data for chain 0x999 + const stateWithNoChainData = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + AccountTrackerController: { + accountsByChainId: { + // No data for 0x999 + }, + }, + TokenBalancesController: { + tokenBalances: { + // No token balances for this account/chain + }, + }, + }, + }, + }; const { queryByTestId } = renderWithProvider( - , - { state: mockInitialState }, + , + { state: stateWithNoChainData }, ); + // Balance component should not render when balance cannot be determined expect(queryByTestId(BALANCE_TEST_ID)).toBeNull(); - - // Restore original mock - mockModule.selectMultichainAccountsState2Enabled = originalMock; }); describe('Exchange Rate Fetching', () => { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index dbbdb5f3d6c..8e9bb0429b1 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -101,7 +101,6 @@ import { } from '@metamask/bridge-controller'; import { InitSendLocation } from '../../Views/confirmations/constants/send'; import { useSendNavigation } from '../../Views/confirmations/hooks/useSendNavigation'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; import parseRampIntent from '../Ramp/utils/parseRampIntent'; ///: BEGIN:ONLY_INCLUDE_IF(tron) import TronEnergyBandwidthDetail from './TronEnergyBandwidthDetail/TronEnergyBandwidthDetail'; @@ -240,9 +239,6 @@ const AssetOverview: React.FC = ({ ); const multiChainTokenBalance = useSelector(selectTokensBalances); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); const chainId = asset.chainId as Hex; const selectedNetworkClientId = useSelector(selectSelectedNetworkClientId); @@ -604,8 +600,8 @@ const AssetOverview: React.FC = ({ const exchangeRate = marketDataRate ?? fetchedRate; let balance; - const minimumDisplayThreshold = 0.00001; + const minimumDisplayThreshold = 0.00001; const isMultichainAsset = isNonEvmAsset; const isEthOrNative = asset.isETH || asset.isNative; @@ -627,8 +623,7 @@ const AssetOverview: React.FC = ({ } ///: END:ONLY_INCLUDE_IF - if (isMultichainAccountsState2Enabled && balanceSource != null) { - // When state2 is enabled and asset has balance, use it directly + if (balanceSource != null) { balance = balanceSource; } else if (isMultichainAsset) { balance = balanceSource diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx new file mode 100644 index 00000000000..876187c9c6a --- /dev/null +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx @@ -0,0 +1,481 @@ +import '../../../../../util/test/component-view/mocks'; +import { renderScreenWithRoutes } from '../../../../../util/test/component-view/render'; +import { initialStateWallet } from '../../../../../util/test/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../util/test/platform'; +import React from 'react'; +import EarnMusdConversionEducationView from './index'; +import { strings } from '../../../../../../locales/i18n'; +import { fireEvent, act } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { Hex } from '@metamask/utils'; +import { MUSD_CONVERSION_APY } from '../../constants/musd'; + +describeForPlatforms('EarnMusdConversionEducationView', () => { + const mockRouteParams = { + preferredPaymentToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Hex, + chainId: '0x1' as Hex, + }, + outputChainId: '0x1' as Hex, + }; + + it('renders education screen with all UI elements', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + expect( + getByText(/Convert your stablecoins to mUSD.*receive up to a \d+% bonus/), + ).toBeOnTheScreen(); + expect( + getByText(strings('earn.musd_conversion.education.primary_button')), + ).toBeOnTheScreen(); + expect( + getByText(strings('earn.musd_conversion.education.secondary_button')), + ).toBeOnTheScreen(); + }); + + it('renders education screen heading', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + // Verify screen renders with heading + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('keeps go back button visible after press', async () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + const goBackButton = getByText( + strings('earn.musd_conversion.education.secondary_button'), + ); + + // Act + await act(async () => { + fireEvent.press(goBackButton); + }); + + // Assert + // Button should still be on screen after press + expect(goBackButton).toBeOnTheScreen(); + }); + + it('keeps continue button visible after press when education not seen', async () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + user: { + musdConversionEducationSeen: false, + }, + } as unknown as Record) + .build(); + + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + const continueButton = getByText( + strings('earn.musd_conversion.education.primary_button'), + ); + + // Act + await act(async () => { + fireEvent.press(continueButton); + }); + + // Assert + expect(continueButton).toBeOnTheScreen(); + }); + + it('renders screen when route params are missing', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + {}, // Missing params + ); + + // Assert + // Component should still render + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders screen when outputChainId is missing in route params', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + { + preferredPaymentToken: mockRouteParams.preferredPaymentToken, + // Missing outputChainId + }, + ); + + // Assert + // Component should still render + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders screen when preferredPaymentToken is missing in route params', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + { + outputChainId: mockRouteParams.outputChainId, + // Missing preferredPaymentToken + }, + ); + + // Assert + // Component should still render + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders education screen with correct APY percentage in heading', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + const heading = getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ); + expect(heading).toBeOnTheScreen(); + expect(heading.props.children).toContain(`${MUSD_CONVERSION_APY}%`); + }); + + it('renders education screen with correct APY percentage in description', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + const description = getByText( + /Convert your stablecoins to mUSD.*receive up to a \d+% bonus/, + ); + expect(description).toBeOnTheScreen(); + expect(description.props.children[0]).toContain(`${MUSD_CONVERSION_APY}%`); + }); + + it('renders education screen when education has been seen', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + user: { + musdConversionEducationSeen: true, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders education screen with all route params provided', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + { + preferredPaymentToken: mockRouteParams.preferredPaymentToken, + outputChainId: mockRouteParams.outputChainId, + }, + ); + + // Assert + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx new file mode 100644 index 00000000000..663d74ea47c --- /dev/null +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx @@ -0,0 +1,117 @@ +import '../../../../../../util/test/component-view/mocks'; +import { renderComponentViewScreen } from '../../../../../../util/test/component-view/render'; +import { initialStateWallet } from '../../../../../../util/test/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../../util/test/platform'; +import React from 'react'; +import { View } from 'react-native'; +import MusdConversionAssetListCta from './index'; +import { EARN_TEST_IDS } from '../../../constants/testIds'; + +// Wrapper component to render the CTA in a screen context +const MusdConversionAssetListCtaScreen = () => ( + + + +); + +describeForPlatforms('MusdConversionAssetListCta', () => { + it('hides CTA when feature flag disabled', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdCtaEnabled: { enabled: false }, + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + MusdConversionAssetListCtaScreen, + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeNull(); + }); + + it('hides CTA when conversion flow feature flag disabled', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: false }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + MusdConversionAssetListCtaScreen, + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeNull(); + }); + + it('does not render CTA when visibility conditions are not met', () => { + // Arrange + // Component visibility depends on complex hook logic that requires + // specific state configuration. When conditions aren't met, component returns null. + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + MusdConversionAssetListCtaScreen, + { name: 'TestScreen' }, + { state }, + ); + + // Assert + // Component returns null when visibility conditions are not met + // This test verifies the component handles the case gracefully without crashing + const cta = queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA); + // When conditions aren't met, component should return null + expect(cta).toBeNull(); + }); +}); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx new file mode 100644 index 00000000000..67223f1a31f --- /dev/null +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx @@ -0,0 +1,584 @@ +import '../../../../../../util/test/component-view/mocks'; +import { renderComponentViewScreen } from '../../../../../../util/test/component-view/render'; +import { initialStateWallet } from '../../../../../../util/test/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../../util/test/platform'; +import React from 'react'; +import { View } from 'react-native'; +import MusdConversionAssetOverviewCta from './index'; +import { EARN_TEST_IDS } from '../../../constants/testIds'; +import { MUSD_CONVERSION_APY } from '../../../constants/musd'; +import { fireEvent } from '@testing-library/react-native'; +import { TokenI } from '../../../../Tokens/types'; + +// Wrapper component to render the CTA in a screen context +const MusdConversionAssetOverviewCtaScreen = ({ + asset, + onDismiss, +}: { + asset: TokenI; + onDismiss?: () => void; +}) => ( + + + +); + +describeForPlatforms('MusdConversionAssetOverviewCta', () => { + const mockAsset = { + 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, + }; + + it('renders CTA with asset', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('displays CTA text correctly', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByText(`Get ${MUSD_CONVERSION_APY}% on your stablecoins`), + ).toBeOnTheScreen(); + expect( + getByText( + `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + ), + ).toBeOnTheScreen(); + }); + + it('renders close button when onDismiss is provided', () => { + // Arrange + const mockOnDismiss = jest.fn(); + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA_CLOSE_BUTTON, + ), + ).toBeOnTheScreen(); + }); + + it('does not render close button when onDismiss is not provided', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + queryByTestId( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA_CLOSE_BUTTON, + ), + ).toBeNull(); + }); + + it('calls onDismiss when close button is pressed', () => { + // Arrange + const mockOnDismiss = jest.fn(); + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Act + fireEvent.press( + getByTestId( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA_CLOSE_BUTTON, + ), + ); + + // Assert + expect(mockOnDismiss).toHaveBeenCalledTimes(1); + }); + + it('renders CTA component as presentational component regardless of allowlist', () => { + // Arrange + const mockAssetNotInAllowlist = { + ...mockAsset, + symbol: 'USDT', // Not in allowlist + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, // Only USDC in allowlist + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + // Component always renders when called - it's a presentational component + // Visibility logic (including allowlist checks) is handled by the parent + // component via shouldShowAssetOverviewCta hook, not by this component itself + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA when asset balance is above minimum', () => { + // Arrange + const mockAssetWithBalance = { + ...mockAsset, + balance: '1000000000', // 1000 USDC (above minimum) + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + earnMusdConversionMinAssetBalanceRequired: 0.01, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA for different asset symbols', () => { + // Arrange + const mockDAIAsset = { + ...mockAsset, + symbol: 'DAI', + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['DAI', 'USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA when asset address is missing', () => { + // Arrange + const mockAssetWithoutAddress = { + ...mockAsset, + address: '', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + // Component should still render, error handling happens in handlePress + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA with asset having low balance', () => { + // Arrange + const mockAssetLowBalance = { + ...mockAsset, + balance: '1000', // 0.001 USDC (very low) + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + earnMusdConversionMinAssetBalanceRequired: 0.01, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + // Component renders regardless of balance - balance check is in parent + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA for asset on different chain', () => { + // Arrange + const mockLineaAsset = { + ...mockAsset, + chainId: '0xe708', // Linea Mainnet + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA with correct boost title text', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByText(`Get ${MUSD_CONVERSION_APY}% on your stablecoins`), + ).toBeOnTheScreen(); + }); + + it('renders CTA with correct boost description text', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByText( + `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + ), + ).toBeOnTheScreen(); + }); + + it('renders CTA for USDT asset when in allowlist', () => { + // Arrange + const mockUSDTAsset = { + ...mockAsset, + symbol: 'USDT', + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC', 'USDT'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index fa1a06a65fa..424dfc0e44c 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -995,10 +995,7 @@ export function getWalletNavbarOptions( - + {shouldDisplayCardButton && ( ({ ), })); -// Mock feature flag selectors -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(() => false), - }), -); - jest.mock('../../../../locales/i18n', () => ({ strings: (key: string) => key, })); @@ -765,9 +757,16 @@ describe('NetworkManager Component', () => { expect(getByTestId('custom-network-selector')).toBeOnTheScreen(); }); - it('should set initial tab to popular networks when selectedCount > 0', () => { - (useNetworksByNamespace as jest.Mock).mockReturnValue({ - selectedCount: 3, + it('sets initial tab to popular networks when multiple networks are enabled', () => { + (useNetworkEnablement as jest.Mock).mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: { + eip155: { + '0x1': true, + '0x89': true, + }, + }, }); const { getByTestId } = renderComponent(); @@ -776,9 +775,11 @@ describe('NetworkManager Component', () => { expect(tabView.props.initialPage).toBe(0); // Popular tab }); - it('should set initial tab to custom networks when selectedCount is 0', () => { - (useNetworksByNamespace as jest.Mock).mockReturnValue({ - selectedCount: 0, + it('sets initial tab to custom networks when no networks are enabled', () => { + (useNetworkEnablement as jest.Mock).mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: {}, }); const { getByTestId } = renderComponent(); diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index ab8a1d32c86..1fbdcdae2fd 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -37,10 +37,6 @@ import Device from '../../../util/device'; import Routes from '../../../constants/navigation/Routes'; import { createNavigationDetails } from '../../../util/navigation/navUtils'; import { selectNetworkConfigurationsByCaipChainId } from '../../../selectors/networkController'; -import { - useNetworksByNamespace, - NetworkType, -} from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useNetworkEnablement } from '../../hooks/useNetworkEnablement/useNetworkEnablement'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; @@ -51,7 +47,6 @@ import { ShowConfirmDeleteModalState, ShowMultiRpcSelectModalState, } from './index.types'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; import { POPULAR_NETWORK_CHAIN_IDS } from '../../../constants/popular-networks'; import RpcSelectionModal from '../../Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal'; import { isNonEvmChainId } from '../../../core/Multichain/utils'; @@ -87,15 +82,8 @@ const NetworkManager = () => { const { colors } = useTheme(); const { styles } = useStyles(createStyles, { colors }); const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); - const { selectedCount } = useNetworksByNamespace({ - networkType: NetworkType.Popular, - }); const { disableNetwork, enabledNetworksByNamespace } = useNetworkEnablement(); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - const enabledNetworks = useMemo(() => { function getEnabledNetworks( obj: Record>, @@ -331,20 +319,17 @@ const NetworkManager = () => { const defaultTabIndex = useMemo(() => { // If no popular networks are selected, default to custom tab (index 1) // Otherwise, show popular tab (index 0) - if (isMultichainAccountsState2Enabled) { - if (enabledNetworks.length === 1) { - const isPopularNetwork = POPULAR_NETWORK_CHAIN_IDS.has( - enabledNetworks[0] as `0x${string}`, - ) - ? 0 - : 1; - return isPopularNetwork; - } - - return enabledNetworks.length > 1 ? 0 : 1; + if (enabledNetworks.length === 1) { + const isPopularNetwork = POPULAR_NETWORK_CHAIN_IDS.has( + enabledNetworks[0] as `0x${string}`, + ) + ? 0 + : 1; + return isPopularNetwork; } - return selectedCount > 0 ? 0 : 1; - }, [selectedCount, isMultichainAccountsState2Enabled, enabledNetworks]); + + return enabledNetworks.length > 1 ? 0 : 1; + }, [enabledNetworks]); // Capture the initial tab index only once on first render // This prevents tab switching when networks are added/deleted diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx index 1b3e432ee1f..0eb1876f3a4 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx @@ -14,7 +14,6 @@ import { useNetworkSelection } from '../../hooks/useNetworkSelection/useNetworkS import { useNetworksToUse } from '../../hooks/useNetworksToUse/useNetworksToUse'; import NetworkMultiSelector from './NetworkMultiSelector'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from './NetworkMultiSelector.constants'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { useMetrics } from '../../hooks/useMetrics'; @@ -95,13 +94,6 @@ jest.mock('react-redux', () => ({ Provider: jest.requireActual('react-redux').Provider, })); -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - jest.mock('../../../selectors/accountsController', () => ({ selectSelectedInternalAccountByScope: jest.fn(() => jest.fn()), selectInternalAccounts: jest.fn(), @@ -289,7 +281,6 @@ describe('NetworkMultiSelector', () => { return evmConfigs; if (selector === selectNonEvmNetworkConfigurationsByChainId) return nonEvmConfigs; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') return { id: 'evm-account' }; @@ -401,9 +392,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -566,7 +554,7 @@ describe('NetworkMultiSelector', () => { ); }); - it('renders custom network component even for non-EIP155 namespace when multichain is enabled', () => { + it('renders custom network component for non-EIP155 namespace', () => { mockUseNetworkEnablement.mockReturnValue({ namespace: 'solana' as KnownCaipNamespace, enabledNetworksByNamespace: { solana: {} }, @@ -586,7 +574,7 @@ describe('NetworkMultiSelector', () => { ); const networkList = getByTestId('mock-network-multi-selector-list'); - // Since multichain is enabled, it should still render the custom network component + // Custom network component should always render expect(networkList.props.additionalNetworksComponent).toBeTruthy(); expect(networkList.props.additionalNetworksComponent.props.testID).toBe( NETWORK_MULTI_SELECTOR_TEST_IDS.CUSTOM_NETWORK_CONTAINER, @@ -800,9 +788,6 @@ describe('NetworkMultiSelector', () => { // Setup selector mock mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -882,9 +867,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope.includes('solana')) { @@ -964,9 +946,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return () => null; // No accounts selected } @@ -981,7 +960,7 @@ describe('NetworkMultiSelector', () => { expect(networkList.props.networks).toEqual(mockNetworks); }); - it('uses regular networks when multichain is disabled', () => { + it('uses regular networks from hook', () => { // Clear all mocks for clean state jest.clearAllMocks(); @@ -1018,7 +997,7 @@ describe('NetworkMultiSelector', () => { customNetworksToReset: [], }); - // Mock useNetworksToUse to return default networks when multichain disabled + // Mock useNetworksToUse to return default networks mockUseNetworksToUse.mockReturnValue({ networksToUse: mockNetworks, evmNetworks: [], @@ -1029,7 +1008,7 @@ describe('NetworkMultiSelector', () => { selectedSolanaAccount: null, selectedBitcoinAccount: null, selectedTronAccount: null, - isMultichainAccountsState2Enabled: false, + isMultichainAccountsState2Enabled: true, areAllNetworksSelectedCombined: false, areAllEvmNetworksSelected: false, areAllSolanaNetworksSelected: false, @@ -1038,9 +1017,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return false; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -1101,9 +1077,6 @@ describe('NetworkMultiSelector', () => { // Override the selector for this specific test mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -2425,7 +2398,7 @@ describe('NetworkMultiSelector', () => { jest.clearAllMocks(); }); - it('renders custom network component for multichain enabled even with non-EIP155 namespace', () => { + it('always renders custom network component for any namespace', () => { mockUseNetworkEnablement.mockReturnValue({ namespace: 'solana' as KnownCaipNamespace, enabledNetworksByNamespace: { solana: {} }, @@ -2440,12 +2413,6 @@ describe('NetworkMultiSelector', () => { enabledNetworksForAllNamespaces: mockEnabledNetworks, }); - mockUseSelector - .mockReturnValueOnce(true) // isMultichainAccountsState2Enabled - .mockReturnValueOnce(() => null) // selectedEvmAccount - .mockReturnValueOnce(() => null) // selectedSolanaAccount - .mockReturnValueOnce(() => null); // selectedBitcoinAccount - const { getByTestId } = renderWithProvider( , ); @@ -2676,9 +2643,6 @@ describe('NetworkMultiSelector', () => { // Override the selector for this specific test mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx index 6914d621574..8561e92a196 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx @@ -99,11 +99,7 @@ const NetworkMultiSelector = ({ const selectedNonEvmChainId = useSelector(selectSelectedNonEvmNetworkChainId); const currentEvmChainId = useSelector(selectEvmChainId); - const { - networksToUse, - areAllNetworksSelectedCombined, - isMultichainAccountsState2Enabled, - } = useNetworksToUse({ + const { networksToUse, areAllNetworksSelectedCombined } = useNetworksToUse({ networks, networkType: NetworkType.Popular, areAllNetworksSelected, @@ -225,22 +221,15 @@ const NetworkMultiSelector = ({ ); const additionalNetworksComponent = useMemo( - () => - namespace === KnownCaipNamespace.Eip155 || - isMultichainAccountsState2Enabled ? ( - - - - ) : null, - [ - namespace, - customNetworkProps, - isMultichainAccountsState2Enabled, - styles.customNetworkContainer, - ], + () => ( + + + + ), + [customNetworkProps, styles.customNetworkContainer], ); const getNetworkName = useCallback( diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index b3eaf8183ee..3f781a1e484 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -92,13 +92,6 @@ jest.mock('../../../selectors/multichainNetworkController', () => ({ selectIsEvmNetworkSelected: jest.fn(), })); -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts/index.ts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - jest.mock('../../../multichain-accounts/remote-feature-flag', () => ({ isMultichainAccountsRemoteFeatureEnabled: jest.fn(), MULTI_CHAIN_ACCOUNTS_FEATURE_VERSION_1: 'v1', @@ -254,8 +247,7 @@ describe('NetworkMultiSelectorList', () => { if (selector === mockSelectIsEvmNetworkSelected) { return true; } - // Default return for selectMultichainAccountsState2Enabled - return false; + return undefined; }); mockUseSafeAreaInsets.mockReturnValue({ diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index a9c0fdff13e..d7a2bb58589 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -59,7 +59,6 @@ import { import { selectEvmChainId } from '../../../selectors/networkController'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/index.ts'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored/index.ts'; import { strings } from '../../../../locales/i18n'; @@ -102,9 +101,6 @@ const NetworkMultiSelectList = ({ const selectedChainIdCaip = isEvmSelected ? formatChainIdToCaip(evmChainId) : (nonEvmChainId ?? formatChainIdToCaip(evmChainId)); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); @@ -142,10 +138,7 @@ const NetworkMultiSelectList = ({ data.push(...filteredNetworks); } - if ( - (selectAllNetworksComponent && isEvmSelected) || - isMultichainAccountsState2Enabled - ) { + if (selectAllNetworksComponent) { data.unshift({ id: SELECT_ALL_NETWORKS_SECTION_ID, type: NetworkListItemType.SelectAllNetworksListItem, @@ -166,8 +159,6 @@ const NetworkMultiSelectList = ({ processedNetworks, additionalNetworksComponent, selectAllNetworksComponent, - isEvmSelected, - isMultichainAccountsState2Enabled, ]); const debouncedSelectNetwork = useMemo( diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index 3ee6237d2a6..5a30c55f807 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -2381,7 +2381,9 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const result = await controller.depositWithConfirmation('100'); + const result = await controller.depositWithConfirmation({ + amount: '100', + }); expect(result).toEqual({ result: expect.any(Promise), @@ -2392,7 +2394,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockDepositServiceInstance.prepareTransaction, @@ -2405,7 +2407,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockInfrastructure.controllers.network.findNetworkClientIdForChain, @@ -2416,7 +2418,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockInfrastructure.controllers.transaction.submit, @@ -2431,16 +2433,18 @@ describe('PerpsController', () => { it('throws error when controller not initialized', async () => { controller.testSetInitialized(false); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'CLIENT_NOT_INITIALIZED', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('CLIENT_NOT_INITIALIZED'); }); it('throws error when no active provider', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map()); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow(); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow(); }); it('propagates DepositService errors', async () => { @@ -2451,9 +2455,9 @@ describe('PerpsController', () => { .spyOn(mockDepositServiceInstance, 'prepareTransaction') .mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Deposit service failed', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Deposit service failed'); }); it('propagates controllers.network.findNetworkClientIdForChain errors', async () => { @@ -2467,9 +2471,9 @@ describe('PerpsController', () => { throw mockError; }); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Network client not found', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network client not found'); }); it('propagates controllers.transaction.submit errors', async () => { @@ -2480,9 +2484,9 @@ describe('PerpsController', () => { mockInfrastructure.controllers.transaction.submit as jest.Mock ).mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Transaction failed', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Transaction failed'); }); it('clears transaction ID when error occurs and not user cancellation', async () => { @@ -2496,9 +2500,9 @@ describe('PerpsController', () => { mockInfrastructure.controllers.transaction.submit as jest.Mock ).mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Network error', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network error'); expect(controller.state.lastDepositTransactionId).toBeNull(); }); @@ -2514,9 +2518,9 @@ describe('PerpsController', () => { mockInfrastructure.controllers.transaction.submit as jest.Mock ).mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'User denied', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('User denied'); // When user cancels, transaction ID is not cleared expect(controller.state.lastDepositTransactionId).toBe('old-tx-id'); @@ -2536,7 +2540,9 @@ describe('PerpsController', () => { }; }); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); await result; @@ -2549,7 +2555,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect(controller.state.lastDepositTransactionId).toBe('tx-meta-123'); }); @@ -2558,7 +2564,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect(controller.state.depositRequests[0].id).toBe(mockDepositId); }); @@ -2567,7 +2573,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockDepositServiceInstance.prepareTransaction, @@ -2580,7 +2586,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect(controller.state.depositRequests).toHaveLength(1); expect(controller.state.depositRequests[0].id).toBe(mockDepositId); @@ -2601,7 +2607,9 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); await result; @@ -2615,8 +2623,8 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const deposit1 = controller.depositWithConfirmation('100'); - const deposit2 = controller.depositWithConfirmation('200'); + const deposit1 = controller.depositWithConfirmation({ amount: '100' }); + const deposit2 = controller.depositWithConfirmation({ amount: '200' }); await Promise.all([deposit1, deposit2]); @@ -2626,14 +2634,17 @@ describe('PerpsController', () => { expect(amounts).toContain('200'); }); - it('uses addTransaction when depositAndPlaceOrder is true', async () => { + it('uses addTransaction when placeOrder is true', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockAddTransaction.mockResolvedValue({ transactionMeta: mockTransactionMeta, }); - await controller.depositWithConfirmation('100', true); + await controller.depositWithConfirmation({ + amount: '100', + placeOrder: true, + }); expect(mockAddTransaction).toHaveBeenCalledWith(mockTransaction, { networkClientId: mockNetworkClientId, @@ -2652,7 +2663,9 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); // Transaction succeeds await result; @@ -2684,7 +2697,9 @@ describe('PerpsController', () => { transactionMeta: mockTransactionMeta, }); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); // Wait for the result promise to reject await expect(result).rejects.toThrow('Network error occurred'); @@ -2729,7 +2744,9 @@ describe('PerpsController', () => { transactionMeta: mockTransactionMeta, }); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); await expect(result).rejects.toThrow(message); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 57183d8044f..1a13813a398 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -57,6 +57,7 @@ import { type ClosePositionParams, type ClosePositionsParams, type ClosePositionsResult, + type DepositWithConfirmationParams, type EditOrderParams, type FeeCalculationParams, type FeeCalculationResult, @@ -1484,13 +1485,12 @@ export class PerpsController extends BaseController< /** * Simplified deposit method that prepares transaction for confirmation screen * No complex state tracking - just sets a loading flag - * @param amount - Optional deposit amount - * @param depositAndPlaceOrder - If true, uses addTransaction instead of submit to avoid navigation + * @param params - Parameters for the deposit flow + * @param params.amount - Optional deposit amount + * @param params.placeOrder - If true, uses addTransaction instead of submit to avoid navigation */ - async depositWithConfirmation( - amount?: string, - depositAndPlaceOrder?: boolean, - ) { + async depositWithConfirmation(params: DepositWithConfirmationParams = {}) { + const { amount, placeOrder } = params; const { controllers } = this.options.infrastructure; try { @@ -1545,7 +1545,7 @@ export class PerpsController extends BaseController< skipInitialGasEstimate: true, }; - if (depositAndPlaceOrder) { + if (placeOrder) { // Use addTransaction to create transaction without navigating to confirmation screen const { transactionMeta: addedTransactionMeta } = await addTransaction( transaction, @@ -1577,7 +1577,7 @@ export class PerpsController extends BaseController< }); // Track the transaction lifecycle only when using submit (deposit-only flow) - if (!depositAndPlaceOrder) { + if (!placeOrder) { // At this point, the confirmation modal is shown to the user // The result promise will resolve/reject based on user action and transaction outcome @@ -1698,10 +1698,9 @@ export class PerpsController extends BaseController< /** * Same as depositWithConfirmation - prepares transaction for confirmation screen. - * @param amount - Optional deposit amount */ - async depositWithOrder(amount?: string) { - return this.depositWithConfirmation(amount, true); + async depositWithOrder() { + return this.depositWithConfirmation({ placeOrder: true }); } /** diff --git a/app/components/UI/Perps/controllers/index.ts b/app/components/UI/Perps/controllers/index.ts index 181bc476e50..3dac5894b28 100644 --- a/app/components/UI/Perps/controllers/index.ts +++ b/app/components/UI/Perps/controllers/index.ts @@ -63,6 +63,7 @@ export type { // Deposit/withdrawal types DepositParams, + DepositWithConfirmationParams, DepositResult, WithdrawParams, WithdrawResult, diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 99dcae13820..9ae800796ca 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -3448,9 +3448,8 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready for trading (includes signing operations) await this.ensureReadyForTrading(); - // Get all current positions - // Force fresh API data (not WebSocket cache) since we're about to mutate positions - const positions = await this.getPositions({ skipCache: true }); + // Get all current positions from cache (avoids 429 rate limiting) + const positions = await this.getPositions(); // Filter positions based on params positionsToClose = @@ -3980,9 +3979,13 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready for trading (includes signing operations) await this.ensureReadyForTrading(); - // Force fresh API data (not WebSocket cache) since we're about to mutate the position - const positions = await this.getPositions({ skipCache: true }); - const position = positions.find((pos) => pos.symbol === params.symbol); + // Use provided position (from WebSocket) or fetch from cache + // This avoids unnecessary API calls and prevents 429 rate limiting + let position = params.position; + if (!position) { + const positions = await this.getPositions(); + position = positions.find((pos) => pos.symbol === params.symbol); + } if (!position) { throw new Error(`No position found for ${params.symbol}`); @@ -4119,9 +4122,8 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready await this.ensureReady(); - // Get current position to determine direction - // Force fresh API data since we're about to mutate the position - const positions = await this.getPositions({ skipCache: true }); + // Get current position to determine direction (from cache to avoid 429 rate limiting) + const positions = await this.getPositions(); const position = positions.find((pos) => pos.symbol === symbol); if (!position) { diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index eaf50a0e42e..24e1285d299 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -239,6 +239,13 @@ export type ClosePositionParams = { // Multi-provider routing (optional: defaults to active/default provider) providerId?: PerpsProviderType; // Optional: override active provider for routing + + /** + * Optional live position data from WebSocket. + * If provided, skips the REST API position fetch (avoids rate limiting issues). + * If not provided, falls back to fetching positions via REST API cache. + */ + position?: Position; }; export type ClosePositionsParams = { @@ -459,6 +466,14 @@ export interface DepositParams { recipient?: Hex; // Recipient address (defaults to selected account) } +/** Params for depositWithConfirmation: prepares transaction for confirmation screen */ +export interface DepositWithConfirmationParams { + /** Optional deposit amount (display/tracking; actual amount comes from prepared transaction) */ + amount?: string; + /** If true, uses addTransaction instead of submit to avoid navigation (e.g. deposit + place order flow) */ + placeOrder?: boolean; +} + export interface DepositResult { success: boolean; txHash?: string; diff --git a/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts b/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts index 9ff822c15b4..3fb05643cbe 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts @@ -121,6 +121,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: mockPosition, }); expect(onSuccess).toHaveBeenCalledWith(successResult); @@ -168,6 +169,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: mockPosition, }); expect(onSuccess).toHaveBeenCalledWith(successResult); @@ -325,6 +327,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: mockPosition, }); }); @@ -392,6 +395,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: positionWithTPSL, }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsClosePosition.ts b/app/components/UI/Perps/hooks/usePerpsClosePosition.ts index 12ce5cc850d..6c512487fd2 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePosition.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePosition.ts @@ -126,6 +126,8 @@ export const usePerpsClosePosition = ( usdAmount: slippage?.usdAmount, priceAtCalculation: slippage?.priceAtCalculation, maxSlippageBps: slippage?.maxSlippageBps, + // Pass live position to avoid getPositions() API call (prevents 429 rate limiting) + position, }); DevLogger.log('usePerpsClosePosition: Close result', result); diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.ts b/app/components/UI/Perps/hooks/usePerpsTrading.ts index 1bc9f608fa2..e149e4984d3 100644 --- a/app/components/UI/Perps/hooks/usePerpsTrading.ts +++ b/app/components/UI/Perps/hooks/usePerpsTrading.ts @@ -113,22 +113,17 @@ export function usePerpsTrading() { result: Promise; }> => { const controller = Engine.context.PerpsController; - return controller.depositWithConfirmation(amount, false); + return controller.depositWithConfirmation({ amount, placeOrder: false }); }, [], ); - const depositWithOrder = useCallback( - async ( - amount?: string, - ): Promise<{ - result: Promise; - }> => { - const controller = Engine.context.PerpsController; - return controller.depositWithOrder(amount); - }, - [], - ); + const depositWithOrder = useCallback(async (): Promise<{ + result: Promise; + }> => { + const controller = Engine.context.PerpsController; + return controller.depositWithOrder(); + }, []); const clearDepositResult = useCallback((): void => { const controller = Engine.context.PerpsController; diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 1bc239a258d..f1ac4dab672 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -16,7 +16,6 @@ import PerpsMarketListView from '../Views/PerpsMarketListView'; import PerpsRedirect from '../Views/PerpsRedirect'; import PerpsPositionsView from '../Views/PerpsPositionsView'; import PerpsWithdrawView from '../Views/PerpsWithdrawView'; -import PerpsOrderView from '../Views/PerpsOrderView'; import PerpsClosePositionView from '../Views/PerpsClosePositionView'; import PerpsCloseAllPositionsView from '../Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView'; import PerpsCancelAllOrdersView from '../Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView'; @@ -276,15 +275,6 @@ const PerpsScreenStack = () => { }} /> - - @@ -38,21 +39,6 @@ const createStyles = (colors) => paddingBottom: 10, ...fontStyles.normal, }, - accesoryBar: { - width: '100%', - paddingTop: 5, - height: 50, - borderBottomColor: colors.border.muted, - borderBottomWidth: 1, - }, - label: { - textAlign: 'center', - flex: 1, - paddingVertical: 10, - fontSize: 17, - ...fontStyles.bold, - color: colors.text.default, - }, modal: { margin: 0, width: '100%', @@ -196,9 +182,7 @@ export default class SelectComponent extends PureComponent { backdropOpacity={1} > - - {this.props.label} - + {this.props.options.map((option) => ( diff --git a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts index 6611502aa1d..4730fe5e46d 100644 --- a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts @@ -30,6 +30,7 @@ interface UseSitesDataResult { const PORTFOLIO_API_BASE_URL = 'https://portfolio.api.cx.metamask.io/'; const DEFAULT_SITES_LIMIT = 200; +const PORTFOLIO_HOSTNAME = 'portfolio.metamask.io'; /** * Hardcoded Portfolio site entry to ensure it's always included @@ -38,8 +39,8 @@ const DEFAULT_SITES_LIMIT = 200; const PORTFOLIO_SITE: SiteData = { id: 'metamask-portfolio', name: 'MetaMask Portfolio', - url: 'https://portfolio.metamask.io', - displayUrl: 'portfolio.metamask.io', + url: `https://${PORTFOLIO_HOSTNAME}`, + displayUrl: PORTFOLIO_HOSTNAME, logoUrl: 'https://raw.githubusercontent.com/MetaMask/metamask-mobile/main/logo.png', featured: true, @@ -58,6 +59,19 @@ const extractDisplayUrl = (url: string): string => { } }; +const isPortfolioSiteUrl = (url: string): boolean => { + try { + const trimmedUrl = url.trim(); + const normalizedUrl = + trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://') + ? trimmedUrl + : `https://${trimmedUrl}`; + return new URL(normalizedUrl).hostname === PORTFOLIO_HOSTNAME; + } catch { + return false; + } +}; + /** * Helper function to merge Portfolio site with API sites, * ensuring Portfolio is always included at the beginning @@ -65,9 +79,7 @@ const extractDisplayUrl = (url: string): string => { const mergePortfolioSite = (sites: SiteData[]): SiteData[] => { // Check if Portfolio is already in the list (by URL match) const portfolioExists = sites.some( - (site) => - site.url.includes('portfolio.metamask.io') || - site.id === PORTFOLIO_SITE.id, + (site) => isPortfolioSiteUrl(site.url) || site.id === PORTFOLIO_SITE.id, ); if (portfolioExists) { diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index c003e03c3c2..8533a750bd0 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -3,13 +3,25 @@ import { fireEvent, waitFor } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import TrendingTokenRowItem from './TrendingTokenRowItem'; import type { TrendingAsset } from '@metamask/assets-controllers'; -import { TimeOption } from '../TrendingTokensBottomSheet'; +import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; // Mock the trendingNetworksList module to avoid getNetworkImageSource errors jest.mock('../../utils/trendingNetworksList', () => ({ TRENDING_NETWORKS_LIST: [], })); +const mockTrackTokenClick = jest.fn(); + +jest.mock('../../services/TrendingFeedSessionManager', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + trackTokenClick: mockTrackTokenClick, + }), + }, +})); + const mockNavigate = jest.fn(); const mockAddPopularNetwork = jest.fn(); @@ -1184,4 +1196,387 @@ describe('TrendingTokenRowItem', () => { }); }); }); + + describe('token click analytics tracking', () => { + const mockFilterContext: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const networkAddedState: any = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurations: {}, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + caipChainId: 'eip155:1', + name: 'Ethereum Mainnet', + }, + }, + }, + MultichainNetworkController: { + selectedMultichainNetworkChainId: undefined, + multichainNetworkConfigurationsByChainId: {}, + }, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsCaipChainId.mockReturnValue(true); + }); + + it('tracks token click with correct properties when position and filterContext are provided', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + price: '1.00135763432467', + priceChangePct: { + h24: '+3.44', + h6: '+1.23', + h1: '+0.56', + m5: '+0.12', + }, + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith({ + token_symbol: 'USDC', + token_address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + token_name: 'USD Coin', + chain_id: '0x1', + position: 2, + price_usd: 1.00135763432467, + price_change_pct: 3.44, + time_filter: TimeOption.TwentyFourHours, + sort_option: PriceChangeOption.PriceChange, + network_filter: 'all', + is_search_result: false, + }); + }); + + it('tracks token click with search result flag when isSearchResult is true', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + price: '50.00', + }); + + const searchFilterContext: TrendingFilterContext = { + ...mockFilterContext, + isSearchResult: true, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + is_search_result: true, + }), + ); + }); + + it('tracks token click with correct network filter when specific network is selected', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const networkFilterContext: TrendingFilterContext = { + ...mockFilterContext, + networkFilter: 'eip155:1', + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + position: 5, + network_filter: 'eip155:1', + }), + ); + }); + + it('tracks token click with different time filters', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + priceChangePct: { + h24: '+3.44', + h6: '+1.23', + h1: '+0.56', + m5: '+0.12', + }, + }); + + const sixHourFilterContext: TrendingFilterContext = { + ...mockFilterContext, + timeFilter: TimeOption.SixHours, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + time_filter: TimeOption.SixHours, + price_change_pct: 1.23, + }), + ); + }); + + it('does not track token click when position is undefined', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); + + it('does not track token click when filterContext is undefined', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); + + it('does not track token click when both position and filterContext are undefined', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); + + it('tracks token click with zero position (first item in list)', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + position: 0, + }), + ); + }); + + it('uses default sort option when sortOption is undefined in filterContext', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const noSortFilterContext: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: undefined, + networkFilter: 'all', + isSearchResult: false, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + sort_option: PriceChangeOption.PriceChange, + }), + ); + }); + + it('handles zero price correctly', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + price: '0', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + price_usd: 0, + }), + ); + }); + + it('handles null price change percentage correctly', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + priceChangePct: undefined, + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + price_change_pct: 0, + }), + ); + }); + }); }); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index b9b8ce05d5c..2f1013e727c 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -37,12 +37,14 @@ import { import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import { formatMarketStats } from './utils'; import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; -import { TimeOption } from '../TrendingTokensBottomSheet'; +import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; import { getTrendingTokenImageUrl } from '../../utils/getTrendingTokenImageUrl'; import { useRWAToken } from '../../../Bridge/hooks/useRWAToken'; import StockBadge from '../../../shared/StockBadge'; import { useAddPopularNetwork } from '../../../../hooks/useAddPopularNetwork'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; +import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; /** * Extracts CAIP chain ID from asset ID @@ -146,6 +148,10 @@ export const getPriceChangeFieldKey = ( interface TrendingTokenRowItemProps { token: TrendingAsset; selectedTimeOption?: TimeOption; + /** 0-indexed position in the list for analytics */ + position?: number; + /** Filter context for analytics tracking */ + filterContext?: TrendingFilterContext; } /** @@ -183,6 +189,8 @@ const getAssetNavigationParams = (token: TrendingAsset) => { const TrendingTokenRowItem = ({ token, selectedTimeOption = TimeOption.TwentyFourHours, + position, + filterContext, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -191,6 +199,7 @@ const TrendingTokenRowItem = ({ ); const { addPopularNetwork } = useAddPopularNetwork(); const { isStockToken } = useRWAToken(); + const sessionManager = TrendingFeedSessionManager.getInstance(); // Memoize derived values const caipChainId = useMemo( @@ -222,6 +231,23 @@ const TrendingTokenRowItem = ({ const handlePress = useCallback(async () => { if (!assetParams) return; + // Track token click event BEFORE navigation to ensure capture + if (position !== undefined && filterContext) { + sessionManager.trackTokenClick({ + token_symbol: token.symbol, + token_address: assetParams.address, + token_name: token.name, + chain_id: assetParams.chainId, + position, + price_usd: parseFloat(token.price) || 0, + price_change_pct: pricePercentChange ?? 0, + time_filter: filterContext.timeFilter, + sort_option: filterContext.sortOption || PriceChangeOption.PriceChange, + network_filter: filterContext.networkFilter, + is_search_result: filterContext.isSearchResult, + }); + } + const isNetworkAdded = Boolean(networkConfigurations[caipChainId]); if (!isNetworkAdded) { @@ -250,6 +276,11 @@ const TrendingTokenRowItem = ({ navigation, networkConfigurations, addPopularNetwork, + position, + filterContext, + pricePercentChange, + token, + sessionManager, ]); return ( diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx index ae890d714da..fe00c4ef8f8 100644 --- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx +++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx @@ -3,7 +3,21 @@ import { RefreshControl } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import { TrendingAsset } from '@metamask/assets-controllers'; import TrendingTokenRowItem from '../TrendingTokenRowItem/TrendingTokenRowItem'; -import { TimeOption } from '../TrendingTokensBottomSheet'; +import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; + +/** + * Filter context for analytics tracking + */ +export interface TrendingFilterContext { + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + timeFilter: TimeOption; + /** Active sort option */ + sortOption: PriceChangeOption | undefined; + /** Active network filter (chain ID or 'all') */ + networkFilter: string; + /** Whether results are from search */ + isSearchResult: boolean; +} export interface TrendingTokensListProps { /** @@ -18,6 +32,10 @@ export interface TrendingTokensListProps { * Refresh control for pull-to-refresh functionality */ refreshControl?: React.ReactElement; + /** + * Filter context for analytics tracking + */ + filterContext?: TrendingFilterContext; } /** @@ -27,15 +45,17 @@ export interface TrendingTokensListProps { * (renderItem and keyExtractor) to avoid recreating them on every render */ const TrendingTokensList: React.FC = React.memo( - ({ trendingTokens, selectedTimeOption, refreshControl }) => { + ({ trendingTokens, selectedTimeOption, refreshControl, filterContext }) => { const renderItem = useCallback( - ({ item }: { item: TrendingAsset }) => ( + ({ item, index }: { item: TrendingAsset; index: number }) => ( ), - [selectedTimeOption], + [selectedTimeOption, filterContext], ); const keyExtractor = useCallback( diff --git a/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts new file mode 100644 index 00000000000..aaf63f828f0 --- /dev/null +++ b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts @@ -0,0 +1,278 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSearchTracking } from './useSearchTracking'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; + +// Mock the TrendingFeedSessionManager +jest.mock('../../services/TrendingFeedSessionManager', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(), + }, +})); + +describe('useSearchTracking', () => { + const mockTrackSearch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + (TrendingFeedSessionManager.getInstance as jest.Mock).mockReturnValue({ + trackSearch: mockTrackSearch, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const defaultProps = { + searchQuery: '', + resultsCount: 0, + isLoading: false, + timeFilter: '24h', + sortOption: 'price_change', + networkFilter: 'all', + }; + + it('does not track when search query is empty', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: '', + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).not.toHaveBeenCalled(); + }); + + it('does not track when results are loading', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: 'ethereum', + isLoading: true, + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).not.toHaveBeenCalled(); + }); + + it('tracks search after debounce when query is non-empty and not loading', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: 'ethereum', + resultsCount: 5, + isLoading: false, + }), + ); + + // Should not track immediately + expect(mockTrackSearch).not.toHaveBeenCalled(); + + // Advance timers past debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + expect(mockTrackSearch).toHaveBeenCalledWith({ + search_query: 'ethereum', + results_count: 5, + has_results: true, + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'all', + }); + }); + + it('tracks has_results as false when resultsCount is 0', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: 'nonexistent', + resultsCount: 0, + isLoading: false, + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledWith( + expect.objectContaining({ + search_query: 'nonexistent', + results_count: 0, + has_results: false, + }), + ); + }); + + it('does not track the same query twice', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { + ...defaultProps, + searchQuery: 'bitcoin', + resultsCount: 3, + }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Rerender with same query + rerender({ ...defaultProps, searchQuery: 'bitcoin', resultsCount: 3 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should still be 1 (not tracked again) + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + }); + + it('tracks when query changes', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { + ...defaultProps, + searchQuery: 'bitcoin', + resultsCount: 3, + }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Rerender with different query + rerender({ ...defaultProps, searchQuery: 'ethereum', resultsCount: 5 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(2); + expect(mockTrackSearch).toHaveBeenLastCalledWith( + expect.objectContaining({ + search_query: 'ethereum', + results_count: 5, + }), + ); + }); + + it('debounces rapid query changes', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { ...defaultProps, searchQuery: 'e', resultsCount: 10 }, + }); + + // Simulate rapid typing + rerender({ ...defaultProps, searchQuery: 'et', resultsCount: 8 }); + act(() => { + jest.advanceTimersByTime(100); + }); + + rerender({ ...defaultProps, searchQuery: 'eth', resultsCount: 5 }); + act(() => { + jest.advanceTimersByTime(100); + }); + + rerender({ ...defaultProps, searchQuery: 'ethe', resultsCount: 3 }); + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should not have tracked yet (still within debounce) + expect(mockTrackSearch).not.toHaveBeenCalled(); + + // Complete the debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should only track the final query + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + expect(mockTrackSearch).toHaveBeenCalledWith( + expect.objectContaining({ + search_query: 'ethe', + results_count: 3, + }), + ); + }); + + it('resets tracking when search is cleared', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { + ...defaultProps, + searchQuery: 'bitcoin', + resultsCount: 3, + }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Clear search + rerender({ ...defaultProps, searchQuery: '', resultsCount: 0 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should not track empty query + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Search again with same query + rerender({ ...defaultProps, searchQuery: 'bitcoin', resultsCount: 3 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should track again after clearing + expect(mockTrackSearch).toHaveBeenCalledTimes(2); + }); + + it('passes all filter values correctly', () => { + renderHook(() => + useSearchTracking({ + searchQuery: 'test', + resultsCount: 10, + isLoading: false, + timeFilter: '7d', + sortOption: 'volume', + networkFilter: '0x1', + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledWith({ + search_query: 'test', + results_count: 10, + has_results: true, + time_filter: '7d', + sort_option: 'volume', + network_filter: '0x1', + }); + }); +}); diff --git a/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts new file mode 100644 index 00000000000..52a72549479 --- /dev/null +++ b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; + +interface UseSearchTrackingOptions { + /** + * The search query string + */ + searchQuery: string; + /** + * Number of results found + */ + resultsCount: number; + /** + * Whether results are still loading + */ + isLoading: boolean; + /** + * Time filter value (e.g., '24h', '7d') + */ + timeFilter: string; + /** + * Sort option value (e.g., 'price_change', 'relevance') + */ + sortOption: string; + /** + * Network filter value (e.g., 'all', '0x1') + */ + networkFilter: string; + /** + * Debounce delay in milliseconds (default: 500) + */ + debounceMs?: number; +} + +/** + * Hook to track search events with debouncing. + * Fires a search analytics event when the user searches for tokens. + * + * @param options - Configuration options for search tracking + */ +export const useSearchTracking = ({ + searchQuery, + resultsCount, + isLoading, + timeFilter, + sortOption, + networkFilter, + debounceMs = 500, +}: UseSearchTrackingOptions): void => { + const lastTrackedSearchQuery = useRef(''); + const searchDebounceTimer = useRef | null>( + null, + ); + const sessionManager = TrendingFeedSessionManager.getInstance(); + + useEffect(() => { + // Clear any existing debounce timer + if (searchDebounceTimer.current) { + clearTimeout(searchDebounceTimer.current); + } + + const trimmedQuery = searchQuery?.trim() || ''; + + // Only track if query is non-empty, results are loaded, and different from last tracked + if ( + trimmedQuery && + !isLoading && + trimmedQuery !== lastTrackedSearchQuery.current + ) { + // Debounce search tracking to avoid tracking every keystroke + searchDebounceTimer.current = setTimeout(() => { + sessionManager.trackSearch({ + search_query: trimmedQuery, + results_count: resultsCount, + has_results: resultsCount > 0, + time_filter: timeFilter, + sort_option: sortOption, + network_filter: networkFilter, + }); + lastTrackedSearchQuery.current = trimmedQuery; + }, debounceMs); + } + + // Reset last tracked query when search is cleared + if (!trimmedQuery) { + lastTrackedSearchQuery.current = ''; + } + + return () => { + if (searchDebounceTimer.current) { + clearTimeout(searchDebounceTimer.current); + } + }; + }, [ + searchQuery, + resultsCount, + isLoading, + timeFilter, + sortOption, + networkFilter, + debounceMs, + sessionManager, + ]); +}; diff --git a/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts b/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts index 40e72ccfe20..b901f2b495c 100644 --- a/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts +++ b/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts @@ -1,5 +1,10 @@ import { AppState } from 'react-native'; -import TrendingFeedSessionManager from './TrendingFeedSessionManager'; +import TrendingFeedSessionManager, { + TrendingInteractionType, + TokenClickProperties, + SearchProperties, + FilterChangeProperties, +} from './TrendingFeedSessionManager'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; @@ -97,6 +102,7 @@ describe('TrendingFeedSessionManager', () => { ); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, session_time: 0, is_session_end: false, entry_point: entryPoint, @@ -138,6 +144,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, session_time: 0, is_session_end: false, entry_point: 'main_trade_button', @@ -159,6 +166,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionEnd, session_time: 5, is_session_end: true, entry_point: 'homepage_balance', @@ -239,6 +247,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ + interaction_type: TrendingInteractionType.SessionEnd, session_time: 3, is_session_end: true, }), @@ -254,6 +263,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ + interaction_type: TrendingInteractionType.SessionEnd, session_time: 2, is_session_end: true, }), @@ -276,6 +286,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, entry_point: 'background', is_session_end: false, }), @@ -363,6 +374,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, session_time: 0, entry_point: 'homepage_trending', is_session_end: false, @@ -383,6 +395,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionEnd, session_time: 5, is_session_end: true, }), @@ -431,4 +444,309 @@ describe('TrendingFeedSessionManager', () => { expect(sessionManager.isFromTrending).toBe(true); }); }); + + describe('trackTokenClick', () => { + const mockTokenClickProperties: TokenClickProperties = { + token_symbol: 'ETH', + token_address: '0x0000000000000000000000000000000000000000', + token_name: 'Ethereum', + chain_id: '0x1', + position: 0, + price_usd: 2500.5, + price_change_pct: 5.25, + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'all', + is_search_result: false, + }; + + it('tracks token click event with correct properties when session is active', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackTokenClick(mockTokenClickProperties); + + expect(MetricsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.TokenClick, + ...mockTokenClickProperties, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track token click when no session is active', () => { + mockTrackEvent.mockClear(); + + sessionManager.trackTokenClick(mockTokenClickProperties); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Cannot track token_click - no active session', + ); + }); + + it('tracks token click with search result flag', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const searchResultProperties: TokenClickProperties = { + ...mockTokenClickProperties, + is_search_result: true, + }; + + sessionManager.trackTokenClick(searchResultProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.TokenClick, + is_search_result: true, + }), + ); + }); + + it('tracks token click with different positions', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const positionProperties: TokenClickProperties = { + ...mockTokenClickProperties, + position: 5, + }; + + sessionManager.trackTokenClick(positionProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.TokenClick, + position: 5, + }), + ); + }); + + it('logs token click details to DevLogger', () => { + sessionManager.startSession('trending_feed'); + (DevLogger.log as jest.Mock).mockClear(); + + sessionManager.trackTokenClick(mockTokenClickProperties); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Token click tracked', + expect.objectContaining({ + sessionId: 'mock-session-id', + token_symbol: 'ETH', + position: 0, + }), + ); + }); + }); + + describe('trackSearch', () => { + const mockSearchProperties: SearchProperties = { + search_query: 'ethereum', + results_count: 5, + has_results: true, + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'all', + }; + + it('tracks search event with correct properties when session is active', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackSearch(mockSearchProperties); + + expect(MetricsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.Search, + ...mockSearchProperties, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track search when no session is active', () => { + mockTrackEvent.mockClear(); + + sessionManager.trackSearch(mockSearchProperties); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Cannot track search - no active session', + ); + }); + + it('tracks search with zero results', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const noResultsProperties: SearchProperties = { + ...mockSearchProperties, + results_count: 0, + has_results: false, + }; + + sessionManager.trackSearch(noResultsProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.Search, + results_count: 0, + has_results: false, + }), + ); + }); + + it('logs search details to DevLogger', () => { + sessionManager.startSession('trending_feed'); + (DevLogger.log as jest.Mock).mockClear(); + + sessionManager.trackSearch(mockSearchProperties); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Search tracked', + expect.objectContaining({ + sessionId: 'mock-session-id', + search_query: 'ethereum', + results_count: 5, + }), + ); + }); + }); + + describe('trackFilterChange', () => { + const mockFilterChangeProperties: FilterChangeProperties = { + filter_type: 'time', + previous_value: '24h', + new_value: '6h', + time_filter: '6h', + sort_option: 'price_change', + network_filter: 'all', + }; + + it('tracks filter change event with correct properties when session is active', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(MetricsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.FilterChange, + ...mockFilterChangeProperties, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track filter change when no session is active', () => { + mockTrackEvent.mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Cannot track filter_change - no active session', + ); + }); + + it('tracks time filter changes', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.FilterChange, + filter_type: 'time', + previous_value: '24h', + new_value: '6h', + }), + ); + }); + + it('tracks sort filter changes', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const sortFilterChange: FilterChangeProperties = { + filter_type: 'sort', + previous_value: 'price_change', + new_value: 'volume', + time_filter: '24h', + sort_option: 'volume', + network_filter: 'all', + }; + + sessionManager.trackFilterChange(sortFilterChange); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.FilterChange, + filter_type: 'sort', + previous_value: 'price_change', + new_value: 'volume', + }), + ); + }); + + it('tracks network filter changes', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const networkFilterChange: FilterChangeProperties = { + filter_type: 'network', + previous_value: 'all', + new_value: 'eip155:1', + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'eip155:1', + }; + + sessionManager.trackFilterChange(networkFilterChange); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.FilterChange, + filter_type: 'network', + previous_value: 'all', + new_value: 'eip155:1', + }), + ); + }); + + it('logs filter change details to DevLogger', () => { + sessionManager.startSession('trending_feed'); + (DevLogger.log as jest.Mock).mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Filter change tracked', + expect.objectContaining({ + sessionId: 'mock-session-id', + filter_type: 'time', + previous_value: '24h', + new_value: '6h', + }), + ); + }); + }); }); diff --git a/app/components/UI/Trending/services/TrendingFeedSessionManager.ts b/app/components/UI/Trending/services/TrendingFeedSessionManager.ts index 37fccca647f..ab6755cc896 100644 --- a/app/components/UI/Trending/services/TrendingFeedSessionManager.ts +++ b/app/components/UI/Trending/services/TrendingFeedSessionManager.ts @@ -4,6 +4,86 @@ import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import { MetaMetrics, MetaMetricsEvents } from '../../../../core/Analytics'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +/** + * Interaction types for Trending Feed analytics events + */ +export enum TrendingInteractionType { + SessionStart = 'session_start', + SessionEnd = 'session_end', + TokenClick = 'token_click', + Search = 'search', + FilterChange = 'filter_change', +} + +/** + * Properties for token click tracking + */ +export interface TokenClickProperties { + /** Token symbol clicked */ + token_symbol: string; + /** Token contract address */ + token_address: string; + /** Token display name */ + token_name: string; + /** Network chain ID (hex format) */ + chain_id: string; + /** 0-indexed position in list */ + position: number; + /** Token price at click time (USD) */ + price_usd: number; + /** Price change percentage */ + price_change_pct: number; + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + time_filter: string; + /** Active sort option (e.g., 'price_change', 'volume', 'market_cap') */ + sort_option: string; + /** Active network filter (e.g., 'all' or specific chain ID) */ + network_filter: string; + /** Was this from search results? */ + is_search_result: boolean; +} + +/** + * Properties for search tracking + */ +export interface SearchProperties { + /** The search query entered */ + search_query: string; + /** Number of results returned */ + results_count: number; + /** Whether search returned any results */ + has_results: boolean; + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + time_filter: string; + /** Active sort option (e.g., 'price_change', 'volume', 'market_cap') */ + sort_option: string; + /** Active network filter (e.g., 'all' or specific chain ID) */ + network_filter: string; +} + +/** + * Filter types for filter change tracking + */ +type FilterType = 'time' | 'sort' | 'network'; + +/** + * Properties for filter change tracking + */ +export interface FilterChangeProperties { + /** Type of filter that changed */ + filter_type: FilterType; + /** Previous filter value */ + previous_value: string; + /** New filter value */ + new_value: string; + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + time_filter: string; + /** Active sort option (e.g., 'price_change', 'volume', 'market_cap') */ + sort_option: string; + /** Active network filter (e.g., 'all' or specific chain ID) */ + network_filter: string; +} + /** * Singleton manager for Trending Feed session tracking * Handles session lifecycle, timing, and analytics events @@ -159,13 +239,17 @@ class TrendingFeedSessionManager { } /** - * Track feed viewed event + * Track feed viewed event with interaction type */ - private trackEvent(isSessionEnd: boolean = false): void { + private trackEvent( + interactionType: TrendingInteractionType, + isSessionEnd: boolean = false, + ): void { if (!this.sessionId) return; const analyticsProperties = { session_id: this.sessionId, + interaction_type: interactionType, session_time: this.getElapsedTime(), is_session_end: isSessionEnd, entry_point: this.entryPoint, @@ -180,6 +264,100 @@ class TrendingFeedSessionManager { ); } + /** + * Private helper to track interaction events with shared logic + * Encapsulates session validation, analytics properties building, logging, and event tracking + * + * @param interactionType - The type of interaction being tracked + * @param properties - Additional properties to include in the event + * @param logMessage - Message for DevLogger + * @param logContext - Additional context for DevLogger (merged with sessionId) + */ + private trackInteraction( + interactionType: TrendingInteractionType, + properties: + | TokenClickProperties + | SearchProperties + | FilterChangeProperties, + logMessage: string, + logContext: Record, + ): void { + if (!this.sessionId) { + DevLogger.log( + `TrendingFeedSessionManager: Cannot track ${interactionType} - no active session`, + ); + return; + } + + const analyticsProperties = { + session_id: this.sessionId, + interaction_type: interactionType, + ...properties, + }; + + DevLogger.log(logMessage, { + sessionId: this.sessionId, + ...logContext, + }); + + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ) + .addProperties(analyticsProperties) + .build(), + ); + } + + /** + * Track token click event + * @param properties - Token click properties including position, filters, etc. + */ + public trackTokenClick(properties: TokenClickProperties): void { + this.trackInteraction( + TrendingInteractionType.TokenClick, + properties, + 'TrendingFeedSessionManager: Token click tracked', + { + token_symbol: properties.token_symbol, + position: properties.position, + }, + ); + } + + /** + * Track search event + * @param properties - Search properties including query, results count, etc. + */ + public trackSearch(properties: SearchProperties): void { + this.trackInteraction( + TrendingInteractionType.Search, + properties, + 'TrendingFeedSessionManager: Search tracked', + { + search_query: properties.search_query, + results_count: properties.results_count, + }, + ); + } + + /** + * Track filter change event + * @param properties - Filter change properties including type, previous/new values + */ + public trackFilterChange(properties: FilterChangeProperties): void { + this.trackInteraction( + TrendingInteractionType.FilterChange, + properties, + 'TrendingFeedSessionManager: Filter change tracked', + { + filter_type: properties.filter_type, + previous_value: properties.previous_value, + new_value: properties.new_value, + }, + ); + } + /** * Start a new session * @param entryPoint - How the user entered the feed @@ -213,8 +391,8 @@ class TrendingFeedSessionManager { entryPoint, }); - // Track initial event - this.trackEvent(false); + // Track initial event with session_start interaction type + this.trackEvent(TrendingInteractionType.SessionStart, false); } /** @@ -234,8 +412,8 @@ class TrendingFeedSessionManager { finalTime: this.getElapsedTime(), }); - // Send final event - this.trackEvent(true); + // Send final event with session_end interaction type + this.trackEvent(TrendingInteractionType.SessionEnd, true); // Mark as ended (but keep state for debugging until next startSession) this.sessionEnded = true; diff --git a/app/components/Views/ActivityView/ActivitiesView.testIds.ts b/app/components/Views/ActivityView/ActivitiesView.testIds.ts index 764ef14bc21..68e8f9fabad 100644 --- a/app/components/Views/ActivityView/ActivitiesView.testIds.ts +++ b/app/components/Views/ActivityView/ActivitiesView.testIds.ts @@ -27,6 +27,7 @@ export const ActivitiesViewSelectorsText = { UNSTAKE: enContent.transactions.tx_review_staking_unstake, STAKING_CLAIM: enContent.transactions.tx_review_staking_claim, PREDICT_DEPOSIT: enContent.transactions.tx_review_predict_deposit, + MUSD_CONVERSION: enContent.transactions.tx_review_musd_conversion, }; export const sentMessageTokenIDs = { diff --git a/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx b/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx index 5963cd92047..b1518102ad6 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx @@ -48,7 +48,7 @@ export const AccountInfo = ({ account }: AccountInfoProps) => { {formatAddress(formattedAddress, 'short')} - + ); diff --git a/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap index f1621253752..9d6bbe30ec8 100644 --- a/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap @@ -1,32 +1,1018 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`GeneralSettings should render correctly 1`] = ` - - - + + + + + + + + + + + + + + General + + + + + + + + + + + + + Currency conversion + + + Display fiat values in using a specific currency throughout the application. + + + + + + + + + USD - United States Dollar + + +  + + + + + + + + + + + + Primary currency + + + Select Native to prioritize displaying values in the native currency of the chain (e.g. ETH). Select Fiat to prioritize displaying values in your selected fiat currency. + + + + + + + + + Native + + + + + + + + + + Fiat + + + + + + + + + + Current language + + + Translate the application to a different supported language. + + + + + + + + + English + + +  + + + + + + + + + + + + Search engine + + + Change the default search engine used when entering search terms in the URL bar. + + + + + + + + + Google + + +  + + + + + + + + + + + + + Hide tokens without balance + + + + + + + Prevents tokens with no balance from displaying in your token listing. + + + + + Account icon + + + Choose from three different styles of unique icons that can help you identify accounts at a glance. + + + + + + + Polycons + + + + + + Jazzicons + + + + + + Blockies + + + + + + + + + `; diff --git a/app/components/Views/Settings/GeneralSettings/index.js b/app/components/Views/Settings/GeneralSettings/index.js index 647549c92a5..55edf7e3df7 100644 --- a/app/components/Views/Settings/GeneralSettings/index.js +++ b/app/components/Views/Settings/GeneralSettings/index.js @@ -7,6 +7,7 @@ import { View, TouchableOpacity, } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { connect } from 'react-redux'; import Engine from '../../../../core/Engine'; @@ -17,7 +18,7 @@ import I18n, { } from '../../../../../locales/i18n'; import SelectComponent from '../../../UI/SelectComponent'; import infuraCurrencies from '../../../../util/infura-conversion.json'; -import { getNavigationOptionsTitle } from '../../../UI/Navbar'; +import HeaderCenter from '../../../../component-library/components-temp/HeaderCenter'; import { setSearchEngine, setPrimaryCurrency, @@ -83,8 +84,10 @@ const createStyles = (colors) => wrapper: { backgroundColor: colors.background.default, flex: 1, + }, + content: { padding: 16, - zIndex: 99999999999999, + flex: 1, }, titleContainer: { flexDirection: 'row', @@ -242,21 +245,7 @@ class Settings extends PureComponent { this.props.setHideZeroBalanceTokens(toggleHideZeroBalanceTokens); }; - updateNavBar = () => { - const { navigation } = this.props; - const colors = this.context.colors || mockTheme.colors; - navigation.setOptions( - getNavigationOptionsTitle( - strings('app_settings.general_title'), - navigation, - false, - colors, - ), - ); - }; - componentDidMount = () => { - this.updateNavBar(); const languages = getLanguages(); this.setState({ languages }); this.languageOptions = Object.keys(languages).map((key) => ({ @@ -282,10 +271,6 @@ class Settings extends PureComponent { ]; }; - componentDidUpdate = () => { - this.updateNavBar(); - }; - // TODO - Reintroduce once we enable manual theme settings // goToThemeSettings = () => { // const { navigation } = this.props; @@ -322,228 +307,236 @@ class Settings extends PureComponent { setAvatarAccountType, selectedAddress, hideZeroBalanceTokens, + navigation, } = this.props; const themeTokens = this.context || mockTheme; const { colors } = themeTokens; const styles = createStyles(colors); return ( - - - - - {strings('app_settings.conversion_title')} - - - {strings('app_settings.conversion_desc')} - - - - - - - - - - {strings('app_settings.primary_currency_title')} - - - {strings('app_settings.primary_currency_desc')} - - {this.primaryCurrencyOptions && ( - - - - )} - - - - {strings('app_settings.current_language')} - - - {strings('app_settings.language_desc')} - - {this.languageOptions && ( + + navigation.goBack()} + includesTopInset + /> + + + + + {strings('app_settings.conversion_title')} + + + {strings('app_settings.conversion_desc')} + - )} - - - - {strings('app_settings.search_engine')} - - - {strings('app_settings.engine_desc')} - - {this.searchEngineOptions && ( - - - + + + {strings('app_settings.primary_currency_title')} + + + {strings('app_settings.primary_currency_desc')} + + {this.primaryCurrencyOptions && ( + + - - )} - - - - - {strings('app_settings.hide_zero_balance_tokens_title')} - - - - + )} - - {strings('app_settings.hide_zero_balance_tokens_desc')} - - - - - {strings('app_settings.accounts_identicon_title')} - - - {strings('app_settings.accounts_identicon_desc')} - - - - - setAvatarAccountType(AvatarAccountType.Maskicon) - } - style={styles.identicon_row} - > - - + + {strings('app_settings.current_language')} + + + {strings('app_settings.language_desc')} + + {this.languageOptions && ( + + + - Polycons - - - setAvatarAccountType(AvatarAccountType.JazzIcon) - } - style={styles.identicon_row} - > - - + )} + + + + {strings('app_settings.search_engine')} + + + {strings('app_settings.engine_desc')} + + {this.searchEngineOptions && ( + + + - - {strings('app_settings.jazzicons')} - - - - setAvatarAccountType(AvatarAccountType.Blockies) - } - style={styles.identicon_row} - > - + )} + + + + + {strings('app_settings.hide_zero_balance_tokens_title')} + + + + + + + {strings('app_settings.hide_zero_balance_tokens_desc')} + + + + + {strings('app_settings.accounts_identicon_title')} + + + {strings('app_settings.accounts_identicon_desc')} + + + + + setAvatarAccountType(AvatarAccountType.Maskicon) + } + style={styles.identicon_row} > - - - - {strings('app_settings.blockies')} - - + + + + Polycons + + + setAvatarAccountType(AvatarAccountType.JazzIcon) + } + style={styles.identicon_row} + > + + + + + {strings('app_settings.jazzicons')} + + + + setAvatarAccountType(AvatarAccountType.Blockies) + } + style={styles.identicon_row} + > + + + + + {strings('app_settings.blockies')} + + + + {/* {this.renderThemeSettingsSection()} */} - {/* {this.renderThemeSettingsSection()} */} - - + + ); } } diff --git a/app/components/Views/Settings/GeneralSettings/index.test.tsx b/app/components/Views/Settings/GeneralSettings/index.test.tsx index 7efbe1fe45c..bc4c92be3ce 100644 --- a/app/components/Views/Settings/GeneralSettings/index.test.tsx +++ b/app/components/Views/Settings/GeneralSettings/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, fireEvent } from '@testing-library/react-native'; import GeneralSettings, { updateUserTraitsWithCurrentCurrency, updateUserTraitsWithCurrencyType, @@ -11,9 +11,37 @@ import { backgroundState } from '../../../../util/test/initial-root-state'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { UserProfileProperty } from '../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; -import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; +import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount'; +import { ThemeContext, mockTheme } from '../../../../util/theme'; jest.mock('../../../../core/Analytics'); +jest.mock('../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + isEnabled: jest.fn().mockReturnValue(true), + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createEventBuilder: jest.fn(), + trackEvent: jest.fn(), + trackAnonymousEvent: jest.fn(), + getMetaMetricsId: jest.fn(), + }), + withMetricsAwareness: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Component: React.ComponentType) => + (props: Record) => , +})); +jest.mock( + '../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount', + () => ({ + __esModule: true, + default: () => null, + AvatarAccountType: { + JazzIcon: 'JazzIcon', + Blockies: 'Blockies', + Maskicon: 'Maskicon', + }, + }), +); const mockStore = configureMockStore(); const initialState = { @@ -31,14 +59,41 @@ const initialState = { }; const store = mockStore(initialState); +const mockNavigation = { + goBack: jest.fn(), + navigate: jest.fn(), +}; + +const renderComponent = () => + render( + + + + + , + ); + describe('GeneralSettings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render correctly', () => { - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); + const { toJSON } = renderComponent(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders header with correct title', () => { + const { getByText } = renderComponent(); + expect(getByText('General')).toBeTruthy(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + const { getByTestId } = renderComponent(); + const backButton = getByTestId('button-icon'); + fireEvent.press(backButton); + + expect(mockNavigation.goBack).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index bfeba8be636..087d4289c64 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -557,4 +557,244 @@ describe('TrendingTokensFullView', () => { expect(mockFetchTrendingTokens).toHaveBeenCalledTimes(1); }); + + describe('trendingTokens sorting logic', () => { + it('returns search results in relevance order when search query is present', async () => { + const mockTokens = [ + createMockToken({ + name: 'Ethereum', + symbol: 'ETH', + assetId: 'eip155:1/erc20:0x111', + aggregatedUsdVolume: 1000, + }), + createMockToken({ + name: 'Bitcoin', + symbol: 'BTC', + assetId: 'eip155:1/erc20:0x222', + aggregatedUsdVolume: 5000, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open search and type a query + const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + fireEvent.press(searchToggle); + + const searchInput = getByTestId('trending-tokens-header-search-bar'); + fireEvent.changeText(searchInput, 'eth'); + + // Tokens should be displayed in original order (relevance), not sorted + // Even if we select a sort option, search results should maintain relevance order + expect(getByText('Ethereum')).toBeOnTheScreen(); + expect(getByText('Bitcoin')).toBeOnTheScreen(); + }); + + it('returns results without sorting when no price change option is selected', () => { + const mockTokens = [ + createMockToken({ + name: 'Token A', + assetId: 'eip155:1/erc20:0xaaa', + aggregatedUsdVolume: 100, + }), + createMockToken({ + name: 'Token B', + assetId: 'eip155:1/erc20:0xbbb', + aggregatedUsdVolume: 500, + }), + createMockToken({ + name: 'Token C', + assetId: 'eip155:1/erc20:0xccc', + aggregatedUsdVolume: 300, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // No price change option selected by default, tokens should be in original order + expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); + expect(getByText('Token A')).toBeOnTheScreen(); + expect(getByText('Token B')).toBeOnTheScreen(); + expect(getByText('Token C')).toBeOnTheScreen(); + }); + + it('returns empty array when search results are empty', () => { + mockUseTrendingSearch.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Should show empty state, not the tokens list + expect(getByTestId('empty-error-trending-state')).toBeOnTheScreen(); + }); + + it('applies sorting when price change option is selected and no search query', async () => { + const mockTokens = [ + createMockToken({ + name: 'Low Volume Token', + assetId: 'eip155:1/erc20:0x111', + aggregatedUsdVolume: 100, + }), + createMockToken({ + name: 'High Volume Token', + assetId: 'eip155:1/erc20:0x222', + aggregatedUsdVolume: 10000, + }), + createMockToken({ + name: 'Medium Volume Token', + assetId: 'eip155:1/erc20:0x333', + aggregatedUsdVolume: 1000, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open price change bottom sheet + const priceChangeButton = getByTestId('price-change-button'); + fireEvent.press(priceChangeButton); + + // Select Volume option + const volumeOption = getByTestId('price-change-select-volume'); + await act(async () => { + fireEvent(volumeOption, 'touchEnd'); + }); + + // The tokens should now be sorted by volume + // Note: The actual sorting is done by sortTrendingTokens utility + expect(getByText('Low Volume Token')).toBeOnTheScreen(); + expect(getByText('High Volume Token')).toBeOnTheScreen(); + expect(getByText('Medium Volume Token')).toBeOnTheScreen(); + }); + + it('does not apply sorting when search query is present even with price change option', async () => { + const mockTokens = [ + createMockToken({ + name: 'Ethereum Classic', + symbol: 'ETC', + assetId: 'eip155:1/erc20:0x111', + aggregatedUsdVolume: 100, + }), + createMockToken({ + name: 'Ethereum', + symbol: 'ETH', + assetId: 'eip155:1/erc20:0x222', + aggregatedUsdVolume: 50000, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // First select a price change option + const priceChangeButton = getByTestId('price-change-button'); + fireEvent.press(priceChangeButton); + + const volumeOption = getByTestId('price-change-select-volume'); + await act(async () => { + fireEvent(volumeOption, 'touchEnd'); + }); + + // Now open search and type a query + const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + fireEvent.press(searchToggle); + + const searchInput = getByTestId('trending-tokens-header-search-bar'); + fireEvent.changeText(searchInput, 'eth'); + + // Even with volume sort selected, search results should maintain relevance order + // (Ethereum Classic first because that's the order returned by mock) + expect(getByText('Ethereum Classic')).toBeOnTheScreen(); + expect(getByText('Ethereum')).toBeOnTheScreen(); + }); + + it('clears search and shows sorted results when search is dismissed', async () => { + const mockTokens = [ + createMockToken({ + name: 'Token X', + assetId: 'eip155:1/erc20:0xaaa', + }), + createMockToken({ + name: 'Token Y', + assetId: 'eip155:1/erc20:0xbbb', + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText, queryByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open search + const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + fireEvent.press(searchToggle); + + // Type search query + const searchInput = getByTestId('trending-tokens-header-search-bar'); + fireEvent.changeText(searchInput, 'token'); + + // Verify search is active + expect(searchInput.props.value).toBe('token'); + + // Clear search by changing text to empty + fireEvent.changeText(searchInput, ''); + + // Results should still be displayed + expect(getByText('Token X')).toBeOnTheScreen(); + expect(getByText('Token Y')).toBeOnTheScreen(); + expect(queryByTestId('empty-search-result-state')).not.toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index 1396185d0d8..746e76cbe4d 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -23,7 +23,9 @@ import Icon, { } from '../../../../component-library/components/Icons/Icon'; import { strings } from '../../../../../locales/i18n'; import { TrendingListHeader } from '../../../UI/Trending/components/TrendingListHeader'; -import TrendingTokensList from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; +import TrendingTokensList, { + TrendingFilterContext, +} from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { SortTrendingBy, @@ -44,6 +46,8 @@ import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingToken import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; import EmptyErrorTrendingState from '../../TrendingView/components/EmptyErrorState/EmptyErrorTrendingState'; import EmptySearchResultState from '../../TrendingView/components/EmptyErrorState/EmptySearchResultState'; +import TrendingFeedSessionManager from '../../../UI/Trending/services/TrendingFeedSessionManager'; +import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; interface TrendingTokensNavigationParamList { [key: string]: undefined | object; @@ -135,6 +139,7 @@ const TrendingTokensFullView = () => { const theme = useAppThemeFromContext(); const styles = useMemo(() => createStyles(theme), [theme]); const insets = useSafeAreaInsets(); + const sessionManager = TrendingFeedSessionManager.getInstance(); const [sortBy, setSortBy] = useState(undefined); const [selectedTimeOption, setSelectedTimeOption] = useState( TimeOption.TwentyFourHours, @@ -253,21 +258,102 @@ const TrendingTokensFullView = () => { selectedTimeOption, ]); + // Compute filter context for analytics tracking + const filterContext: TrendingFilterContext = useMemo( + () => ({ + timeFilter: selectedTimeOption, + sortOption: selectedPriceChangeOption, + networkFilter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + isSearchResult: Boolean(searchQuery?.trim()), + }), + [ + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + searchQuery, + ], + ); + + // Track search events with debounce + useSearchTracking({ + searchQuery, + resultsCount: trendingTokens.length, + isLoading, + timeFilter: selectedTimeOption, + sortOption: selectedPriceChangeOption || PriceChangeOption.PriceChange, + networkFilter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + const handlePriceChangeSelect = useCallback( (option: PriceChangeOption, sortDirection: SortDirection) => { + const previousValue = + selectedPriceChangeOption || PriceChangeOption.PriceChange; setSelectedPriceChangeOption(option); setPriceChangeSortDirection(sortDirection); + + // Track filter change if value actually changed + if (option !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'sort', + previous_value: previousValue, + new_value: option, + time_filter: selectedTimeOption, + sort_option: option, + network_filter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + } }, - [], + [ + selectedPriceChangeOption, + selectedTimeOption, + selectedNetwork, + sessionManager, + ], ); const handlePriceChangePress = useCallback(() => { setShowPriceChangeBottomSheet(true); }, []); - const handleNetworkSelect = useCallback((chainIds: CaipChainId[] | null) => { - setSelectedNetwork(chainIds); - }, []); + const handleNetworkSelect = useCallback( + (chainIds: CaipChainId[] | null) => { + const previousValue = + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all'; + const newValue = chainIds && chainIds.length > 0 ? chainIds[0] : 'all'; + + setSelectedNetwork(chainIds); + + // Track filter change if value actually changed + if (newValue !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'network', + previous_value: previousValue, + new_value: newValue, + time_filter: selectedTimeOption, + sort_option: + selectedPriceChangeOption || PriceChangeOption.PriceChange, + network_filter: newValue, + }); + } + }, + [ + selectedNetwork, + selectedTimeOption, + selectedPriceChangeOption, + sessionManager, + ], + ); const handleAllNetworksPress = useCallback(() => { setShowNetworkBottomSheet(true); @@ -275,10 +361,32 @@ const TrendingTokensFullView = () => { const handleTimeSelect = useCallback( (selectedSortBy: SortTrendingBy, timeOption: TimeOption) => { + const previousValue = selectedTimeOption; setSortBy(selectedSortBy); setSelectedTimeOption(timeOption); + + // Track filter change if value actually changed + if (timeOption !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'time', + previous_value: previousValue, + new_value: timeOption, + time_filter: timeOption, + sort_option: + selectedPriceChangeOption || PriceChangeOption.PriceChange, + network_filter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + } }, - [], + [ + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + sessionManager, + ], ); const handle24hPress = useCallback(() => { @@ -424,6 +532,7 @@ const TrendingTokensFullView = () => { = ({ } }, [searchQuery, flatData.length]); + // Track search events for tokens section + useSearchTracking({ + searchQuery, + resultsCount: data.tokens?.length || 0, + isLoading: isLoading.tokens, + timeFilter: TimeOption.TwentyFourHours, + sortOption: 'relevance', + networkFilter: 'all', + }); + const renderFooter = useMemo(() => { if (searchQuery.length === 0) return null; @@ -118,7 +130,7 @@ const ExploreSearchResults: React.FC = ({ }, [searchQuery]); const renderFlatItem: ListRenderItem = useCallback( - ({ item }) => { + ({ item, index }) => { if (item.type === 'header') { return renderSectionHeader(item.data); } @@ -137,13 +149,20 @@ const ExploreSearchResults: React.FC = ({ return ( ); } // Cast navigation to 'never' to satisfy different navigation param list types - return ; + return ( + + ); }, [navigation, renderSectionHeader], ); diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx index 560304917a9..d90737b0188 100644 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx +++ b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx @@ -36,7 +36,9 @@ const SectionCard: React.FC = ({ const section = SECTIONS_CONFIG[sectionId]; const renderFlatItem: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => ( + + ), [navigation, section], ); diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx index e16aa318d44..a2335019260 100644 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx +++ b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx @@ -49,7 +49,11 @@ const SectionCarrousel: React.FC = ({ {isLoading ? ( ) : ( - + )} diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index 265d5c60d4f..475093e1a40 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -22,6 +22,11 @@ import SiteRowItemWrapper from '../../UI/Sites/components/SiteRowItemWrapper/Sit import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData'; import { useTrendingSearch } from '../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import { + TimeOption, + PriceChangeOption, +} from '../../UI/Trending/components/TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; import { filterMarketsByQuery } from '../../UI/Perps/utils/marketUtils'; import PredictMarketRowItem from '../../UI/Predict/components/PredictMarketRowItem'; import SectionCard from './components/Sections/SectionTypes/SectionCard'; @@ -41,10 +46,12 @@ interface SectionConfig { viewAllAction: (navigation: NavigationProp) => void; RowItem: React.ComponentType<{ item: unknown; + index: number; navigation: NavigationProp; }>; OverrideRowItemSearch?: React.ComponentType<{ item: unknown; + index?: number; navigation: NavigationProp; }>; Skeleton: React.ComponentType; @@ -123,6 +130,28 @@ const PREDICTIONS_FUSE_OPTIONS: FuseOptions = { * - Section headers with "View All" navigation */ +/** + * Default filter context for tokens in the Trending View home section. + * Used for analytics tracking of token clicks from the home page. + */ +const DEFAULT_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, +}; + +/** + * Filter context for tokens in search results on the Explore page. + * Used for analytics tracking of token clicks from search results. + */ +const SEARCH_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: true, +}; + export const SECTIONS_CONFIG: Record = { tokens: { id: 'tokens', @@ -131,8 +160,19 @@ export const SECTIONS_CONFIG: Record = { viewAllAction: (navigation) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW); }, - RowItem: ({ item }) => ( - + RowItem: ({ item, index }) => ( + + ), + OverrideRowItemSearch: ({ item, index }) => ( + ), Skeleton: TrendingTokensSkeleton, Section: SectionCard, @@ -167,7 +207,7 @@ export const SECTIONS_CONFIG: Record = { }, }); }, - RowItem: ({ item, navigation }) => ( + RowItem: ({ item, index: _index, navigation }) => ( { @@ -217,7 +257,7 @@ export const SECTIONS_CONFIG: Record = { screen: Routes.PREDICT.MARKET_LIST, }); }, - RowItem: ({ item }) => ( + RowItem: ({ item, index: _index }) => ( = { viewAllAction: (navigation) => { navigation.navigate(Routes.SITES_FULL_VIEW); }, - RowItem: ({ item, navigation }) => ( + RowItem: ({ item, index: _index, navigation }) => ( ), Skeleton: SiteSkeleton, diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index b9d16cc3764..ff4c7c6e231 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -11,19 +11,13 @@ import { AlertKeys } from '../../constants/alerts'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { Severity } from '../../types/alerts'; import { useConfirmActions } from '../useConfirmActions'; -import { useTransactionPayToken } from '../pay/useTransactionPayToken'; -import { noop } from 'lodash'; import { useConfirmationContext } from '../../context/confirmation-context'; import { useRampNavigation } from '../../../../UI/Ramp/hooks/useRampNavigation'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; -import { useTransactionPayRequiredTokens } from '../pay/useTransactionPayData'; -import { - TransactionPayRequiredToken, - TransactionPaymentToken, -} from '@metamask/transaction-pay-controller'; -import { Hex } from '@metamask/utils'; +import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; +import { Hex } from '@metamask/utils'; jest.mock('../../../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../../../util/navigation/navUtils'), @@ -53,7 +47,6 @@ jest.mock('../../../../../selectors/preferencesController'); jest.mock('../useHasInsufficientBalance'); jest.mock('../useConfirmActions'); jest.mock('../transactions/useTransactionMetadataRequest'); -jest.mock('../pay/useTransactionPayToken'); jest.mock('../useAccountNativeBalance'); jest.mock('../../../../../../locales/i18n'); jest.mock('../../../../../selectors/networkController'); @@ -62,8 +55,7 @@ jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({ useRampNavigation: jest.fn(), })); jest.mock('../gas/useIsGaslessSupported'); -jest.mock('../pay/useTransactionPayData'); -jest.mock('../pay/useTransactionPayData'); +jest.mock('../pay/useTransactionPayHasSourceAmount'); describe('useInsufficientBalanceAlert', () => { const mockUseTransactionMetadataRequest = jest.mocked( @@ -74,15 +66,13 @@ describe('useInsufficientBalanceAlert', () => { const mockSelectUseTransactionSimulations = jest.mocked( selectUseTransactionSimulations, ); - const mockUseTransactionPayToken = jest.mocked(useTransactionPayToken); const mockUseConfirmationContext = jest.mocked(useConfirmationContext); const mockUseRampNavigation = jest.mocked(useRampNavigation); const mockGoToBuy = jest.fn(); const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported); - const useTransactionPayRequiredTokensMock = jest.mocked( - useTransactionPayRequiredTokens, + const useTransactionPayHasSourceAmountMock = jest.mocked( + useTransactionPayHasSourceAmount, ); - const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const useHasInsufficientBalanceMock = jest.mocked(useHasInsufficientBalance); const mockChainId = '0x1' as Hex; @@ -111,10 +101,6 @@ describe('useInsufficientBalanceAlert', () => { } as unknown as ReturnType); mockUseTransactionMetadataRequest.mockReturnValue(mockTransaction); mockSelectUseTransactionSimulations.mockReturnValue(false); - mockUseTransactionPayToken.mockReturnValue({ - payToken: undefined, - setPayToken: noop as never, - }); (strings as jest.Mock).mockImplementation((key, params) => { if (key === 'alert_system.insufficient_balance.buy_action') { @@ -142,12 +128,7 @@ describe('useInsufficientBalanceAlert', () => { goToDeposit: jest.fn(), }); - useTransactionPayRequiredTokensMock.mockReturnValue([]); - - useTransactionPayTokenMock.mockReturnValue({ - payToken: undefined, - setPayToken: jest.fn(), - }); + useTransactionPayHasSourceAmountMock.mockReturnValue(false); useHasInsufficientBalanceMock.mockReturnValue({ hasInsufficientBalance: true, @@ -291,38 +272,12 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); - it('returns no alert if pay token', () => { - useTransactionPayTokenMock.mockReturnValue({ - payToken: { - address: '0x123' as Hex, - } as TransactionPaymentToken, - setPayToken: jest.fn(), - }); + it('returns empty array when using pay source amounts', () => { + useTransactionPayHasSourceAmountMock.mockReturnValue(true); const { result } = renderHook(() => useInsufficientBalanceAlert()); - expect(result.current).toStrictEqual([]); - }); - - it('returns alert if pay token matches required token', () => { - useTransactionPayTokenMock.mockReturnValue({ - payToken: { - address: '0x123' as Hex, - chainId: mockChainId, - } as TransactionPaymentToken, - setPayToken: jest.fn(), - }); - - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123' as Hex, - chainId: mockChainId, - } as TransactionPayRequiredToken, - ]); - - const { result } = renderHook(() => useInsufficientBalanceAlert()); - - expect(result.current).toHaveLength(1); + expect(result.current).toEqual([]); }); describe('when ignoreGasFeeToken is true', () => { diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index f8cda1d2504..fb974dc5779 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -11,8 +11,7 @@ import { useConfirmationContext } from '../../context/confirmation-context'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; import { TransactionType } from '@metamask/transaction-controller'; import { hasTransactionType } from '../../utils/transaction'; -import { useTransactionPayToken } from '../pay/useTransactionPayToken'; -import { useTransactionPayRequiredTokens } from '../pay/useTransactionPayData'; +import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; @@ -29,28 +28,13 @@ export const useInsufficientBalanceAlert = ({ const { onReject } = useConfirmActions(); const { isSupported: isGaslessSupported, pending: isGaslessCheckPending } = useIsGaslessSupported(); - const { payToken } = useTransactionPayToken(); - const requiredTokens = useTransactionPayRequiredTokens(); + const isUsingPay = useTransactionPayHasSourceAmount(); const isSimulationEnabled = useSelector(selectUseTransactionSimulations); const { hasInsufficientBalance, nativeCurrency } = useHasInsufficientBalance(); - const primaryRequiredToken = (requiredTokens ?? []).find( - (token) => !token.skipIfBalance, - ); - - const isPayTokenTarget = - payToken && - payToken.chainId === primaryRequiredToken?.chainId && - payToken.address.toLowerCase() === - primaryRequiredToken?.address.toLowerCase(); - return useMemo(() => { - if ( - !transactionMetadata || - isTransactionValueUpdating || - (payToken && !isPayTokenTarget) - ) { + if (!transactionMetadata || isTransactionValueUpdating || isUsingPay) { return []; } @@ -118,12 +102,11 @@ export const useInsufficientBalanceAlert = ({ }, [ transactionMetadata, isTransactionValueUpdating, - payToken, - isPayTokenTarget, isGaslessCheckPending, isGaslessSupported, isSimulationEnabled, ignoreGasFeeToken, + isUsingPay, hasInsufficientBalance, nativeCurrency, goToBuy, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts index 9f3f536aecd..39690d200c0 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts @@ -1,5 +1,8 @@ import { getNativeTokenAddress } from '@metamask/assets-controllers'; -import { TransactionType } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { TransactionPaymentToken } from '@metamask/transaction-pay-controller'; import { Hex } from '@metamask/utils'; import { noop } from 'lodash'; @@ -12,6 +15,7 @@ import { selectTransactionPaymentTokenByTransactionId } from '../../../../../sel import { updateTransaction } from '../../../../../util/transaction-controller'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { hasTransactionType } from '../../utils/transaction'; export function useTransactionPayToken(): { isNative?: boolean; @@ -57,16 +61,17 @@ export function useTransactionPayToken(): { } // perps deposits only use relay, so doesn't need gasFeeToken update - const isPredictDepositTransaction = - transactionMeta?.type === TransactionType.predictDeposit; + const isPredictDepositTransaction = hasTransactionType(transactionMeta, [ + TransactionType.predictDeposit, + ]); - if (isPredictDepositTransaction) { + if (isPredictDepositTransaction && transactionMeta) { const isNewPayTokenRequiredToken = newPayToken.chainId === primaryRequiredToken?.chainId && newPayToken.address.toLowerCase() === primaryRequiredToken?.address.toLowerCase(); - const updatedTx = { + const updatedTx: TransactionMeta = { ...transactionMeta, selectedGasFeeToken: isNewPayTokenRequiredToken ? newPayToken.address diff --git a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts b/app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts deleted file mode 100644 index b28cb76199a..00000000000 --- a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DISCOVERY_TOKENS_LIMIT = '50'; diff --git a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts b/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts deleted file mode 100644 index 6de17a9b69b..00000000000 --- a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { act } from '@testing-library/react-hooks'; -import Engine from '../../../../core/Engine'; -import useTokenSearchDiscovery, { MAX_RESULTS } from './useTokenSearch'; -import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; - -jest.mock('../../../../core/Engine', () => ({ - context: { - TokenSearchDiscoveryController: { - searchSwappableTokens: jest.fn(), - }, - }, -})); - -const mockInitialState = { - engine: { - backgroundState: { - TokenSearchDiscoveryController: { - recentSearches: [], - lastSearchTimestamp: 0, - }, - RemoteFeatureFlagController: { - remoteFeatureFlags: { - tokenSearchDiscoveryEnabled: true, - }, - }, - }, - }, -}; - -describe('useTokenSearchDiscovery', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useFakeTimers({ legacyFakeTimers: true }); - }); - - it('updates states correctly when searching tokens', async () => { - const mockSearchQuery = 'DAI'; - const mockSearchResult = [ - { name: 'DAI', tokenAddress: '0x123', chainId: '0x1' }, - ]; - - ( - Engine.context.TokenSearchDiscoveryController - .searchSwappableTokens as jest.Mock - ).mockResolvedValueOnce(mockSearchResult); - - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - - // Initial state - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - expect(result.current.results).toEqual([]); - - // Call search - await act(async () => { - result.current.searchTokens(mockSearchQuery); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - // Final state - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - expect(result.current.results).toEqual(mockSearchResult); - expect( - Engine.context.TokenSearchDiscoveryController.searchSwappableTokens, - ).toHaveBeenCalledWith({ query: mockSearchQuery, limit: MAX_RESULTS }); - }); - - it('does not search when less than two characters are queried', async () => { - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - await act(async () => { - result.current.searchTokens('a'); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect( - Engine.context.TokenSearchDiscoveryController.searchSwappableTokens, - ).not.toHaveBeenCalled(); - }); - - it('resets the state when reset() is called', async () => { - const mockSearchResult = [ - { name: 'DAI', tokenAddress: '0x123', chainId: '0x1' }, - ]; - - ( - Engine.context.TokenSearchDiscoveryController - .searchSwappableTokens as jest.Mock - ).mockResolvedValueOnce(mockSearchResult); - - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - - await act(async () => { - result.current.searchTokens('doge'); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect(result.current.results).toEqual(mockSearchResult); - - await act(async () => { - result.current.reset(); - }); - - expect(result.current.results).toEqual([]); - }); - - it('returns error and empty results if search failed', async () => { - const mockError = new Error('Search failed'); - ( - Engine.context.TokenSearchDiscoveryController - .searchSwappableTokens as jest.Mock - ).mockRejectedValueOnce(mockError); - - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - - await act(async () => { - result.current.searchTokens('doge'); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toEqual(mockError); - expect(result.current.results).toEqual([]); - }); -}); diff --git a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts b/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts deleted file mode 100644 index 73d4b6a8bcf..00000000000 --- a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useState, useRef, useMemo, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { debounce } from 'lodash'; -import Engine from '../../../../core/Engine'; -import { selectRecentTokenSearches } from '../../../../selectors/tokenSearchDiscoveryController'; -import { TokenSearchResponseItem } from '@metamask/token-search-discovery-controller'; -import { tokenSearchDiscoveryEnabled } from '../../../../selectors/featureFlagController/tokenSearchDiscovery'; - -const SEARCH_DEBOUNCE_DELAY = 250; -const MINIMUM_QUERY_LENGTH = 2; -export const MAX_RESULTS = '20'; - -export const useTokenSearchDiscovery = () => { - const recentSearches = useSelector(selectRecentTokenSearches); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [results, setResults] = useState([]); - const latestRequestId = useRef(0); - const tokenSearchEnabled = useSelector(tokenSearchDiscoveryEnabled); - - const searchTokens = useMemo( - () => - debounce(async (query: string) => { - setIsLoading(true); - setError(null); - - if (query.length < MINIMUM_QUERY_LENGTH || !tokenSearchEnabled) { - setResults([]); - setIsLoading(false); - return; - } - - const requestId = ++latestRequestId.current; - - try { - const { TokenSearchDiscoveryController } = Engine.context; - const result = - await TokenSearchDiscoveryController.searchSwappableTokens({ - query, - limit: MAX_RESULTS, - }); - if (requestId === latestRequestId.current) { - setResults(result); - } - } catch (err) { - if (requestId === latestRequestId.current) { - setError(err as Error); - } - } finally { - if (requestId === latestRequestId.current) { - setIsLoading(false); - } - } - }, SEARCH_DEBOUNCE_DELAY), - [tokenSearchEnabled], - ); - - const reset = useCallback(() => { - setResults([]); - setError(null); - setIsLoading(false); - }, []); - - return { - searchTokens, - recentSearches, - isLoading, - error, - results, - reset, - }; -}; - -export default useTokenSearchDiscovery; diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts index 6aeaea1bd4d..f1947fa1499 100644 --- a/app/constants/deeplinks.ts +++ b/app/constants/deeplinks.ts @@ -45,6 +45,7 @@ export enum ACTIONS { ONBOARDING = 'onboarding', TRENDING = 'trending', EARN_MUSD = 'earn-musd', + NFT = 'nft', } export const PREFIXES = { @@ -77,5 +78,6 @@ export const PREFIXES = { [ACTIONS.CARD_HOME]: '', [ACTIONS.TRENDING]: '', [ACTIONS.EARN_MUSD]: '', + [ACTIONS.NFT]: '', METAMASK: 'metamask://', }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 566267d4477..268bc5b46fb 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -259,7 +259,6 @@ const Routes = { PERPS: { ROOT: 'Perps', PERPS_TAB: 'PerpsTradingView', // Redirect to wallet home and select perps tab - ORDER: 'PerpsOrder', WITHDRAW: 'PerpsWithdraw', POSITIONS: 'PerpsPositions', PERPS_HOME: 'PerpsMarketListView', // Home screen (positions, orders, watchlist, markets) diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts new file mode 100644 index 00000000000..b17708f7824 --- /dev/null +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts @@ -0,0 +1,91 @@ +import { handleNftUrl } from '../handleNftUrl'; +import NavigationService from '../../../../NavigationService'; +import Routes from '../../../../../constants/navigation/Routes'; +import DevLogger from '../../../../SDKConnect/utils/DevLogger'; +import Logger from '../../../../../util/Logger'; + +jest.mock('../../../../NavigationService'); +jest.mock('../../../../SDKConnect/utils/DevLogger'); +jest.mock('../../../../../util/Logger'); + +describe('handleNftUrl', () => { + let mockNavigate: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockNavigate = jest.fn(); + NavigationService.navigation = { + navigate: mockNavigate, + } as unknown as typeof NavigationService.navigation; + + (DevLogger.log as jest.Mock) = jest.fn(); + (Logger.error as jest.Mock) = jest.fn(); + }); + + it('navigates to NFTS_FULL_VIEW', () => { + handleNftUrl(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.NFTS_FULL_VIEW); + }); + + it('logs start of deeplink handling', () => { + handleNftUrl(); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handleNftUrl] Starting NFT deeplink handling', + ); + }); + + it('falls back to WALLET.HOME on navigation error', () => { + mockNavigate.mockImplementationOnce(() => { + throw new Error('Navigation error'); + }); + + handleNftUrl(); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenLastCalledWith(Routes.WALLET.HOME); + }); + + it('logs error when navigation fails', () => { + const error = new Error('Navigation error'); + mockNavigate.mockImplementationOnce(() => { + throw error; + }); + + handleNftUrl(); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handleNftUrl] Failed to handle NFT deeplink:', + error, + ); + expect(Logger.error).toHaveBeenCalledWith( + error, + '[handleNftUrl] Error handling NFT deeplink', + ); + }); + + it('logs error when fallback navigation also fails', () => { + const primaryError = new Error('Primary navigation error'); + const fallbackError = new Error('Fallback navigation error'); + mockNavigate + .mockImplementationOnce(() => { + throw primaryError; + }) + .mockImplementationOnce(() => { + throw fallbackError; + }); + + handleNftUrl(); + + expect(Logger.error).toHaveBeenCalledWith( + primaryError, + '[handleNftUrl] Error handling NFT deeplink', + ); + expect(Logger.error).toHaveBeenCalledWith( + fallbackError, + '[handleNftUrl] Failed to navigate to fallback screen', + ); + }); +}); diff --git a/app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts new file mode 100644 index 00000000000..91f92176395 --- /dev/null +++ b/app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts @@ -0,0 +1,31 @@ +import NavigationService from '../../../NavigationService'; +import Routes from '../../../../constants/navigation/Routes'; +import DevLogger from '../../../SDKConnect/utils/DevLogger'; +import Logger from '../../../../util/Logger'; + +/** + * NFT deeplink handler + * + * Supported URL formats: + * - https://link.metamask.io/nft + * - https://metamask.io/nft (mapped via Branch) + */ +export const handleNftUrl = () => { + DevLogger.log('[handleNftUrl] Starting NFT deeplink handling'); + + try { + NavigationService.navigation.navigate(Routes.WALLET.NFTS_FULL_VIEW); + } catch (error) { + DevLogger.log('[handleNftUrl] Failed to handle NFT deeplink:', error); + Logger.error(error as Error, '[handleNftUrl] Error handling NFT deeplink'); + + try { + NavigationService.navigation.navigate(Routes.WALLET.HOME); + } catch (navError) { + Logger.error( + navError as Error, + '[handleNftUrl] Failed to navigate to fallback screen', + ); + } + } +}; diff --git a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts index 40794504d71..45425f62dde 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts @@ -32,6 +32,7 @@ import { handleCardOnboarding } from './handleCardOnboarding'; import { handleCardHome } from './handleCardHome'; import { handleTrendingUrl } from './handleTrendingUrl'; import { handleEarnMusd } from './handleEarnMusd'; +import { handleNftUrl } from './handleNftUrl'; import { RampType } from '../../../../reducers/fiatOrders/types'; import { SHIELD_WEBSITE_URL } from '../../../../constants/shield'; import { @@ -82,6 +83,7 @@ const SUPPORTED_ACTIONS = { TRENDING: ACTIONS.TRENDING, SHIELD: ACTIONS.SHIELD, EARN_MUSD: ACTIONS.EARN_MUSD, + NFT: ACTIONS.NFT, // MetaMask SDK specific actions ANDROID_SDK: ACTIONS.ANDROID_SDK, CONNECT: ACTIONS.CONNECT, @@ -597,6 +599,10 @@ async function handleUniversalLink({ handleEarnMusd(); break; } + case SUPPORTED_ACTIONS.NFT: { + handleNftUrl(); + break; + } } } diff --git a/app/core/DeeplinkManager/types/deepLink.types.ts b/app/core/DeeplinkManager/types/deepLink.types.ts index 203621b4467..68ee60879b6 100644 --- a/app/core/DeeplinkManager/types/deepLink.types.ts +++ b/app/core/DeeplinkManager/types/deepLink.types.ts @@ -136,6 +136,7 @@ export const SUPPORTED_ACTIONS = [ ACTIONS.CARD_ONBOARDING, ACTIONS.CARD_HOME, ACTIONS.SHIELD, + ACTIONS.NFT, ] as const satisfies readonly ACTIONS[]; export type SupportedAction = (typeof SUPPORTED_ACTIONS)[number]; diff --git a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts index 4886d5fd0e3..922670d53ef 100644 --- a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts +++ b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts @@ -54,6 +54,7 @@ export enum DeepLinkRoute { ENABLE_CARD_BUTTON = 'enable-card-button', CARD_ONBOARDING = 'card-onboarding', CARD_HOME = 'card-home', + NFT = 'nft', INVALID = 'invalid', } diff --git a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts index 0f0878d639a..2ce11508907 100644 --- a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts +++ b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts @@ -444,6 +444,18 @@ const extractShieldProperties = ( // SHIELD route doesn't have sensitive parameters to extract }; +/** + * Extract properties for NFT route + * @param urlParams - URL parameters + * @param sensitiveProps - Object to add properties to + */ +const extractNftProperties = ( + _urlParams: UrlParamValues, + _sensitiveProps: Record, +): void => { + // NFT route doesn't have sensitive parameters to extract +}; + /** * Extract properties for INVALID route * No properties to extract, this function is a placeholder @@ -483,6 +495,7 @@ const routeExtractors: Record< [DeepLinkRoute.ENABLE_CARD_BUTTON]: extractEnableCardButtonProperties, [DeepLinkRoute.CARD_ONBOARDING]: extractCardOnboardingProperties, [DeepLinkRoute.CARD_HOME]: extractCardHomeProperties, + [DeepLinkRoute.NFT]: extractNftProperties, [DeepLinkRoute.INVALID]: extractInvalidProperties, }; @@ -615,6 +628,8 @@ export const mapSupportedActionToRoute = ( return DeepLinkRoute.CARD_ONBOARDING; case ACTIONS.CARD_HOME: return DeepLinkRoute.CARD_HOME; + case ACTIONS.NFT: + return DeepLinkRoute.NFT; default: return DeepLinkRoute.INVALID; } @@ -667,6 +682,8 @@ export const extractRouteFromUrl = (url: string): DeepLinkRoute => { return DeepLinkRoute.CARD_ONBOARDING; case 'card-home': return DeepLinkRoute.CARD_HOME; + case 'nft': + return DeepLinkRoute.NFT; case undefined: // Empty path (no segments after filtering) return DeepLinkRoute.HOME; default: diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 6661aa8fc3c..6d452d6cf35 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -49,6 +49,7 @@ import { import NotificationManager from '../NotificationManager'; import Logger from '../../util/Logger'; import { isZero } from '../../util/lodash'; +import { initializeRpcProviderDomains } from '../../util/rpc-domain-utils'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { notificationServicesControllerInit } from './controllers/notifications/notification-services-controller-init'; @@ -68,6 +69,7 @@ import { parseCaipAssetType, } from '@metamask/utils'; import { providerErrors } from '@metamask/rpc-errors'; +import { captureException } from '@sentry/react-native'; import { networkIdUpdated, @@ -148,7 +150,6 @@ import { tokenSearchDiscoveryDataControllerInit } from './controllers/token-sear import { assetsContractControllerInit } from './controllers/assets-contract-controller-init'; import { tokensControllerInit } from './controllers/tokens-controller-init'; import { tokenListControllerInit } from './controllers/token-list-controller-init'; -import { tokenSearchDiscoveryControllerInit } from './controllers/token-search-discovery-controller-init'; import { tokenDetectionControllerInit } from './controllers/token-detection-controller-init'; import { tokenBalancesControllerInit } from './controllers/token-balances-controller-init'; import { tokenRatesControllerInit } from './controllers/token-rates-controller-init'; @@ -316,7 +317,6 @@ export class Engine { TokenRatesController: tokenRatesControllerInit, TokenListController: tokenListControllerInit, TokenDetectionController: tokenDetectionControllerInit, - TokenSearchDiscoveryController: tokenSearchDiscoveryControllerInit, TokenSearchDiscoveryDataController: tokenSearchDiscoveryDataControllerInit, DeFiPositionsController: defiPositionsControllerInit, @@ -423,8 +423,6 @@ export class Engine { const tokenRatesController = controllersByName.TokenRatesController; const tokenListController = controllersByName.TokenListController; const tokenDetectionController = controllersByName.TokenDetectionController; - const tokenSearchDiscoveryController = - controllersByName.TokenSearchDiscoveryController; const tokenSearchDiscoveryDataController = controllersByName.TokenSearchDiscoveryDataController; const bridgeController = controllersByName.BridgeController; @@ -473,6 +471,12 @@ export class Engine { controllersByName.NetworkEnablementController; networkEnablementController.init(); + // Initialize RPC domain validation cache for analytics + // This runs asynchronously and doesn't block Engine initialization + initializeRpcProviderDomains().catch((error) => { + captureException(error); + }); + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) snapController.init(); cronjobController.init(); @@ -511,7 +515,6 @@ export class Engine { RemoteFeatureFlagController: remoteFeatureFlagController, SelectedNetworkController: selectedNetworkController, SignatureController: signatureController, - TokenSearchDiscoveryController: tokenSearchDiscoveryController, LoggingController: loggingController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) CronjobController: cronjobController, @@ -1306,7 +1309,6 @@ export default { TokenListController, TokenRatesController, TokensController, - TokenSearchDiscoveryController, TokenSearchDiscoveryDataController, TransactionController, TransactionPayController, @@ -1373,7 +1375,6 @@ export default { TokenListController: TokenListController.state, TokenRatesController: TokenRatesController.state, TokensController: TokensController.state, - TokenSearchDiscoveryController: TokenSearchDiscoveryController.state, TokenSearchDiscoveryDataController: TokenSearchDiscoveryDataController.state, TransactionController: TransactionController.state, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index ecbca20acec..ef506dade9a 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -47,7 +47,6 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'TokenListController:stateChange', 'TokenRatesController:stateChange', 'TokensController:stateChange', - 'TokenSearchDiscoveryController:stateChange', 'TokenSearchDiscoveryDataController:stateChange', 'TransactionController:stateChange', 'TransactionPayController:stateChange', diff --git a/app/core/Engine/controllers/network-controller/utils.test.ts b/app/core/Engine/controllers/network-controller/utils.test.ts index eb134651664..2012abf4365 100644 --- a/app/core/Engine/controllers/network-controller/utils.test.ts +++ b/app/core/Engine/controllers/network-controller/utils.test.ts @@ -371,6 +371,36 @@ describe('isPublicEndpointUrl', () => { ), ).toBe(false); }); + + it('returns false for localhost URLs', () => { + expect( + isPublicEndpointUrl( + 'http://localhost:8545', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://127.0.0.1:8545', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + + it('returns false for invalid URLs', () => { + expect( + isPublicEndpointUrl(':::invalid-url', MOCK_METAMASK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('returns true for known public provider domains like Alchemy', () => { + expect( + isPublicEndpointUrl( + 'https://eth-mainnet.alchemyapi.io/v2/some-key', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(true); + }); }); /** diff --git a/app/core/Engine/controllers/network-controller/utils.ts b/app/core/Engine/controllers/network-controller/utils.ts index 1f1fb7fb482..f7fd40420ea 100644 --- a/app/core/Engine/controllers/network-controller/utils.ts +++ b/app/core/Engine/controllers/network-controller/utils.ts @@ -6,6 +6,7 @@ import { PopularList, } from '../../../../util/networks/customNetworks'; import { BUILT_IN_CUSTOM_NETWORKS_RPC } from '@metamask/controller-utils'; +import { isPublicRpcDomain } from '../../../../util/rpc-domain-utils'; /** * We capture Segment events for degraded or unavailable RPC endpoints for 1% @@ -150,17 +151,10 @@ export function isPublicEndpointUrl( endpointUrl: string, infuraProjectId: string, ) { - const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( - endpointUrl, - infuraProjectId, - ); - const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); - const isKnownCustomEndpointUrl = - KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); - return ( - isMetaMaskInfuraEndpointUrl || - isQuicknodeEndpointUrl || - isKnownCustomEndpointUrl + getIsMetaMaskInfuraEndpointUrl(endpointUrl, infuraProjectId) || + getIsQuicknodeEndpointUrl(endpointUrl) || + KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl) || + isPublicRpcDomain(endpointUrl) ); } diff --git a/app/core/Engine/controllers/token-search-discovery-controller-init.test.ts b/app/core/Engine/controllers/token-search-discovery-controller-init.test.ts deleted file mode 100644 index b2801ce7337..00000000000 --- a/app/core/Engine/controllers/token-search-discovery-controller-init.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { buildControllerInitRequestMock } from '../utils/test-utils'; -import { ExtendedMessenger } from '../../ExtendedMessenger'; -import { getTokenSearchDiscoveryControllerMessenger } from '../messengers/token-search-discovery-controller-messenger'; -import { ControllerInitRequest } from '../types'; -import { tokenSearchDiscoveryControllerInit } from './token-search-discovery-controller-init'; -import { - TokenSearchDiscoveryController, - TokenDiscoveryApiService, - TokenSearchApiService, - TokenSearchDiscoveryControllerMessenger, -} from '@metamask/token-search-discovery-controller'; -import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; - -jest.mock('@metamask/token-search-discovery-controller'); - -function getInitRequestMock(): jest.Mocked< - ControllerInitRequest -> { - const baseMessenger = new ExtendedMessenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - - const requestMock = { - ...buildControllerInitRequestMock(baseMessenger), - controllerMessenger: - getTokenSearchDiscoveryControllerMessenger(baseMessenger), - initMessenger: undefined, - }; - - return requestMock; -} - -describe('TokenSearchDiscoveryControllerInit', () => { - it('initializes the controller', () => { - const { controller } = - tokenSearchDiscoveryControllerInit(getInitRequestMock()); - expect(controller).toBeInstanceOf(TokenSearchDiscoveryController); - }); - - it('passes the proper arguments to the controller', () => { - tokenSearchDiscoveryControllerInit(getInitRequestMock()); - - const controllerMock = jest.mocked(TokenSearchDiscoveryController); - expect(controllerMock).toHaveBeenCalledWith({ - messenger: expect.any(Object), - state: undefined, - tokenSearchService: expect.any(TokenSearchApiService), - tokenDiscoveryService: expect.any(TokenDiscoveryApiService), - }); - }); -}); diff --git a/app/core/Engine/controllers/token-search-discovery-controller-init.ts b/app/core/Engine/controllers/token-search-discovery-controller-init.ts deleted file mode 100644 index ff7a8c99466..00000000000 --- a/app/core/Engine/controllers/token-search-discovery-controller-init.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ControllerInitFunction } from '../types'; -import { - TokenDiscoveryApiService, - TokenSearchApiService, - TokenSearchDiscoveryController, - type TokenSearchDiscoveryControllerMessenger, -} from '@metamask/token-search-discovery-controller'; - -const PORTFOLIO_API_URL = { - dev: 'https://portfolio.dev-api.cx.metamask.io/', - prod: 'https://portfolio.api.cx.metamask.io/', -}; - -const getPortfolioApiBaseUrl = () => { - const env = process.env.METAMASK_ENVIRONMENT; - switch (env) { - case 'dev': - case 'e2e': - return PORTFOLIO_API_URL.dev; - case 'pre-release': - case 'production': - case 'beta': - case 'rc': - case 'exp': - return PORTFOLIO_API_URL.prod; - default: - return PORTFOLIO_API_URL.dev; - } -}; - -/** - * Initialize the token search discovery controller. - * - * @param request - The request object. - * @param request.controllerMessenger - The messenger to use for the controller. - * @returns The initialized controller. - */ -export const tokenSearchDiscoveryControllerInit: ControllerInitFunction< - TokenSearchDiscoveryController, - TokenSearchDiscoveryControllerMessenger -> = ({ controllerMessenger, persistedState }) => { - const baseUrl = getPortfolioApiBaseUrl(); - - const controller = new TokenSearchDiscoveryController({ - messenger: controllerMessenger, - state: persistedState.TokenSearchDiscoveryController, - tokenSearchService: new TokenSearchApiService(baseUrl), - tokenDiscoveryService: new TokenDiscoveryApiService(baseUrl), - }); - - return { - controller, - }; -}; diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 6347b1a9948..8ebe67bf76b 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -86,7 +86,6 @@ import { getTokenListControllerInitMessenger, getTokenListControllerMessenger, } from './token-list-controller-messenger'; -import { getTokenSearchDiscoveryControllerMessenger } from './token-search-discovery-controller-messenger'; import { getTokenDetectionControllerInitMessenger, getTokenDetectionControllerMessenger, @@ -408,10 +407,6 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getTokenRatesControllerMessenger, getInitMessenger: noop, }, - TokenSearchDiscoveryController: { - getMessenger: getTokenSearchDiscoveryControllerMessenger, - getInitMessenger: noop, - }, TokenSearchDiscoveryDataController: { getMessenger: getTokenSearchDiscoveryDataControllerMessenger, getInitMessenger: noop, diff --git a/app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts b/app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts deleted file mode 100644 index 7b86637619a..00000000000 --- a/app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, - MOCK_ANY_NAMESPACE, -} from '@metamask/messenger'; -import { TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller'; -import { getTokenSearchDiscoveryControllerMessenger } from './token-search-discovery-controller-messenger'; - -type RootMessenger = Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents ->; - -const getRootMessenger = (): RootMessenger => - new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - -describe('getTokenSearchDiscoveryControllerMessenger', () => { - it('returns a messenger', () => { - const messenger = getRootMessenger(); - const tokenSearchDiscoveryControllerMessenger = - getTokenSearchDiscoveryControllerMessenger(messenger); - - expect(tokenSearchDiscoveryControllerMessenger).toBeInstanceOf(Messenger); - }); -}); diff --git a/app/core/Engine/messengers/token-search-discovery-controller-messenger.ts b/app/core/Engine/messengers/token-search-discovery-controller-messenger.ts deleted file mode 100644 index 19e27fdcbc8..00000000000 --- a/app/core/Engine/messengers/token-search-discovery-controller-messenger.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - Messenger, - MessengerActions, - MessengerEvents, -} from '@metamask/messenger'; -import { type TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller'; -import { RootMessenger } from '../types'; - -/** - * Get the messenger for the token search discovery controller. This is scoped to the - * actions and events that the token search discovery controller is allowed to handle. - * - * @param rootMessenger - The root messenger. - * @returns The TokenSearchDiscoveryControllerMessenger. - */ -export function getTokenSearchDiscoveryControllerMessenger( - rootMessenger: RootMessenger, -): TokenSearchDiscoveryControllerMessenger { - const messenger = new Messenger< - 'TokenSearchDiscoveryController', - MessengerActions, - MessengerEvents, - RootMessenger - >({ - namespace: 'TokenSearchDiscoveryController', - parent: rootMessenger, - }); - return messenger; -} diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index c694aecbce9..9e9197151b9 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -267,12 +267,6 @@ import { ActionConstraint, EventConstraint, } from '@metamask/messenger'; -import { - TokenSearchDiscoveryController, - TokenSearchDiscoveryControllerState, - TokenSearchDiscoveryControllerActions, - TokenSearchDiscoveryControllerEvents, -} from '@metamask/token-search-discovery-controller'; import { SnapKeyringEvents } from '@metamask/eth-snap-keyring'; import { MultichainNetworkController, @@ -500,7 +494,6 @@ type GlobalActions = | SmartTransactionsControllerActions | AssetsContractControllerActions | RemoteFeatureFlagControllerActions - | TokenSearchDiscoveryControllerActions | TokenSearchDiscoveryDataControllerActions | MultichainNetworkControllerActions | BridgeControllerActions @@ -577,7 +570,6 @@ type GlobalEvents = | SmartTransactionsControllerEvents | AssetsContractControllerEvents | RemoteFeatureFlagControllerEvents - | TokenSearchDiscoveryControllerEvents | TokenSearchDiscoveryDataControllerEvents | SnapKeyringEvents | MultichainNetworkControllerEvents @@ -665,7 +657,6 @@ export type Controllers = { TokenListController: TokenListController; TokenDetectionController: TokenDetectionController; TokenRatesController: TokenRatesController; - TokenSearchDiscoveryController: TokenSearchDiscoveryController; TokensController: TokensController; DeFiPositionsController: DeFiPositionsController; TransactionController: TransactionController; @@ -743,7 +734,6 @@ export type EngineState = { PhishingController: PhishingControllerState; TokenBalancesController: TokenBalancesControllerState; TokenRatesController: TokenRatesControllerState; - TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState; TransactionController: TransactionControllerState; TransactionPayController: TransactionPayControllerState; SmartTransactionsController: SmartTransactionsControllerState; @@ -877,7 +867,6 @@ export type ControllersToInitialize = | 'TokenListController' | 'TokenRatesController' | 'TokensController' - | 'TokenSearchDiscoveryController' | 'TokenSearchDiscoveryDataController' | 'TransactionController' | 'TransactionPayController' diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 3698d79e5c5..bb8b11a7755 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -164,7 +164,6 @@ jest.mock('../Engine', () => { SelectedNetworkController: { subscribe: jest.fn() }, SnapInterfaceController: { subscribe: jest.fn() }, SignatureController: { subscribe: jest.fn() }, - TokenSearchDiscoveryController: { subscribe: jest.fn() }, MultichainBalancesController: { subscribe: jest.fn() }, RatesController: { subscribe: jest.fn() }, }, diff --git a/app/selectors/tokenSearchDiscoveryController.test.ts b/app/selectors/tokenSearchDiscoveryController.test.ts deleted file mode 100644 index a9b946526ca..00000000000 --- a/app/selectors/tokenSearchDiscoveryController.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { RootState } from '../reducers'; -import { selectRecentTokenSearches } from './tokenSearchDiscoveryController'; - -describe('Token Search Discovery Controller Selectors', () => { - const mockRecentSearches = ['ETH', 'USDC', 'DAI']; - - const mockState = { - engine: { - backgroundState: { - TokenSearchDiscoveryController: { - recentSearches: mockRecentSearches, - }, - }, - }, - } as unknown as RootState; - - describe('selectRecentTokenSearches', () => { - it('returns recent token searches from state', () => { - expect(selectRecentTokenSearches(mockState)).toEqual(mockRecentSearches); - }); - - it('returns empty array when no recent searches exist', () => { - const stateWithoutSearches = { - engine: { - backgroundState: { - TokenSearchDiscoveryController: { - recentSearches: [], - }, - }, - }, - } as unknown as RootState; - - expect(selectRecentTokenSearches(stateWithoutSearches)).toEqual([]); - }); - - it('returns empty array when TokenSearchDiscoveryController is not initialized', () => { - const stateWithoutController = { - engine: { - backgroundState: {}, - }, - } as unknown as RootState; - - expect(selectRecentTokenSearches(stateWithoutController)).toEqual([]); - }); - }); -}); diff --git a/app/selectors/tokenSearchDiscoveryController.ts b/app/selectors/tokenSearchDiscoveryController.ts deleted file mode 100644 index 4aff774c7b5..00000000000 --- a/app/selectors/tokenSearchDiscoveryController.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createSelector } from 'reselect'; -import { RootState } from '../reducers'; - -const selectTokenSearchDiscoveryControllerState = (state: RootState) => - state.engine.backgroundState.TokenSearchDiscoveryController; - -export const selectRecentTokenSearches = createSelector( - selectTokenSearchDiscoveryControllerState, - (state) => state?.recentSearches ?? [], -); diff --git a/app/selectors/types.ts b/app/selectors/types.ts index a22867dc35a..49bd5037612 100644 --- a/app/selectors/types.ts +++ b/app/selectors/types.ts @@ -18,7 +18,6 @@ import { TransactionControllerState } from '@metamask/transaction-controller'; import { GasFeeController } from '@metamask/gas-fee-controller'; import { ApprovalControllerState } from '@metamask/approval-controller'; import { AccountsControllerState } from '@metamask/accounts-controller'; -import { TokenSearchDiscoveryControllerState } from '@metamask/token-search-discovery-controller'; import { AccountTreeControllerState } from '@metamask/account-tree-controller'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { SnapController } from '@metamask/snaps-controllers'; @@ -49,7 +48,6 @@ export interface EngineState { ApprovalController: ApprovalControllerState; AccountsController: AccountsControllerState; AccountTreeController: AccountTreeControllerState; - TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState; }; }; } diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index b9d0a1275c4..7a78989b518 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -765,10 +765,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "TokenRatesController": { "marketData": {}, }, - "TokenSearchDiscoveryController": { - "lastSearchTimestamp": null, - "recentSearches": [], - }, "TokenSearchDiscoveryDataController": { "tokenDisplayData": [], }, @@ -1544,10 +1540,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "TokenRatesController": { "marketData": {}, }, - "TokenSearchDiscoveryController": { - "lastSearchTimestamp": null, - "recentSearches": [], - }, "TokenSearchDiscoveryDataController": { "tokenDisplayData": [], }, diff --git a/app/util/rpc-domain-utils.test.ts b/app/util/rpc-domain-utils.test.ts index 862914b1d0f..841815429da 100644 --- a/app/util/rpc-domain-utils.test.ts +++ b/app/util/rpc-domain-utils.test.ts @@ -7,6 +7,7 @@ import { getKnownDomains, isKnownDomain, extractRpcDomain, + isPublicRpcDomain, getNetworkRpcUrl, getModuleState, } from './rpc-domain-utils'; @@ -419,6 +420,28 @@ describe('rpc-domain-utils', () => { }); }); + describe('isPublicRpcDomain', () => { + it('returns false for invalid URLs', () => { + expect(isPublicRpcDomain(':::invalid-url')).toBe(false); + }); + + it('returns false for private/localhost URLs', () => { + expect(isPublicRpcDomain('http://localhost:8545')).toBe(false); + expect(isPublicRpcDomain('http://127.0.0.1:8545')).toBe(false); + }); + + it('returns false for unknown private domains', () => { + expect(isPublicRpcDomain('https://unknown-domain.com')).toBe(false); + }); + + it('returns true for known public provider URLs', () => { + expect(isPublicRpcDomain('https://mainnet.infura.io/v3/key')).toBe(true); + expect( + isPublicRpcDomain('https://eth-mainnet.alchemyapi.io/v2/key'), + ).toBe(true); + }); + }); + describe('getNetworkRpcUrl', () => { describe('when retrieving RPC URLs', () => { it('returns RPC URL from legacy format', () => { diff --git a/app/util/rpc-domain-utils.ts b/app/util/rpc-domain-utils.ts index cb026992645..2d80c4d42ee 100644 --- a/app/util/rpc-domain-utils.ts +++ b/app/util/rpc-domain-utils.ts @@ -107,6 +107,18 @@ export const RpcDomainStatus = { export type RpcDomainStatus = (typeof RpcDomainStatus)[keyof typeof RpcDomainStatus]; +/** + * Checks if an RPC endpoint URL has a valid public domain. + * Extracts the domain from the URL and verifies it's not private, invalid, or unknown. + * + * @param endpointUrl - The RPC endpoint URL to check + * @returns True if the URL has a valid public domain, false otherwise + */ +export function isPublicRpcDomain(endpointUrl: string): boolean { + const rpcDomain = extractRpcDomain(endpointUrl); + return !Object.values(RpcDomainStatus).includes(rpcDomain as RpcDomainStatus); +} + function parseDomain(url: string): string | undefined { try { const normalizedUrl = url.includes('://') ? url : `https://${url}`; diff --git a/app/util/test/component-view/mocks.ts b/app/util/test/component-view/mocks.ts index a1cec7f3105..3a068cb9e18 100644 --- a/app/util/test/component-view/mocks.ts +++ b/app/util/test/component-view/mocks.ts @@ -143,6 +143,11 @@ jest.mock('../../../core/Engine', () => { unsubscribe() { return undefined; }, + call(_action: string, ..._args: unknown[]) { + // Analytics calls are side effects - return resolved promise to prevent errors + // but don't execute actual analytics tracking in tests + return Promise.resolve(undefined); + }, }, getTotalEvmFiatAccountBalance() { return { balance: '0', fiatBalance: '0' }; diff --git a/app/util/test/component-view/presets/bridge.ts b/app/util/test/component-view/presets/bridge.ts index bd07213171f..aa3005ca512 100644 --- a/app/util/test/component-view/presets/bridge.ts +++ b/app/util/test/component-view/presets/bridge.ts @@ -33,6 +33,7 @@ export const initialStateBridge = (options?: InitialStateBridgeOptions) => { .withMinimalTokenRates() .withMinimalMultichainAssetsRates() .withMinimalMultichainBalances() + .withMinimalAnalyticsController() .withAccountTreeForSelectedAccount() .withRemoteFeatureFlags({}); diff --git a/app/util/test/component-view/presets/wallet.ts b/app/util/test/component-view/presets/wallet.ts index 41c9687a5cd..411846d9e8b 100644 --- a/app/util/test/component-view/presets/wallet.ts +++ b/app/util/test/component-view/presets/wallet.ts @@ -28,6 +28,7 @@ export const initialStateWallet = (options?: InitialStateWalletOptions) => { .withMinimalKeyringController() .withMinimalTokenRates() .withMinimalMultichainAssetsRates() + .withMinimalAnalyticsController() .withAccountTreeForSelectedAccount() .withRemoteFeatureFlags({}) .withOverrides({ @@ -50,6 +51,15 @@ export const initialStateWallet = (options?: InitialStateWalletOptions) => { TokenBalancesController: { tokenBalances: {}, }, + TokensController: { + allTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, MultichainBalancesController: { balances: {}, }, diff --git a/app/util/test/component-view/stateFixture.ts b/app/util/test/component-view/stateFixture.ts index 51b2faf7e34..f815d133959 100644 --- a/app/util/test/component-view/stateFixture.ts +++ b/app/util/test/component-view/stateFixture.ts @@ -148,6 +148,10 @@ export interface StateFixtureBuilder { withMinimalMultichainBalances(): StateFixtureBuilder; withMinimalMultichainAssets(): StateFixtureBuilder; withMinimalMultichainTransactions(): StateFixtureBuilder; + withMinimalAnalyticsController(options?: { + optedIn?: boolean; + analyticsId?: string; + }): StateFixtureBuilder; withBridgeRecommendedQuoteEvmSimple(params?: { srcAmount?: string; srcTokenAddress?: string; @@ -709,6 +713,28 @@ export function createStateFixture(): StateFixtureBuilder { ); return api; }, + withMinimalAnalyticsController(options = {}) { + const bg = (current.engine?.backgroundState ?? {}) as unknown as Record< + string, + unknown + >; + const { optedIn = false, analyticsId = 'test-analytics-id' } = options; + current = deepMerge( + current as PlainObject, + { + engine: { + backgroundState: { + ...bg, + AnalyticsController: { + optedIn, + analyticsId, + }, + }, + }, + } as unknown as DeepPartial as PlainObject, + ); + return api; + }, withOverrides(overrides) { current = deepMerge(current as PlainObject, overrides as PlainObject); return api; diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 293f8a85424..60746f2f9b8 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -342,10 +342,6 @@ "TokenRatesController": { "marketData": {} }, - "TokenSearchDiscoveryController": { - "lastSearchTimestamp": null, - "recentSearches": [] - }, "TokenSearchDiscoveryDataController": { "tokenDisplayData": [] }, diff --git a/app/util/test/testSetupView.js b/app/util/test/testSetupView.js index c8f063e7761..c2bcc8db50a 100644 --- a/app/util/test/testSetupView.js +++ b/app/util/test/testSetupView.js @@ -7,6 +7,7 @@ /* eslint-disable import/no-commonjs */ /* eslint-disable react/prop-types */ /* eslint-disable react/display-name */ + const { NativeModules } = require('react-native'); // eslint-disable-next-line import/no-nodejs-modules const nodeCrypto = require('crypto'); diff --git a/bitrise.yml b/bitrise.yml index c138a3347b3..84b0a0ea430 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3469,13 +3469,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.64.0 + VERSION_NAME: 7.65.0 - opts: is_expand: false VERSION_NUMBER: 3418 - opts: is_expand: false - FLASK_VERSION_NAME: 7.64.0 + FLASK_VERSION_NAME: 7.65.0 - opts: is_expand: false FLASK_VERSION_NUMBER: 3418 diff --git a/docs/readme/e2e-testing.md b/docs/readme/e2e-testing.md index bbd60ddb6ab..354d61401f6 100644 --- a/docs/readme/e2e-testing.md +++ b/docs/readme/e2e-testing.md @@ -72,21 +72,32 @@ Ensure that following devices are set up: export PREBUILT_ANDROID_TEST_APK_PATH='build/MetaMask-Test.apk' ``` -3. Create the build directory if it doesn't exist: +### App Build - ```bash - # In root of project - mkdir build - ``` +You can either use prebuilt app files from Expo (iOS only) or build the app locally. + +#### Option 1: Use Expo Prebuilds (iOS Only) -4. Install dependencies +Choose one of the following methods to download the prebuilt iOS app: + +**Method A: Using Runway Script (Recommended)** + +```bash +yarn install:ios:runway --skipInstall +``` + +**Method B: Manual Download from Runway** + +1. Navigate to [Runway builds](https://app.runway.team/bucket/aCddXOkg1p_nDryri-FMyvkC9KRqQeVT_12sf6Nw0u6iGygGo6BlNzjD6bOt-zma260EzAxdpXmlp2GQphp3TN1s6AJE4i6d_9V0Tv5h4pHISU49dFk=) +2. Download the latest version of the app +3. Copy and rename the build: ```bash - # In root of project - yarn setup:expo + # Copy your downloaded .app file to the prebuild path + cp /path/to/your/downloaded/AAA.app build/MetaMask.app ``` -### Build the app (optional) +#### Option 2: Build the App Locally Sometimes it is necessary to build the app locally, for example, to enable build-time feature flags (like GNS), to debug issues more effectively, or to identify and update element locators. @@ -102,57 +113,56 @@ yarn test:e2e:android:debug:build # These commands are hardcoded to build for `main` build type and `e2e` environment based on the .detoxrc.js file ``` -### Use Expo prebuilds (iOS Only) +### Run the E2E Tests -You can use prebuilt app files instead of building the app locally. +Running E2E tests requires two separate terminal sessions: one for the Metro bundler and one for executing the tests. -#### iOS builds +#### Terminal 1: Start the Metro Bundler -1. **Download iOS simulator builds** from Runway/Bitrise/GitHub workflows (build jobs) +First, ensure the build watcher is running in a dedicated terminal for logs: -2. **Copy and rename the build**: Copy your downloaded .app file to the prebuild path +```bash +export METAMASK_ENVIRONMENT='e2e' +export METAMASK_BUILD_TYPE='main' +yarn setup:expo +yarn watch:clean # First time or after dependency changes +yarn watch # Subsequent runs +``` - ```bash - # Copy your downloaded .app file to the prebuild path - cp /path/to/your/downloaded/AAA.app build/MetaMask.app - ``` +#### Terminal 2: Execute Tests -3. **Start the build watcher**: +In a separate terminal, set up and run your tests: - ```bash - source .e2e.env && yarn watch:clean - ``` - -4. **Launch the iPhone 15 Pro simulator** from Xcode or in a new terminal by: +**Initial Setup (First Time Only)** - ```bash - xcrun simctl boot "iPhone 15 Pro" - open -a Simulator # to open the simulator app GUI - ``` +```bash +cp .e2e.env.example .e2e.env +``` -### Run the E2E Tests +**Run All Tests** ```bash -# Firstly, make sure the build watcher is running in a dedicated terminal for the logs -# and the emulators are up and running -# Ensure METAMASK_BUILD_TYPE is set to `main` and METAMASK_ENVIRONMENT is set to `e2e` in .js.env -source .e2e.env # Ensure .js.env is sourced -yarn watch:clean # First time or after dependency changes -yarn watch # Subsequent runs - -# Run all Tests source .e2e.env && yarn test:e2e:ios:debug:run source .e2e.env && yarn test:e2e:android:debug:run +``` + +**Run Specific Test Folder** -# Run specific folder +```bash source .e2e.env && yarn test:e2e:ios:debug:run e2e/specs/your-folder source .e2e.env && yarn test:e2e:android:debug:run e2e/specs/your-folder +``` -# Run specific test +**Run Specific Test File** + +```bash source .e2e.env && yarn test:e2e:ios:debug:run e2e/specs/onboarding/create-wallet.spec.js source .e2e.env && yarn test:e2e:android:debug:run e2e/specs/onboarding/create-wallet.spec.js +``` -# Run tests by tag +**Run Tests by Tag** + +```bash source .e2e.env && yarn test:e2e:ios:debug:run --testNamePattern="Smoke" source .e2e.env && yarn test:e2e:android:debug:run --testNamePattern="Smoke" ``` diff --git a/e2e/pages/Confirmation/TransactionPayConfirmation.ts b/e2e/pages/Confirmation/TransactionPayConfirmation.ts index e264de21f57..fc3f5882f80 100644 --- a/e2e/pages/Confirmation/TransactionPayConfirmation.ts +++ b/e2e/pages/Confirmation/TransactionPayConfirmation.ts @@ -66,6 +66,11 @@ class TransactionPayConfirmation { } } + async enterAmountAndContinue(amount: string): Promise { + await this.tapKeyboardAmount(amount); + await this.tapKeyboardContinueButton(); + } + async verifyBridgeTime(time: string): Promise { await Assertions.expectElementToHaveText(this.bridgeTime, time, { description: 'Bridge time should be correct', diff --git a/e2e/pages/Transactions/ActivitiesView.ts b/e2e/pages/Transactions/ActivitiesView.ts index 7006632d35a..ebee8636d30 100644 --- a/e2e/pages/Transactions/ActivitiesView.ts +++ b/e2e/pages/Transactions/ActivitiesView.ts @@ -4,6 +4,7 @@ import { } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; import Matchers from '../../../tests/framework/Matchers'; import Gestures from '../../../tests/framework/Gestures'; +import Assertions from '../../../tests/framework/Assertions'; class ActivitiesView { get title(): DetoxElement { @@ -151,6 +152,45 @@ class ActivitiesView { elemDescription: `Tapping Predict Position: ${positionName}`, }); } + + /** + * Verifies that an activity item with the given title is visible and its row status matches. + * Use after TabBarComponent.tapActivity(). Row 0 is the most recent transaction. + * + * @param titleText - Activity title to look for (e.g. "mUSD conversion", "Sent ETH") + * @param statusText - Expected status for the row (e.g. "Confirmed", "Failed") + * @param rowIndex - Row index (default 0 = most recent) + */ + async verifyActivityItemWithStatus( + titleText: string, + statusText: string, + rowIndex = 0, + ): Promise { + await Assertions.expectTextDisplayed(titleText, { + timeout: 20000, + description: `Activity item "${titleText}" should be visible`, + }); + await Assertions.expectElementToHaveText( + this.transactionStatus(rowIndex), + statusText, + { + timeout: 10000, + description: `Activity row (index ${rowIndex}) should show status "${statusText}"`, + }, + ); + } + + /** + * Verifies that the mUSD conversion activity item is visible and its status is Confirmed. + * Delegates to verifyActivityItemWithStatus. + */ + async verifyMusdConversionConfirmed(rowIndex = 0): Promise { + await this.verifyActivityItemWithStatus( + ActivitiesViewSelectorsText.MUSD_CONVERSION, + ActivitiesViewSelectorsText.CONFIRM_TEXT, + rowIndex, + ); + } } export default new ActivitiesView(); diff --git a/e2e/pages/wallet/WalletView.ts b/e2e/pages/wallet/WalletView.ts index 4b1a86b97cc..8057860b749 100644 --- a/e2e/pages/wallet/WalletView.ts +++ b/e2e/pages/wallet/WalletView.ts @@ -2,6 +2,8 @@ import { WalletViewSelectorsIDs, WalletViewSelectorsText, } from '../../../app/components/Views/Wallet/WalletView.testIds'; +import { EARN_TEST_IDS } from '../../../app/components/UI/Earn/constants/testIds'; +import { SECONDARY_BALANCE_BUTTON_TEST_ID } from '../../../app/components/UI/AssetElement/index.constants'; import { PredictTabViewSelectorsIDs, PredictPositionsHeaderSelectorsIDs, @@ -622,6 +624,32 @@ class WalletView { return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_SEND_BUTTON); } + // mUSD conversion (Earn) - asset list CTA, education screen, token list CTA, asset overview CTA + get musdConversionCta(): DetoxElement { + return Matchers.getElementByID( + EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA, + ); + } + + get getMusdButton(): DetoxElement { + return Matchers.getElementByText('Get mUSD'); + } + + get getStartedButton(): DetoxElement { + return Matchers.getElementByText('Get Started'); + } + + /** Token list item CTA: "Get 3% mUSD bonus" on USDC row. Use testID + index (1 = USDC after ETH) to avoid regex/text flakiness. */ + get tokenListItemConvertToMusdCta(): DetoxElement { + return Matchers.getElementByID(SECONDARY_BALANCE_BUTTON_TEST_ID, 1); + } + + get assetOverviewMusdCta(): DetoxElement { + return Matchers.getElementByID( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA, + ); + } + get walletReceiveButton(): DetoxElement { return Matchers.getElementByID( WalletViewSelectorsIDs.WALLET_RECEIVE_BUTTON, @@ -664,6 +692,60 @@ class WalletView { }); } + async tapGetMusdButton(): Promise { + await Gestures.waitAndTap(this.getMusdButton, { + elemDescription: 'Get mUSD button', + }); + } + + async tapGetStartedButton(): Promise { + await Gestures.waitAndTap(this.getStartedButton, { + elemDescription: 'Get Started button on education screen', + }); + } + + /** Tap the "Get X% mUSD bonus" CTA on a token list row (visible when user has mUSD balance). Uses checkStability + delay so list is fully loaded before tap. */ + async tapTokenListItemConvertToMusdCta(): Promise { + await Gestures.waitAndTap(this.tokenListItemConvertToMusdCta, { + checkStability: true, + delay: 1000, + elemDescription: 'Token list item mUSD conversion CTA', + }); + } + + /** + * Scrolls down on the Asset Overview screen until the mUSD conversion CTA is visible, + * then asserts it is visible so the caller can safely tap. Uses the same scroll + * container as the Asset/Transactions screen (transactions-container). + */ + async scrollDownToAssetOverviewMusdCta(): Promise { + const assetOverviewScrollContainer = Matchers.getIdentifier( + 'transactions-container', + ); + await Gestures.scrollToElement( + this.assetOverviewMusdCta as unknown as DetoxElement, + assetOverviewScrollContainer, + { + direction: 'down', + scrollAmount: 200, + elemDescription: 'Asset Overview mUSD CTA', + timeout: 15000, + }, + ); + await Assertions.expectElementToBeVisible(this.assetOverviewMusdCta, { + timeout: 5000, + description: 'Asset Overview mUSD CTA should be visible after scroll', + }); + } + + async tapAssetOverviewMusdCta(): Promise { + await Gestures.waitAndTap(this.assetOverviewMusdCta, { + checkStability: true, + delay: 800, + elemDescription: 'Asset Overview mUSD CTA', + }); + } + async tapWalletReceiveButton(): Promise { await Gestures.waitAndTap(this.walletReceiveButton, { elemDescription: 'Wallet Receive Button', diff --git a/e2e/specs/wallet/helpers/musd-fixture.ts b/e2e/specs/wallet/helpers/musd-fixture.ts new file mode 100644 index 00000000000..95192da67d4 --- /dev/null +++ b/e2e/specs/wallet/helpers/musd-fixture.ts @@ -0,0 +1,75 @@ +import FixtureBuilder, { + type MusdFixtureOptions, +} from '../../../../tests/framework/fixtures/FixtureBuilder'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { AnvilPort } from '../../../../tests/framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../../../tests/seeder/anvil-manager'; +import { + USDC_MAINNET, + MUSD_MAINNET, +} from '../../../../tests/constants/musd-mainnet'; + +const USDC_DECIMALS = 6; +const MUSD_DECIMALS = 6; +const ETH_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; + +export type { MusdFixtureOptions }; + +/** + * Builds a fixture for mUSD conversion E2E tests using FixtureBuilder: + * Mainnet, ETH/USDC/mUSD tokens, rates, balances, and mUSD eligibility state. + */ +export function createMusdFixture( + node: AnvilManager, + options: MusdFixtureOptions, +): ReturnType { + const rpcPort = node?.getPort?.() ?? AnvilPort(); + const baseTokens = [ + { + address: toChecksumHexAddress(ETH_NATIVE_ADDRESS), + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + }, + { + address: toChecksumHexAddress(USDC_MAINNET), + symbol: 'USDC', + decimals: USDC_DECIMALS, + name: 'USDCoin', + }, + ...(options.hasMusdBalance + ? [ + { + address: toChecksumHexAddress(MUSD_MAINNET), + symbol: 'MUSD', + decimals: MUSD_DECIMALS, + name: 'MUSD', + }, + ] + : []), + ]; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: CHAIN_IDS.MAINNET, + rpcUrl: `http://localhost:${rpcPort}`, + type: 'custom', + nickname: 'Ethereum Mainnet', + ticker: 'ETH', + }, + }) + .withNetworkEnabledMap({ eip155: { [CHAIN_IDS.MAINNET]: true } }) + .withMetaMetricsOptIn() + .withTokensForAllPopularNetworks(baseTokens) + .withTokenRates( + CHAIN_IDS.MAINNET, + toChecksumHexAddress(ETH_NATIVE_ADDRESS), + 3000.0, + ) + .withTokenRates(CHAIN_IDS.MAINNET, toChecksumHexAddress(USDC_MAINNET), 1.0) + .withTokenRates(CHAIN_IDS.MAINNET, toChecksumHexAddress(MUSD_MAINNET), 1.0) + .withMusdConversion(options) + .build(); +} diff --git a/e2e/specs/wallet/musd-conversion-happy-path.spec.ts b/e2e/specs/wallet/musd-conversion-happy-path.spec.ts new file mode 100644 index 00000000000..9f9092a4de5 --- /dev/null +++ b/e2e/specs/wallet/musd-conversion-happy-path.spec.ts @@ -0,0 +1,236 @@ +import { SmokeWalletPlatform } from '../../tags'; +import TestHelpers from '../../helpers'; +import WalletView from '../../pages/wallet/WalletView'; +import { loginToApp } from '../../viewHelper'; +import Assertions from '../../../tests/framework/Assertions'; +import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; +import { + LocalNode, + LocalNodeType, + type WithFixturesOptions, +} from '../../../tests/framework/types'; +import { AnvilManager } from '../../../tests/seeder/anvil-manager'; +import TransactionPayConfirmation from '../../pages/Confirmation/TransactionPayConfirmation'; +import FooterActions from '../../pages/Browser/Confirmations/FooterActions'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import ActivitiesView from '../../pages/Transactions/ActivitiesView'; +import { setupMusdMocks } from '../../../tests/api-mocking/mock-responses/musd/musd-mocks'; +import { + createMusdFixture, + type MusdFixtureOptions, +} from './helpers/musd-fixture'; + +/** + * Returns the shared withFixtures config for mUSD conversion tests. + * Only fixture options vary per scenario; localNodeOptions, restartDevice, and testSpecificMock are centralized here. + */ +function withMusdFixturesOptions( + fixtureOptions: MusdFixtureOptions, +): WithFixturesOptions { + return { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + return createMusdFixture(node, fixtureOptions); + }, + localNodeOptions: [ + { + type: LocalNodeType.anvil, + options: { chainId: 1 }, + }, + ], + restartDevice: true, + testSpecificMock: setupMusdMocks, + }; +} + +describe(SmokeWalletPlatform('mUSD Conversion Happy Path'), () => { + beforeAll(async () => { + jest.setTimeout(150000); + await TestHelpers.launchApp(); + }); + + it('converts USDC to mUSD successfully (First Time User)', async () => { + await withFixtures( + withMusdFixturesOptions({ + musdConversionEducationSeen: false, + }), + async () => { + await device.disableSynchronization(); + await loginToApp(); + + // Verify wallet is visible + await Assertions.expectElementToBeVisible(WalletView.container, { + description: 'Wallet view should be visible', + }); + + // Verify mUSD CTA is visible and tap Get mUSD + await Assertions.expectElementToBeVisible( + WalletView.musdConversionCta, + { + description: 'mUSD conversion CTA should be visible', + }, + ); + await WalletView.tapGetMusdButton(); + + // Verify education screen is shown (first time user) and tap Get Started + await Assertions.expectElementToBeVisible(WalletView.getStartedButton, { + timeout: 10000, + description: 'Education screen Get Started button should be visible', + }); + await WalletView.tapGetStartedButton(); + + // Verify custom amount/confirmation screen is shown + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.payWithRow, + { + timeout: 10000, + description: + 'Pay with row should be visible on confirmation screen', + }, + ); + + // Enter amount ($12) and continue (avoid "0" key to prevent banner blocking) + await TransactionPayConfirmation.enterAmountAndContinue('12'); + + // Verify confirmation details are visible + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.total, + { + timeout: 10000, + description: 'Total amount should be visible', + }, + ); + + // Confirm the transaction (tap the convert/confirm button) + await FooterActions.tapConfirmButton(); + + // Verify we're back in the wallet after confirmation (ignore processing/completed banners - flaky) + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 30000, + description: 'Wallet view should be visible after conversion', + }); + + // Go to Activity and verify mUSD conversion is confirmed (same pattern as send-native-token: no swipeDown) + await TabBarComponent.tapActivity(); + await ActivitiesView.verifyMusdConversionConfirmed(0); + }, + ); + }); + + it('converts USDC to mUSD from Token List (Returning User)', async () => { + await withFixtures( + withMusdFixturesOptions({ + musdConversionEducationSeen: true, + hasMusdBalance: true, + musdBalance: 100, + }), + async () => { + await device.disableSynchronization(); + await loginToApp(); + + // Verify wallet is visible + await Assertions.expectElementToBeVisible(WalletView.container, { + description: 'Wallet view should be visible', + }); + + // Scroll to top then to USDC row (UI shows symbol "USDC" on token row). tapTokenListItemConvertToMusdCta uses checkStability + delay so list is ready before tap. + await WalletView.scrollToTopOfTokensList(); + await WalletView.scrollToToken('USDCoin'); + await Assertions.expectElementToBeVisible( + WalletView.tokenListItemConvertToMusdCta, + { + timeout: 10000, + description: + 'Token list item mUSD CTA (Get X% mUSD bonus) should be visible on USDC row', + }, + ); + await WalletView.tapTokenListItemConvertToMusdCta(); + + // Education skipped (musdConversionEducationSeen: true) - confirmation screen shown (payToken/quote may load) + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.payWithRow, + { + timeout: 20000, + description: + 'Pay with row should be visible on confirmation screen', + }, + ); + + // Enter amount and continue (avoid "0" key - use 5) + await TransactionPayConfirmation.enterAmountAndContinue('5'); + + // Confirm the transaction + await FooterActions.tapConfirmButton(); + + // Verify we're back in the wallet after confirmation + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 30000, + description: + 'Wallet view should be visible after transaction confirmation', + }); + + // Go to Activity and verify mUSD conversion is confirmed + await TabBarComponent.tapActivity(); + await ActivitiesView.verifyMusdConversionConfirmed(0); + }, + ); + }); + + it('converts USDC to mUSD from Asset Overview', async () => { + await withFixtures( + withMusdFixturesOptions({ + musdConversionEducationSeen: true, + }), + async () => { + await device.disableSynchronization(); + await loginToApp(); + + // Verify wallet is visible + await Assertions.expectElementToBeVisible(WalletView.container, { + description: 'Wallet view should be visible', + }); + + // Tap on USDC to go to Asset Overview, scroll to mUSD CTA (ensures loaded), then tap + await WalletView.tapOnToken('USDCoin'); + await WalletView.scrollDownToAssetOverviewMusdCta(); + await WalletView.tapAssetOverviewMusdCta(); + + // Verify confirmation screen (payToken/quote may load) + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.payWithRow, + { + timeout: 20000, + description: + 'Pay with row should be visible on confirmation screen', + }, + ); + + // Enter amount and continue (avoid "0" key - use 5) + await TransactionPayConfirmation.enterAmountAndContinue('5'); + + // Verify confirmation details are visible + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.total, + { + timeout: 10000, + description: 'Total amount should be visible', + }, + ); + + // Confirm the transaction + await FooterActions.tapConfirmButton(); + + // Verify we're back in the wallet after confirmation + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 30000, + description: + 'Wallet view should be visible after transaction confirmation', + }); + + // Go to Activity and verify mUSD conversion is confirmed + await TabBarComponent.tapActivity(); + await ActivitiesView.verifyMusdConversionConfirmed(0); + }, + ); + }); +}); diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 2494c51e957..9a0566a5619 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 1fdbf266c4a..4cf9f0dfe42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.64.0", + "version": "7.65.0", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", @@ -183,7 +183,8 @@ "qs": "6.14.1", "@playwright/test": "^1.57.0", "@metamask/transaction-controller@npm:^61.0.0": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", - "@metamask/transaction-controller@npm:^62.9.2": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" + "@metamask/transaction-controller@npm:^62.9.2": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", + "@metamask/transaction-controller@npm:^62.11.0": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -296,9 +297,8 @@ "@metamask/storage-service": "^0.0.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", - "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", - "@metamask/transaction-pay-controller": "^11.1.0", + "@metamask/transaction-pay-controller": "^12.0.2", "@metamask/tron-wallet-snap": "^1.19.2", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/scripts/install-ios-runway-app.sh b/scripts/install-ios-runway-app.sh index 6e7b6ed3bec..26a1ad240d1 100755 --- a/scripts/install-ios-runway-app.sh +++ b/scripts/install-ios-runway-app.sh @@ -13,7 +13,7 @@ BUNDLE_ID="io.metamask.MetaMask" # Get the repo root directory (script is in scripts/, so go up one level) readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -readonly RUNWAY_DIR="$REPO_ROOT/runway-artifacts" +readonly RUNWAY_DIR="$REPO_ROOT/build" RUNWAY_API_URL="https://app.runway.team/api/bucket/aCddXOkg1p_nDryri-FMyvkC9KRqQeVT_12sf6Nw0u6iGygGo6BlNzjD6bOt-zma260EzAxdpXmlp2GQphp3TN1s6AJE4i6d_9V0Tv5h4pHISU49dFk=/builds" # Ensure script is run from the repo root @@ -26,6 +26,7 @@ if [[ "$(pwd)" != "$REPO_ROOT" ]]; then fi UNINSTALL=false SKIP_DOWNLOAD=false +SKIP_INSTALL=false # Track files for cleanup ZIP_PATH="" @@ -70,9 +71,13 @@ while [[ $# -gt 0 ]]; do SKIP_DOWNLOAD=true shift ;; + --skipInstall) + SKIP_INSTALL=true + shift + ;; *) echo -e "${RED}Unknown option: $1${NC}" - echo "Usage: $0 [--skip-download] [--uninstall]" + echo "Usage: $0 [--skip-download] [--skipInstall] [--uninstall]" exit 1 ;; esac @@ -162,13 +167,8 @@ download_latest_app() { # Extract the .app bundle # The zip contains the contents of the .app, so we create the .app directory first - APP_NAME="${ARTIFACT_NAME%.zip}" - - # Validate APP_NAME ends with .app - if [[ ! "$APP_NAME" =~ \.app$ ]]; then - echo -e "${RED}❌ Invalid app name (must end with .app): $APP_NAME${NC}" - exit 1 - fi + # Always name the app "MetaMask.app" regardless of the artifact filename + APP_NAME="MetaMask.app" EXTRACTED_APP_PATH="$RUNWAY_DIR/$APP_NAME" @@ -205,6 +205,12 @@ if [ "$SKIP_DOWNLOAD" = false ]; then download_latest_app fi +# Skip installation if requested +if [ "$SKIP_INSTALL" = true ]; then + echo -e "${GREEN}✓ Download complete. Installation skipped (--skipInstall flag).${NC}" + exit 0 +fi + echo -e "${GREEN}Checking for running iOS simulator...${NC}" # Check if a simulator is booted @@ -219,14 +225,14 @@ fi echo -e "${GREEN}✓ Simulator is running:${NC}" echo " $BOOTED_DEVICE" -# Check if runway-artifacts directory exists +# Check if build directory exists if [[ ! -d "$RUNWAY_DIR" ]]; then echo -e "${RED}❌ Directory $RUNWAY_DIR does not exist${NC}" echo -e "${YELLOW}Run without --skip-download to download an app first${NC}" exit 1 fi -# Find the .app file with the highest version number in runway-artifacts +# Find the .app file with the highest version number in build APP_PATH=$(find "$RUNWAY_DIR" -name "*.app" -type d -maxdepth 1 2>/dev/null | sort -V | tail -1 || true) if [[ -z "$APP_PATH" ]]; then diff --git a/tests/api-mocking/mock-responses/musd/musd-mocks.ts b/tests/api-mocking/mock-responses/musd/musd-mocks.ts new file mode 100644 index 00000000000..361bb80f01b --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-mocks.ts @@ -0,0 +1,112 @@ +/** + * mUSD conversion E2E API mocks. + * Sets up feature flags, geolocation, ramp tokens, price APIs, token API, + * Merkl rewards, and Relay quote/status. + */ + +import { Mockttp } from 'mockttp'; +import { setupRemoteFeatureFlagsMock } from '../../helpers/remoteFeatureFlagsHelper.ts'; +import { setupMockRequest } from '../../helpers/mockHelpers.ts'; +import { getDecodedProxiedURL } from '../../../../e2e/specs/notifications/utils/helpers.ts'; +import { + mockRelayQuoteMainnetMusd, + mockRelayStatus, +} from '../transaction-pay.ts'; +import { MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; +import { MUSD_RAMP_TOKENS_RESPONSE } from './musd-ramp-tokens-response.ts'; +import { + MUSD_SPOT_PRICES_V3_RESPONSE, + MUSD_CHAINS_SPOT_PRICES_V2_RESPONSE, + MUSD_EXCHANGE_RATES_V1_RESPONSE, + MUSD_HISTORICAL_PRICES_RESPONSE, +} from './musd-price-responses.ts'; +import { MUSD_TOKEN_API_RESPONSE } from './musd-token-response.ts'; + +export async function setupMusdMocks(mockServer: Mockttp): Promise { + await setupRemoteFeatureFlagsMock(mockServer, { + earnMusdConversionFlowEnabled: { enabled: true, minimumVersion: '0.0.0' }, + earnMusdCtaEnabled: { enabled: true, minimumVersion: '0.0.0' }, + earnMusdConversionTokenListItemCtaEnabled: { + enabled: true, + minimumVersion: '0.0.0', + }, + earnMusdConversionAssetOverviewCtaEnabled: { + enabled: true, + minimumVersion: '0.0.0', + }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + earnMusdConvertibleTokensAllowlist: { '*': ['USDC'] }, + earnMusdConversionMinAssetBalanceRequired: 0.01, + earnMusdConversionGeoBlockedCountries: { blockedRegions: ['GB'] }, + }); + + await mockServer + .forGet('/proxy') + .matching((request) => { + const url = getDecodedProxiedURL(request.url); + return /on-ramp\.(dev-api|uat-api|api)\.cx\.metamask\.io\/geolocation/.test( + url, + ); + }) + .asPriority(998) + .thenCallback(() => ({ + statusCode: 200, + body: 'US', + headers: { 'content-type': 'text/plain' }, + })); + + await setupMockRequest(mockServer, { + url: /on-ramp-cache\.(uat-api|api)\.cx\.metamask\.io\/regions\/.*\/tokens/, + response: MUSD_RAMP_TOKENS_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v3\/spot-prices/, + response: MUSD_SPOT_PRICES_V3_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v2\/chains\/\d+\/spot-prices/, + response: MUSD_CHAINS_SPOT_PRICES_V2_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v1\/exchange-rates/, + response: MUSD_EXCHANGE_RATES_V1_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v3\/historical-prices/, + response: MUSD_HISTORICAL_PRICES_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: new RegExp( + `token\\.api\\.cx\\.metamask\\.io/token/1\\?address=${MUSD_MAINNET}`, + 'i', + ), + response: MUSD_TOKEN_API_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /api\.merkl\.xyz\/v4\/users\/0x[a-fA-F0-9]+\/rewards\?chainId=/, + response: [], + requestMethod: 'GET', + responseCode: 200, + }); + + await mockRelayQuoteMainnetMusd(mockServer); + await mockRelayStatus(mockServer); +} diff --git a/tests/api-mocking/mock-responses/musd/musd-price-responses.ts b/tests/api-mocking/mock-responses/musd/musd-price-responses.ts new file mode 100644 index 00000000000..fe0f5190780 --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-price-responses.ts @@ -0,0 +1,128 @@ +/** + * Price API mock responses for mUSD conversion E2E. + * v3 spot-prices, v2 chains spot-prices, v1 exchange-rates, historical-prices. + */ + +import { USDC_MAINNET, MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; + +const ETH_NATIVE = '0x0000000000000000000000000000000000000000'; + +export const MUSD_SPOT_PRICES_V3_RESPONSE = { + 'eip155:1/slip44:60': { + id: 'ethereum', + price: 0.999904095987313, + marketCap: 120707177.900275, + allTimeHigh: 1.6514207089624, + allTimeLow: 0.000144565964182697, + totalVolume: 9910501.61509202, + high1d: 1.01396406829944, + low1d: 0.972789150571307, + circulatingSupply: 120694373.7963051, + dilutedMarketCap: 120707177.900275, + marketCapPercentChange1d: 2.69249, + priceChange1d: 77.51, + pricePercentChange1h: -0.8412978033401519, + pricePercentChange1d: 2.656997542645518, + pricePercentChange7d: 2.280904394391741, + pricePercentChange14d: -9.26474467228875, + pricePercentChange30d: 2.3819062900759485, + pricePercentChange200d: 1.9842726591642341, + pricePercentChange1y: -5.689801242350527, + }, + [`eip155:1/erc20:${USDC_MAINNET}`]: { + id: 'usd-coin', + price: 0.000333804977889164, + marketCap: 23754743.4686059, + allTimeHigh: 0.000390647532775852, + allTimeLow: 0.000293034730938571, + totalVolume: 4928178.91900057, + high1d: 0.000333824677209193, + low1d: 0.000333722507854467, + circulatingSupply: 71166951334.28784, + dilutedMarketCap: 23754727.3558976, + marketCapPercentChange1d: -0.73791, + priceChange1d: 0.00013626, + pricePercentChange1h: 0.013840506207943954, + pricePercentChange1d: 0.013630960582563337, + pricePercentChange7d: -0.0009572939895655817, + pricePercentChange14d: -0.00423420136358611, + pricePercentChange30d: -0.000059529858641097485, + pricePercentChange200d: -0.014687097277342517, + pricePercentChange1y: -0.01926094991611645, + }, + [`eip155:1/erc20:${MUSD_MAINNET}`]: { + id: 'metamask-usd', + price: 0.000333694127478155, + marketCap: 7677.36891681464, + allTimeHigh: 0.000362267156463077, + allTimeLow: 0.000309008208387742, + totalVolume: 4392.14336541057, + high1d: 0.00035425387373947, + low1d: 0.000333370591188189, + circulatingSupply: 22999586.214533, + dilutedMarketCap: 7677.36891681464, + marketCapPercentChange1d: -7.9584, + priceChange1d: -0.000168554433610746, + pricePercentChange1h: -0.04427332716520257, + pricePercentChange1d: -0.01686233770769416, + pricePercentChange7d: 0.07342340725992479, + pricePercentChange14d: -0.09041741955087122, + pricePercentChange30d: -0.10435457604221866, + pricePercentChange200d: null, + pricePercentChange1y: null, + }, +}; + +export const MUSD_CHAINS_SPOT_PRICES_V2_RESPONSE = { + [ETH_NATIVE]: { + id: 'ethereum', + price: 3000.0, + pricePercentChange1d: 2.65, + }, + [USDC_MAINNET]: { + id: 'usd-coin', + price: 1.0, + pricePercentChange1d: 0.01, + }, + [MUSD_MAINNET]: { + id: 'metamask-usd', + price: 1.0, + pricePercentChange1d: -0.01, + }, +}; + +export const MUSD_EXCHANGE_RATES_V1_RESPONSE = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.0000112264812935107, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000333886780150301, + currencyType: 'crypto', + }, + usd: { name: 'US Dollar', ticker: 'usd', value: 1, currencyType: 'fiat' }, + eur: { + name: 'Euro', + ticker: 'eur', + value: 0.837579007063758, + currencyType: 'fiat', + }, + gbp: { + name: 'British Pound Sterling', + ticker: 'gbp', + value: 0.726810998426553, + currencyType: 'fiat', + }, +}; + +export const MUSD_HISTORICAL_PRICES_RESPONSE = { + prices: [ + [Date.now() - 86400000, 1.0], + [Date.now() - 43200000, 1.0], + [Date.now(), 1.0], + ], +}; diff --git a/tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts b/tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts new file mode 100644 index 00000000000..72760722502 --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts @@ -0,0 +1,37 @@ +/** + * Ramp tokens response for mUSD conversion E2E mocks. + * Used by on-ramp-cache regions/tokens endpoint. + */ + +import { USDC_MAINNET, MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; + +export const MUSD_RAMP_TOKENS_RESPONSE = { + topTokens: [ + { + assetId: `eip155:1/erc20:${MUSD_MAINNET}`, + chainId: 'eip155:1', + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + tokenSupported: true, + }, + ], + allTokens: [ + { + assetId: `eip155:1/erc20:${MUSD_MAINNET}`, + chainId: 'eip155:1', + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + tokenSupported: true, + }, + { + assetId: `eip155:1/erc20:${USDC_MAINNET}`, + chainId: 'eip155:1', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + tokenSupported: true, + }, + ], +}; diff --git a/tests/api-mocking/mock-responses/musd/musd-token-response.ts b/tests/api-mocking/mock-responses/musd/musd-token-response.ts new file mode 100644 index 00000000000..dfa36703938 --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-token-response.ts @@ -0,0 +1,15 @@ +/** + * Token API mock response for mUSD (token metadata). + */ + +import { MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; + +export const MUSD_TOKEN_API_RESPONSE = { + address: MUSD_MAINNET, + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + chainId: 1, + logoURI: '', + aggregators: [], +}; diff --git a/tests/api-mocking/mock-responses/transaction-pay.ts b/tests/api-mocking/mock-responses/transaction-pay.ts index 6b3778307f1..b754fcb54d9 100644 --- a/tests/api-mocking/mock-responses/transaction-pay.ts +++ b/tests/api-mocking/mock-responses/transaction-pay.ts @@ -1,5 +1,6 @@ import { Mockttp } from 'mockttp'; -import { DEFAULT_FIXTURE_ACCOUNT } from '../../framework/fixtures/FixtureBuilder.ts'; +import { USDC_MAINNET, MUSD_MAINNET } from '../../constants/musd-mainnet'; +import { DEFAULT_FIXTURE_ACCOUNT } from '../../framework/fixtures/FixtureBuilder'; export const RELAY_QUOTE_MOCK = { steps: [ @@ -310,6 +311,86 @@ export const RELAY_QUOTE_MOCK = { }, }; +/** + * Relay quote mock for Mainnet mUSD conversion (chainId 1, USDC → mUSD). + * TransactionPayController's normalizeQuote expects: + * - details.currencyIn/currencyOut with chainId matching the request (1) + * - steps[].items[].data.chainId = 1 so gas/network lookups use Mainnet + * - details.timeEstimate, details.totalImpact.usd + */ +export const MAINNET_MUSD_RELAY_QUOTE_MOCK = { + steps: [ + { + id: 'deposit', + action: 'Confirm transaction in your wallet', + description: 'Convert USDC to mUSD', + kind: 'transaction', + items: [ + { + status: 'incomplete', + data: { + from: DEFAULT_FIXTURE_ACCOUNT, + to: '0x00000000aa467eba42a3d604b3d74d63b2b6c6cb', + data: '0x470b5f3b22544142d6b2116ec296913046fe06578b495e602ac2fe0c87b843de', + value: '0', + chainId: 1, + gas: '100000', + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + }, + check: { + endpoint: + '/intents/status?requestId=0x470b5f3b22544142d6b2116ec296913046fe06578b495e602ac2fe0c87b843de', + method: 'GET', + }, + }, + ], + requestId: + '0x470b5f3b22544142d6b2116ec296913046fe06578b495e602ac2fe0c87b843de', + depositAddress: '', + }, + ], + details: { + operation: 'swap', + sender: DEFAULT_FIXTURE_ACCOUNT, + recipient: DEFAULT_FIXTURE_ACCOUNT, + currencyIn: { + currency: { + chainId: 1, + address: USDC_MAINNET, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + metadata: { logoURI: '', verified: true }, + }, + amount: '100000000', + amountFormatted: '100', + amountUsd: '100', + minimumAmount: '100000000', + }, + currencyOut: { + currency: { + chainId: 1, + address: MUSD_MAINNET, + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + metadata: { logoURI: '', verified: true }, + }, + amount: '100100000', + amountFormatted: '100.1', + amountUsd: '100.1', + minimumAmount: '100000000', + }, + totalImpact: { usd: '-0.01', percent: '-0.01' }, + timeEstimate: 4, + }, + fees: { + relayer: { amountUsd: '0.01' }, + }, + metamask: { gasLimits: [100000] }, +}; + export const RELAY_STATUS_MOCK = { status: 'success', txHashes: [ @@ -330,6 +411,23 @@ export async function mockRelayQuote(mockServer: Mockttp) { })); } +/** + * Mocks Relay quote API for Mainnet mUSD conversion (chainId 1, USDC → mUSD). + * Use this in mUSD conversion E2E so normalizeQuote uses Mainnet for gas/rates. + */ +export async function mockRelayQuoteMainnetMusd(mockServer: Mockttp) { + await mockServer + .forPost('/proxy') + .matching((request) => { + const url = new URL(request.url).searchParams.get('url'); + return Boolean(url?.includes('api.relay.link/quote')); + }) + .thenCallback(() => ({ + statusCode: 200, + json: MAINNET_MUSD_RELAY_QUOTE_MOCK, + })); +} + export async function mockRelayStatus(mockServer: Mockttp) { await mockServer .forGet('/proxy') diff --git a/tests/constants/musd-mainnet.ts b/tests/constants/musd-mainnet.ts new file mode 100644 index 00000000000..83e7520eebe --- /dev/null +++ b/tests/constants/musd-mainnet.ts @@ -0,0 +1,8 @@ +/** + * Mainnet token addresses for mUSD conversion E2E and API mocks. + * Single source of truth so fixture, mocks, and transaction-pay stay in sync. + */ +export const USDC_MAINNET = + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const; +export const MUSD_MAINNET = + '0xaca92e438df0b2401ff60da7e4337b687a2435da' as const; diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index da5bb83bea8..51a9045b9cf 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -8,6 +8,7 @@ import { import { merge } from 'lodash'; import { encryptVault } from './helpers.ts'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { Caip25CaveatType, @@ -37,6 +38,7 @@ import { MOCK_ENTROPY_SOURCE_3, } from '../../../app/util/test/keyringControllerTestUtils.ts'; import { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import { USDC_MAINNET, MUSD_MAINNET } from '../../constants/musd-mainnet.ts'; export const DEFAULT_FIXTURE_ACCOUNT_CHECKSUM = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; @@ -68,6 +70,17 @@ export const SIMPLE_KEYRING_SNAP_ID = export const GENERIC_SNAP_WALLET_1_ID = 'snap:npm:@metamask/generic-snap-1'; export const GENERIC_SNAP_WALLET_2_ID = 'snap:npm:@metamask/generic-snap-2'; +/** + * Options for mUSD conversion E2E fixture state. + */ +export interface MusdFixtureOptions { + musdConversionEducationSeen: boolean; + hasUsdcBalance?: boolean; + usdcBalance?: number; + hasMusdBalance?: boolean; + musdBalance?: number; +} + /** * FixtureBuilder class provides a fluent interface for building fixture data. */ @@ -2293,6 +2306,97 @@ class FixtureBuilder { return this; } + /** + * Sets mUSD conversion fixture state: user flags, fiat orders, currency rates, + * and Mainnet token balances (USDC, optional MUSD) and native ETH for the default account. + * Call after withNetworkController, withTokensForAllPopularNetworks([ETH, USDC, MUSD?]), and withTokenRates. + * + * @param options - mUSD conversion options (education seen, USDC/MUSD balances). + * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + */ + withMusdConversion(options: MusdFixtureOptions) { + const USDC_DECIMALS = 6; + const MUSD_DECIMALS = 6; + const ETH_BALANCE_WEI = '0x' + (BigInt(10) * BigInt(10 ** 18)).toString(16); + + merge(this.fixture.state.user, { + musdConversionEducationSeen: options.musdConversionEducationSeen, + }); + + this.fixture.state.fiatOrders = this.fixture.state.fiatOrders ?? {}; + merge(this.fixture.state.fiatOrders, { + detectedGeolocation: 'US', + rampRoutingDecision: 'AGGREGATOR', + }); + + if (!this.fixture.state.engine.backgroundState.CurrencyRateController) { + merge(this.fixture.state.engine.backgroundState, { + CurrencyRateController: { currentCurrency: 'usd', currencyRates: {} }, + }); + } + merge(this.fixture.state.engine.backgroundState.CurrencyRateController, { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate: Date.now() / 1000, + conversionRate: 3000.0, + usdConversionRate: 3000.0, + }, + }, + }); + + const ac = this.fixture.state.engine.backgroundState.AccountsController; + const accountId = ac?.internalAccounts?.selectedAccount; + const accountAddress = ac?.internalAccounts?.accounts?.[accountId]?.address; + if (!accountAddress) return this; + + const engine = this.fixture.state.engine.backgroundState; + if (!engine.AccountTrackerController) { + merge(engine, { + AccountTrackerController: { accounts: {}, accountsByChainId: {} }, + }); + } + const atc = engine.AccountTrackerController; + atc.accounts = atc.accounts ?? {}; + atc.accountsByChainId = atc.accountsByChainId ?? {}; + atc.accounts[accountAddress] = { balance: ETH_BALANCE_WEI }; + atc.accountsByChainId[CHAIN_IDS.MAINNET] = { + ...atc.accountsByChainId[CHAIN_IDS.MAINNET], + [accountAddress]: { balance: ETH_BALANCE_WEI }, + }; + + if (!engine.TokenBalancesController) { + merge(engine, { TokenBalancesController: { tokenBalances: {} } }); + } + engine.TokenBalancesController.tokenBalances = + engine.TokenBalancesController.tokenBalances ?? {}; + const tb = engine.TokenBalancesController.tokenBalances; + if (!tb[accountAddress]) tb[accountAddress] = {}; + if (!tb[accountAddress][CHAIN_IDS.MAINNET]) + tb[accountAddress][CHAIN_IDS.MAINNET] = {}; + const mainnetBalances = tb[accountAddress][CHAIN_IDS.MAINNET] as Record< + string, + string + >; + + if (options.hasUsdcBalance !== false) { + mainnetBalances[toChecksumHexAddress(USDC_MAINNET.toLowerCase())] = + '0x' + + Math.floor((options.usdcBalance ?? 100) * 10 ** USDC_DECIMALS).toString( + 16, + ); + } + if (options.hasMusdBalance) { + mainnetBalances[toChecksumHexAddress(MUSD_MAINNET.toLowerCase())] = + '0x' + + Math.floor((options.musdBalance ?? 10) * 10 ** MUSD_DECIMALS).toString( + 16, + ); + } + + return this; + } + /** * Build and return the fixture object. * @returns {Object} - The built fixture object. diff --git a/tests/page-objects/Trending/TrendingView.ts b/tests/page-objects/Trending/TrendingView.ts index dca82994412..bae25f41150 100644 --- a/tests/page-objects/Trending/TrendingView.ts +++ b/tests/page-objects/Trending/TrendingView.ts @@ -1,4 +1,9 @@ -import { Matchers, Gestures, Assertions } from '../../framework'; +import { + Matchers, + Gestures, + Assertions, + type ScrollOptions, +} from '../../framework'; import { TrendingViewSelectorsIDs, SECTION_BACK_BUTTONS, @@ -111,14 +116,14 @@ class TrendingView { } /** - * Generic method to scroll to an element in the trending feed. - * This ensures elements are visible and hittable before interaction. - * Works regardless of section order changes. + * Scrolls the feed until the target element is visible (same pattern as WalletView.scrollToToken). + * Uses Gestures.scrollToElement which retries scroll + visibility check until the element is on screen. */ private async scrollToElementInFeed( targetElement: DetoxElement, description: string, direction: 'up' | 'down' = 'down', + options: Partial = {}, ): Promise { await Gestures.scrollToElement( targetElement, @@ -127,6 +132,7 @@ class TrendingView { direction, scrollAmount: 300, elemDescription: description, + ...options, }, ); } @@ -296,10 +302,8 @@ class TrendingView { } /** - * Generic method to tap on an item row with automatic scrolling. - * @param getElement - Function to get the element - * @param identifier - Item identifier (id, symbol, name, etc.) - * @param itemType - Type of item for description ('token', 'perp', 'prediction', 'site') + * Tap on an item row after scrolling until it is visible (same pattern as WalletView.scrollToToken + tap). + * Gestures.scrollToElement retries scroll until the element is visible. */ private async tapItemRow( getElement: () => DetoxElement, @@ -308,10 +312,15 @@ class TrendingView { ): Promise { const targetElement = getElement(); - // Use generic scroll method to ensure element is visible + // Sites section is typically lower in the feed; give scroll retry more time (same idea as WalletView.scrollDownToAssetOverviewMusdCta) + const scrollOptions: Partial = + itemType === 'site' ? { timeout: 15000 } : {}; + await this.scrollToElementInFeed( targetElement, `Scroll to ${identifier} ${itemType} row`, + 'down', + scrollOptions, ); await Gestures.tap(targetElement, { @@ -352,7 +361,17 @@ class TrendingView { } async verifySiteVisible(name: string): Promise { - await this.verifyItemVisible(() => this.getSiteRow(name), name, 'site'); + const siteRow = () => this.getSiteRow(name); + + // Scroll until Site row is visible (same pattern as WalletView.scrollDownToAssetOverviewMusdCta) + await this.scrollToElementInFeed( + siteRow(), + `Scroll to Site row for ${name}`, + 'down', + { timeout: 15000 }, + ); + + await this.verifyItemVisible(siteRow, name, 'site'); } async tapSiteRow(name: string): Promise { diff --git a/yarn.lock b/yarn.lock index bf45c6bde9f..98a08efec24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7424,9 +7424,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^96.0.0": - version: 96.0.0 - resolution: "@metamask/assets-controllers@npm:96.0.0" +"@metamask/assets-controllers@npm:^97.0.0": + version: 97.0.0 + resolution: "@metamask/assets-controllers@npm:97.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7444,11 +7444,12 @@ __metadata: "@metamask/core-backend": "npm:^5.0.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.0.0" - "@metamask/keyring-controller": "npm:^25.0.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^5.1.0" "@metamask/network-controller": "npm:^29.0.0" + "@metamask/network-enablement-controller": "npm:^4.1.0" "@metamask/permission-controller": "npm:^12.2.0" "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.2" @@ -7474,13 +7475,13 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/c5cf7363972b2f267ba96a925fd74eaee3eebde8bf470af7d4c49589b33b34fc9b8574289e4592cbce13e941201893d2ad20018da0dada8025317db0ce33df0f + checksum: 10/44f6adc0f3263a17c2be49aff7c558f0478c41f8c0318c03db5706451284ccf56c5534d56366c6504538e89eda8aa86669310467a170276f28875c17bcbd6367 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^97.0.0": - version: 97.0.0 - resolution: "@metamask/assets-controllers@npm:97.0.0" +"@metamask/assets-controllers@npm:^98.0.0": + version: 98.0.0 + resolution: "@metamask/assets-controllers@npm:98.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7513,6 +7514,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/storage-service": "npm:^0.0.1" "@metamask/transaction-controller": "npm:^62.9.2" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" @@ -7529,13 +7531,13 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/44f6adc0f3263a17c2be49aff7c558f0478c41f8c0318c03db5706451284ccf56c5534d56366c6504538e89eda8aa86669310467a170276f28875c17bcbd6367 + checksum: 10/a2a3564ae4cb5349a134c40844db62db43d8aefa7ac00c8d6a31ace63573b6ea2a77988e1daae7719a99a79736f4dee3af7886d85829cd2efd68dd55b55fb5f6 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^98.0.0": - version: 98.0.0 - resolution: "@metamask/assets-controllers@npm:98.0.0" +"@metamask/assets-controllers@npm:^99.0.0, @metamask/assets-controllers@npm:^99.1.0": + version: 99.1.0 + resolution: "@metamask/assets-controllers@npm:99.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7569,7 +7571,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/storage-service": "npm:^0.0.1" - "@metamask/transaction-controller": "npm:^62.9.2" + "@metamask/transaction-controller": "npm:^62.11.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7585,7 +7587,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/a2a3564ae4cb5349a134c40844db62db43d8aefa7ac00c8d6a31ace63573b6ea2a77988e1daae7719a99a79736f4dee3af7886d85829cd2efd68dd55b55fb5f6 + checksum: 10/01e9e33f50d5207817264c969ee37ad534399756d580ff1ebb965018cba0c669a181ba70273ba6901e5c71c2f5acd7506b2ff12432c9b218cfa778e3d12c59b7 languageName: node linkType: hard @@ -7658,7 +7660,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^64.8.0, @metamask/bridge-controller@npm:^64.8.1, @metamask/bridge-controller@npm:^64.8.2": +"@metamask/bridge-controller@npm:^64.8.0, @metamask/bridge-controller@npm:^64.8.2": version: 64.8.2 resolution: "@metamask/bridge-controller@npm:64.8.2" dependencies: @@ -7689,7 +7691,38 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^64.4.4, @metamask/bridge-status-controller@npm:^64.4.5": +"@metamask/bridge-controller@npm:^65.1.0": + version: 65.1.0 + resolution: "@metamask/bridge-controller@npm:65.1.0" + dependencies: + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/assets-controllers": "npm:^99.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/gas-fee-controller": "npm:^26.0.2" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain-network-controller": "npm:^3.0.2" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + reselect: "npm:^5.1.1" + uuid: "npm:^8.3.2" + checksum: 10/778c219ecd44f808f936ebb6f5ffe1dd8689e3b160522482d861f7be37d7fed6488561f4d5d4adbcdf63a57f1cedb0f50ff65e06098af5ecf20abfb2ecbbcabb + languageName: node + linkType: hard + +"@metamask/bridge-status-controller@npm:^64.4.5": version: 64.4.5 resolution: "@metamask/bridge-status-controller@npm:64.4.5" dependencies: @@ -7710,6 +7743,27 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@npm:^65.0.1": + version: 65.0.1 + resolution: "@metamask/bridge-status-controller@npm:65.0.1" + dependencies: + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^65.1.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/gas-fee-controller": "npm:^26.0.2" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/aaf19debd3f19ebd1ca7ff1faada30b4aae5dcd18f24d657293295922b4a4f621f6bb095f2135f70430c187c45d83b7463d300ae2b008774f236b2208c004972 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -9624,17 +9678,6 @@ __metadata: languageName: node linkType: hard -"@metamask/token-search-discovery-controller@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/token-search-discovery-controller@npm:4.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" - checksum: 10/0e4f067acc872bd538db41ec07e55104d641caa522565c2a55c8be85bffcf6368ac2c24205c2e0b55230c73f36e9793c4366f3551edad69291e375f18b84a64f - languageName: node - linkType: hard - "@metamask/toprf-secure-backup@npm:^0.10.0": version: 0.10.1 resolution: "@metamask/toprf-secure-backup@npm:0.10.1" @@ -9729,29 +9772,29 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^11.1.0": - version: 11.1.0 - resolution: "@metamask/transaction-pay-controller@npm:11.1.0" +"@metamask/transaction-pay-controller@npm:^12.0.2": + version: 12.0.2 + resolution: "@metamask/transaction-pay-controller@npm:12.0.2" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^96.0.0" + "@metamask/assets-controllers": "npm:^99.1.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^64.8.1" - "@metamask/bridge-status-controller": "npm:^64.4.4" + "@metamask/bridge-controller": "npm:^65.1.0" + "@metamask/bridge-status-controller": "npm:^65.0.1" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "npm:^62.9.2" + "@metamask/transaction-controller": "npm:^62.11.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/98385db74a16ed91e21d5e646bf302046b80bbf6f48303f8393b9bf98c42e6f5dac0b5c883abb999be884f2cebf422b15dd34aa04cc7a215ea17a15c10a4e1ad + checksum: 10/e22613a2dce4670bd00d09e698666bf633737bef99125ebcc186dceb151c629c3386ef6f3373c436e9970b09fc1c52bd1c9ffc4e26140bb6a8a3f7cf75ca4299 languageName: node linkType: hard @@ -34772,9 +34815,8 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" - "@metamask/transaction-pay-controller": "npm:^11.1.0" + "@metamask/transaction-pay-controller": "npm:^12.0.2" "@metamask/tron-wallet-snap": "npm:^1.19.2" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6"