diff --git a/app/components/UI/AccountOverview/index.js b/app/components/UI/AccountOverview/index.js index 91bc8793d594..715884466a15 100644 --- a/app/components/UI/AccountOverview/index.js +++ b/app/components/UI/AccountOverview/index.js @@ -45,6 +45,7 @@ import Text, { } from '../../../component-library/components/Texts/Text'; import { withMetricsAwareness } from '../../../components/hooks/useMetrics'; import { isPortfolioUrl } from '../../../util/url'; +import { buildPortfolioUrl } from '../../../util/browser'; const createStyles = (colors) => StyleSheet.create({ @@ -189,6 +190,10 @@ class AccountOverview extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Whether data collection for marketing is enabled + */ + isDataCollectionForMarketingEnabled: PropTypes.bool, }; state = { @@ -301,7 +306,7 @@ class AccountOverview extends PureComponent { }; onOpenPortfolio = () => { - const { navigation, browserTabs } = this.props; + const { navigation, browserTabs, metrics } = this.props; const existingPortfolioTab = browserTabs.find((tab) => isPortfolioUrl(tab.url), ); @@ -310,7 +315,16 @@ class AccountOverview extends PureComponent { if (existingPortfolioTab) { existingTabId = existingPortfolioTab.id; } else { - newTabUrl = `${AppConstants.PORTFOLIO.URL}/?metamaskEntry=mobile`; + const additionalParams = { + metricsEnabled: metrics.isEnabled(), + marketingEnabled: + this.props.isDataCollectionForMarketingEnabled ?? false, + }; + const portfolioUrl = buildPortfolioUrl( + AppConstants.PORTFOLIO.URL, + additionalParams, + ); + newTabUrl = portfolioUrl.href; } const params = { ...(newTabUrl && { newTabUrl }), @@ -440,6 +454,8 @@ const mapStateToProps = (state) => ({ currentCurrency: selectCurrentCurrency(state), chainId: selectChainId(state), browserTabs: state.browser.tabs, + isDataCollectionForMarketingEnabled: + state.security.dataCollectionForMarketing, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/UI/Bridge/hooks/useGoToPortfolioBridge.ts b/app/components/UI/Bridge/hooks/useGoToPortfolioBridge.ts index 151b96c1e72f..89889af8b7d6 100644 --- a/app/components/UI/Bridge/hooks/useGoToPortfolioBridge.ts +++ b/app/components/UI/Bridge/hooks/useGoToPortfolioBridge.ts @@ -11,6 +11,7 @@ import type { BrowserParams } from '../../../Views/Browser/Browser.types'; import { getDecimalChainId } from '../../../../util/networks'; import { useMetrics } from '../../../hooks/useMetrics'; import { isBridgeUrl } from '../../../../util/url'; +import { useBuildPortfolioUrl } from '../../../hooks/useBuildPortfolioUrl'; /** * Returns a function that is used to navigate to the MetaMask Bridges webpage. @@ -24,6 +25,7 @@ export default function useGoToPortfolioBridge(location: string) { const browserTabs = useSelector((state: any) => state.browser.tabs); const { navigate } = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); + const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); return (address?: string) => { const existingBridgeTab = browserTabs.find((tab: BrowserTab) => isBridgeUrl(tab.url), @@ -37,11 +39,20 @@ export default function useGoToPortfolioBridge(location: string) { params.newTabUrl = undefined; params.existingTabId = existingBridgeTab.id; } else { - params.newTabUrl = `${ - AppConstants.BRIDGE.URL - }/?metamaskEntry=mobile&srcChain=${getDecimalChainId(chainId)}${ - address ? `&token=${address}` : '' - }`; + const additionalParams: Record = { + srcChain: getDecimalChainId(chainId), + }; + + if (address) { + additionalParams.token = address; + } + + const bridgeUrl = buildPortfolioUrlWithMetrics( + AppConstants.BRIDGE.URL, + additionalParams, + ); + + params.newTabUrl = bridgeUrl.href; } navigate(Routes.BROWSER.HOME, { diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts index 4edcb9a547e1..32d90b00a6ab 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts @@ -38,4 +38,5 @@ export enum NetworkToCaipChainId { LOCALHOST = 'eip155:1337', ETHEREUM_SEPOLIA = 'eip155:11155111', LINEA_SEPOLIA = 'eip155:59141', + SEI = 'eip155:1329', } diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 29e8e7088ed1..be251c998719 100644 --- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx @@ -84,6 +84,16 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../../../hooks/useMetrics'); +jest.mock('../../../../hooks/useBuildPortfolioUrl', () => ({ + useBuildPortfolioUrl: jest.fn(() => (baseUrl: string) => { + const url = new URL(baseUrl); + url.searchParams.set('metamaskEntry', 'mobile'); + url.searchParams.set('marketingEnabled', 'true'); + url.searchParams.set('metricsEnabled', 'true'); + return url; + }), +})); + // Mock the environment variables jest.mock('../../../../../util/environment', () => ({ isProduction: jest.fn().mockReturnValue(false), @@ -256,7 +266,7 @@ describe('StakeButton', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { params: { - newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`, + newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile&marketingEnabled=true&metricsEnabled=true`, timestamp: expect.any(Number), }, screen: Routes.BROWSER.VIEW, diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index 7c1d28bc1c36..67b4ccefb46b 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -13,6 +13,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import AppConstants from '../../../../../core/AppConstants'; import Engine from '../../../../../core/Engine'; import { RootState } from '../../../../../reducers'; +import { useBuildPortfolioUrl } from '../../../../hooks/useBuildPortfolioUrl'; import { selectEvmChainId, selectNetworkConfigurationByChainId, @@ -57,6 +58,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { const styles = createStyles(colors); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); + const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); const browserTabs = useSelector((state: RootState) => state.browser.tabs); const chainId = useSelector(selectEvmChainId); @@ -149,7 +151,8 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { if (existingStakeTab) { existingTabId = existingStakeTab.id; } else { - newTabUrl = `${AppConstants.STAKE.URL}?metamaskEntry=mobile`; + const stakeUrl = buildPortfolioUrlWithMetrics(AppConstants.STAKE.URL); + newTabUrl = stakeUrl.href; } const params = { ...(newTabUrl && { newTabUrl }), diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index c901c19dc479..c563d700c690 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -4,6 +4,11 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import TrendingTokenRowItem from './TrendingTokenRowItem'; import type { TrendingAsset } from '@metamask/assets-controllers'; +// Mock the trendingNetworksList module to avoid getNetworkImageSource errors +jest.mock('../../utils/trendingNetworksList', () => ({ + TRENDING_NETWORKS_LIST: [], +})); + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx index ec3554312d49..d8334233b56f 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx @@ -15,32 +15,33 @@ jest.mock('../../../../../util/networks', () => ({ mockGetNetworkImageSource(params), })); -const mockNetworks: ProcessedNetwork[] = [ - { - id: 'eip155:1', - name: 'Ethereum Mainnet', - caipChainId: 'eip155:1' as CaipChainId, - imageSource: { - uri: 'https://example.com/ethereum.png', - } as ImageSourcePropType, - isSelected: false, - }, - { - id: 'eip155:137', - name: 'Polygon', - caipChainId: 'eip155:137' as CaipChainId, - imageSource: { - uri: 'https://example.com/polygon.png', - } as ImageSourcePropType, - isSelected: false, - }, -]; - -const mockUsePopularNetworks = jest.fn(() => mockNetworks); +// Mock the TRENDING_NETWORKS_LIST constant +jest.mock('../../utils/trendingNetworksList', () => { + const mockNetworks: ProcessedNetwork[] = [ + { + id: 'eip155:1', + name: 'Ethereum Mainnet', + caipChainId: 'eip155:1' as CaipChainId, + imageSource: { + uri: 'https://example.com/ethereum.png', + } as ImageSourcePropType, + isSelected: false, + }, + { + id: 'eip155:137', + name: 'Polygon', + caipChainId: 'eip155:137' as CaipChainId, + imageSource: { + uri: 'https://example.com/polygon.png', + } as ImageSourcePropType, + isSelected: false, + }, + ]; -jest.mock('../../hooks/usePopularNetworks/usePopularNetworks', () => ({ - usePopularNetworks: () => mockUsePopularNetworks(), -})); + return { + TRENDING_NETWORKS_LIST: mockNetworks, + }; +}); let storedOnClose: (() => void) | undefined; @@ -209,7 +210,6 @@ describe('TrendingTokenNetworkBottomSheet', () => { storedOnClose = undefined; mockOnClose.mockClear(); mockOnOpenBottomSheet.mockClear(); - mockUsePopularNetworks.mockReturnValue(mockNetworks); mockGetNetworkImageSource.mockImplementation( (params: { chainId: string }) => { if (params.chainId === 'eip155:1') { diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index b7b2e94382fc..0b027f38e3ff 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -19,7 +19,7 @@ import Avatar, { import { strings } from '../../../../../../locales/i18n'; import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { CaipChainId } from '@metamask/utils'; -import { usePopularNetworks } from '../../hooks/usePopularNetworks/usePopularNetworks'; +import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; export enum NetworkOption { AllNetworks = 'all', @@ -51,7 +51,7 @@ const TrendingTokenNetworkBottomSheet: React.FC< }) => { const sheetRef = useRef(null); const { colors } = useTheme(); - const networks = usePopularNetworks(); + const networks = TRENDING_NETWORKS_LIST; // Default to "All networks" if no selection const [selectedNetwork, setSelectedNetwork] = useState< diff --git a/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts deleted file mode 100644 index b20e30f2c582..000000000000 --- a/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { useSelector } from 'react-redux'; -import { CaipChainId } from '@metamask/utils'; -import { BtcScope, SolScope } from '@metamask/keyring-api'; -import { isTestNet } from '../../../../../util/networks'; -import { usePopularNetworks, EXCLUDED_NETWORKS } from './usePopularNetworks'; - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - -jest.mock('../../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(), - isTestNet: jest.fn(), -})); - -jest.mock('../../../../../util/networks/customNetworks', () => ({ - PopularList: [ - { - chainId: '0xa86a', - nickname: 'Avalanche', - }, - { - chainId: '0xa4b1', - nickname: 'Arbitrum', - }, - { - chainId: '0x38', - nickname: 'BNB Chain', - }, - ], -})); - -describe('usePopularNetworks', () => { - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockIsTestNet = isTestNet as jest.MockedFunction; - - beforeEach(() => { - jest.clearAllMocks(); - mockIsTestNet.mockReturnValue(false); - }); - - describe('basic functionality', () => { - it('returns networks from networkConfigurations', () => { - const mockNetworkConfigurations = { - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - 'eip155:137': { - caipChainId: 'eip155:137' as CaipChainId, - name: 'Polygon', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - // Should have 2 from networkConfigurations + 3 from PopularList = 5 total - expect(result.current.length).toBeGreaterThanOrEqual(5); - expect(result.current.some((n) => n.name === 'Ethereum Mainnet')).toBe( - true, - ); - expect(result.current.some((n) => n.name === 'Polygon')).toBe(true); - expect(result.current.some((n) => n.name === 'Avalanche')).toBe(true); - expect(result.current.some((n) => n.name === 'Arbitrum')).toBe(true); - expect(result.current.some((n) => n.name === 'BNB Chain')).toBe(true); - }); - - it('adds networks from PopularList that do not exist in networkConfigurations', () => { - const mockNetworkConfigurations = { - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - // Should have Ethereum Mainnet + 3 networks from PopularList - expect(result.current.length).toBeGreaterThanOrEqual(4); - expect(result.current.some((n) => n.name === 'Ethereum Mainnet')).toBe( - true, - ); - expect(result.current.some((n) => n.name === 'Avalanche')).toBe(true); - expect(result.current.some((n) => n.name === 'Arbitrum')).toBe(true); - expect(result.current.some((n) => n.name === 'BNB Chain')).toBe(true); - }); - - it('does not duplicate networks that exist in both networkConfigurations and PopularList', () => { - const mockNetworkConfigurations = { - 'eip155:43114': { - caipChainId: 'eip155:43114' as CaipChainId, - name: 'Avalanche', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - const avalancheNetworks = result.current.filter( - (n) => n.name === 'Avalanche', - ); - expect(avalancheNetworks).toHaveLength(1); - }); - }); - - describe('testnet filtering', () => { - it('filters out EVM testnets from networkConfigurations', () => { - const mockNetworkConfigurations = { - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - 'eip155:11155111': { - caipChainId: 'eip155:11155111' as CaipChainId, - name: 'Sepolia', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - // Sepolia chain ID in hex - mockIsTestNet.mockImplementation((chainId) => chainId === '0xaa36a7'); - - const { result } = renderHook(() => usePopularNetworks()); - - // Should have 1 from networkConfigurations (Ethereum Mainnet) + 3 from PopularList = 4 total - // Sepolia should be filtered out as it's a testnet - expect(result.current.length).toBeGreaterThanOrEqual(4); - expect(result.current.some((n) => n.name === 'Ethereum Mainnet')).toBe( - true, - ); - expect(result.current.some((n) => n.name === 'Sepolia')).toBe(false); - expect(result.current.some((n) => n.name === 'Avalanche')).toBe(true); - expect(result.current.some((n) => n.name === 'Arbitrum')).toBe(true); - expect(result.current.some((n) => n.name === 'BNB Chain')).toBe(true); - }); - - it('filters out Bitcoin testnets from networkConfigurations', () => { - const mockNetworkConfigurations = { - // Bitcoin testnet variants using full CAIP IDs from BtcScope - [BtcScope.Testnet]: { - caipChainId: BtcScope.Testnet as CaipChainId, - name: 'Bitcoin Testnet', - }, - [BtcScope.Testnet4]: { - caipChainId: BtcScope.Testnet4 as CaipChainId, - name: 'Bitcoin Testnet4', - }, - [BtcScope.Regtest]: { - caipChainId: BtcScope.Regtest as CaipChainId, - name: 'Bitcoin Regtest', - }, - [BtcScope.Signet]: { - caipChainId: BtcScope.Signet as CaipChainId, - name: 'Bitcoin Signet', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - expect(result.current.some((n) => n.name === 'Bitcoin Testnet')).toBe( - false, - ); - expect(result.current.some((n) => n.name === 'Bitcoin Testnet4')).toBe( - false, - ); - expect(result.current.some((n) => n.name === 'Bitcoin Regtest')).toBe( - false, - ); - expect(result.current.some((n) => n.name === 'Bitcoin Signet')).toBe( - false, - ); - }); - - it('filters out Solana Devnet from networkConfigurations', () => { - const mockNetworkConfigurations = { - [SolScope.Mainnet]: { - caipChainId: SolScope.Mainnet as CaipChainId, - name: 'Solana Mainnet', - }, - [SolScope.Devnet]: { - caipChainId: SolScope.Devnet as CaipChainId, - name: 'Solana Devnet', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - expect(result.current.some((n) => n.name === 'Solana Mainnet')).toBe( - true, - ); - expect(result.current.some((n) => n.name === 'Solana Devnet')).toBe( - false, - ); - }); - }); - - describe('custom network filtering', () => { - it('filters EVM custom networks from networkConfigurations', () => { - const mockNetworkConfigurations = { - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - 'eip155:81457': { - caipChainId: 'eip155:81457' as CaipChainId, - chainId: '0x13e31', - name: 'blast', - rpcEndpoints: [ - { - url: 'https://blast-rpc.publicnode.com', - name: '', - // Match RpcEndpointType.Custom value used in the hook - type: 'custom', - networkClientId: '0c8dd6d9-a167-4656-9057-b5daf33dbbde', - }, - ], - nativeCurrency: 'ETH', - defaultRpcEndpointIndex: 0, - lastUpdatedAt: 1763644775633, - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - expect( - result.current.some( - (network) => network.caipChainId === 'eip155:81457', - ), - ).toBe(false); - expect( - result.current.some((network) => network.caipChainId === 'eip155:1'), - ).toBe(true); - }); - }); - - describe('excluded networks filtering', () => { - it('filters out all excluded networks from networkConfigurations', () => { - const mockNetworkConfigurations = { - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - 'eip155:11297108109': { - caipChainId: 'eip155:11297108109' as CaipChainId, - name: 'Palm', - }, - 'eip155:999': { - caipChainId: 'eip155:999' as CaipChainId, - name: 'Hyper EVM', - }, - 'eip155:143': { - caipChainId: 'eip155:143' as CaipChainId, - name: 'Monad', - }, - 'bip122:000000000019d6689c085ae165831e93': { - caipChainId: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId, - name: 'Bitcoin Mainnet', - }, - 'eip155:137': { - caipChainId: 'eip155:137' as CaipChainId, - name: 'Polygon', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - const resultChainIds = result.current.map((n) => n.caipChainId); - EXCLUDED_NETWORKS.forEach((excludedChainId) => { - expect(resultChainIds).not.toContain(excludedChainId); - }); - expect(result.current.some((n) => n.name === 'Ethereum Mainnet')).toBe( - true, - ); - expect(result.current.some((n) => n.name === 'Polygon')).toBe(true); - }); - }); - - describe('sorting', () => { - it('sorts Ethereum Mainnet first', () => { - const mockNetworkConfigurations = { - 'eip155:137': { - caipChainId: 'eip155:137' as CaipChainId, - name: 'Polygon', - }, - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - 'eip155:42161': { - caipChainId: 'eip155:42161' as CaipChainId, - name: 'Arbitrum', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - expect(result.current[0].caipChainId).toBe('eip155:1'); - expect(result.current[0].name).toBe('Ethereum Mainnet'); - }); - - it('sorts Linea Mainnet second', () => { - const mockNetworkConfigurations = { - 'eip155:137': { - caipChainId: 'eip155:137' as CaipChainId, - name: 'Polygon', - }, - 'eip155:59144': { - caipChainId: 'eip155:59144' as CaipChainId, - name: 'Linea Main Network', - }, - 'eip155:1': { - caipChainId: 'eip155:1' as CaipChainId, - name: 'Ethereum Mainnet', - }, - }; - - mockUseSelector.mockReturnValue(mockNetworkConfigurations); - - const { result } = renderHook(() => usePopularNetworks()); - - expect(result.current[0].caipChainId).toBe('eip155:1'); - expect(result.current[0].name).toBe('Ethereum Mainnet'); - expect(result.current[1].caipChainId).toBe('eip155:59144'); - expect(result.current[1].name).toBe('Linea Main Network'); - }); - }); -}); diff --git a/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.ts b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.ts deleted file mode 100644 index 5050c9f8826e..000000000000 --- a/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; -import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; -import { BtcScope, SolScope } from '@metamask/keyring-api'; -import { - NetworkConfiguration, - RpcEndpointType, -} from '@metamask/network-controller'; -import { getNetworkImageSource, isTestNet } from '../../../../../util/networks'; -import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; -import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { PopularList } from '../../../../../util/networks/customNetworks'; - -/** - * List of CAIP chain IDs to exclude from the popular networks list - * These networks are not supported for trending/filtering features - */ -export const EXCLUDED_NETWORKS: CaipChainId[] = [ - 'eip155:11297108109', // Palm - 'eip155:999', // Hyper EVM - 'eip155:143', // Monad - 'bip122:000000000019d6689c085ae165831e93', // Bitcoin Mainnet -]; - -/** - * Hook to get popular networks, combining networks from Redux state and PopularList. - * Filters out testnets, excluded networks, and ensures Ethereum Mainnet and Linea Mainnet appear first. - * The selector selectNetworkConfigurationsByCaipChainId is affected by whether the user has removed or added a network. - * This hook will return all popular networks regardless of whether the user has removed or added a network. - * - * @returns Array of ProcessedNetwork objects representing popular mainnet networks - */ -export const usePopularNetworks = (): ProcessedNetwork[] => { - const networkConfigurations = useSelector( - selectNetworkConfigurationsByCaipChainId, - ); - return useMemo(() => { - const processedNetworks: ProcessedNetwork[] = []; - const addedCaipChainIds = new Set(); - - // Helper function to check if a CAIP chain ID is a testnet - const isTestnetCaipChainId = (caipChainId: CaipChainId): boolean => { - const { namespace, reference } = parseCaipChainId(caipChainId); - - // Check EVM testnets using isTestNet helper - if (namespace === 'eip155') { - const hexChainId = `0x${parseInt(reference, 10).toString(16)}` as Hex; - return isTestNet(hexChainId); - } - - // Check Bitcoin testnets using full CAIP IDs from BtcScope - if (namespace === 'bip122') { - return ( - caipChainId === BtcScope.Testnet || - caipChainId === BtcScope.Testnet4 || - caipChainId === BtcScope.Regtest || - caipChainId === BtcScope.Signet - ); - } - - // Check Solana testnets using full CAIP IDs from SolScope - if (namespace === 'solana') { - return caipChainId === SolScope.Devnet; - } - - // For other namespaces, assume mainnet if not explicitly a testnet - return false; - }; - - // First, add all networks from networkConfigurations (excluding testnets) - for (const [caipChainId, config] of Object.entries(networkConfigurations)) { - // Skip testnets using isTestnet helper and custom networks based of rpcEndpoints[defaultRpcEndpointIndex].type - const isEvmCustomChain = - config.caipChainId.startsWith('eip155') && - (config as NetworkConfiguration).rpcEndpoints?.[ - (config as NetworkConfiguration).defaultRpcEndpointIndex - ]?.type === RpcEndpointType.Custom; - - if ( - isTestnetCaipChainId(caipChainId as CaipChainId) || - isEvmCustomChain - ) { - continue; - } - - processedNetworks.push({ - id: caipChainId, - name: config.name, - caipChainId: caipChainId as CaipChainId, - isSelected: false, - imageSource: getNetworkImageSource({ - chainId: caipChainId, - }), - }); - addedCaipChainIds.add(caipChainId as CaipChainId); - } - - // Then, add networks from PopularList that don't already exist in networkConfigurations (excluding testnets) - for (const popularNetwork of PopularList) { - const chainId = popularNetwork.chainId; - const caipChainId = toEvmCaipChainId(chainId as Hex); - - // Only add if it doesn't already exist in networkConfigurations - if (!addedCaipChainIds.has(caipChainId)) { - processedNetworks.push({ - id: caipChainId, - name: popularNetwork.nickname, - caipChainId, - isSelected: false, - imageSource: getNetworkImageSource({ - chainId: caipChainId, - }), - }); - addedCaipChainIds.add(caipChainId); - } - } - - // Filter out excluded networks - const filteredNetworks = processedNetworks.filter( - (network) => !EXCLUDED_NETWORKS.includes(network.caipChainId), - ); - - // Sort networks so Ethereum Mainnet and Linea Mainnet appear first - return filteredNetworks.sort((a, b) => { - const ethereumMainnet = 'eip155:1'; - const lineaMainnet = 'eip155:59144'; - - // Ethereum Mainnet should be first - if (a.caipChainId === ethereumMainnet) return -1; - if (b.caipChainId === ethereumMainnet) return 1; - - // Linea Mainnet should be second - if (a.caipChainId === lineaMainnet) return -1; - if (b.caipChainId === lineaMainnet) return 1; - - // All other networks maintain their original order - return 0; - }); - }, [networkConfigurations]); -}; diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts index 43388999af37..396d0caa19e8 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts @@ -4,13 +4,6 @@ import { act, waitFor } from '@testing-library/react-native'; import { CaipChainId } from '@metamask/utils'; // eslint-disable-next-line import/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; -import { usePopularNetworks } from '../usePopularNetworks/usePopularNetworks'; - -jest.mock('../usePopularNetworks/usePopularNetworks'); - -const mockUsePopularNetworks = usePopularNetworks as jest.MockedFunction< - typeof usePopularNetworks ->; const createMockSearchResult = (overrides = {}) => ({ assetId: 'eip155:1/erc20:0x123' as CaipChainId, @@ -30,15 +23,6 @@ describe('useSearchRequest', () => { beforeEach(() => { spySearchTokens = jest.spyOn(assetsControllers, 'searchTokens'); jest.clearAllMocks(); - mockUsePopularNetworks.mockReturnValue([ - { - id: 'eip155:1', - name: 'Ethereum Mainnet', - caipChainId: 'eip155:1' as CaipChainId, - isSelected: false, - imageSource: { uri: 'ethereum' }, - }, - ]); }); afterEach(() => { diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts index 49343fd3f28e..323260277fad 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts @@ -2,8 +2,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { CaipChainId } from '@metamask/utils'; import { searchTokens } from '@metamask/assets-controllers'; import { useStableArray } from '../../../Perps/hooks/useStableArray'; -import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { usePopularNetworks } from '../usePopularNetworks/usePopularNetworks'; +import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; interface SearchResult { assetId: CaipChainId; @@ -27,18 +26,13 @@ export const useSearchRequest = (options: { }) => { const { chainIds: providedChainIds = [], query, limit } = options; - // Get popular networks for filtering - const popularNetworks = usePopularNetworks(); - - // Use provided chainIds or default to popular networks + // Use provided chainIds or default to trending networks const chainIds = useMemo((): CaipChainId[] => { if (providedChainIds.length > 0) { return providedChainIds; } - return popularNetworks.map( - (network: ProcessedNetwork) => network.caipChainId, - ); - }, [providedChainIds, popularNetworks]); + return TRENDING_NETWORKS_LIST.map((network) => network.caipChainId); + }, [providedChainIds]); const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(true); diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts index 85d47b739499..d1ecdbf51563 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts @@ -3,38 +3,41 @@ import { renderHookWithProvider } from '../../../../../util/test/renderWithProvi import { act, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; -import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { usePopularNetworks } from '../usePopularNetworks/usePopularNetworks'; import { CaipChainId } from '@metamask/utils'; +import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { ImageSourcePropType } from 'react-native'; + +// Mock the TRENDING_NETWORKS_LIST constant +jest.mock('../../utils/trendingNetworksList', () => { + const mockNetworks: ProcessedNetwork[] = [ + { + id: 'eip155:1', + name: 'Ethereum Mainnet', + caipChainId: 'eip155:1' as CaipChainId, + imageSource: { + uri: 'https://example.com/ethereum.png', + } as ImageSourcePropType, + isSelected: false, + }, + { + id: 'eip155:137', + name: 'Polygon', + caipChainId: 'eip155:137' as CaipChainId, + imageSource: { + uri: 'https://example.com/polygon.png', + } as ImageSourcePropType, + isSelected: false, + }, + ]; -jest.mock('../usePopularNetworks/usePopularNetworks'); - -const mockUsePopularNetworks = usePopularNetworks as jest.MockedFunction< - typeof usePopularNetworks ->; - -// Default mock networks -const mockDefaultNetworks: ProcessedNetwork[] = [ - { - id: '1', - name: 'Ethereum Mainnet', - caipChainId: 'eip155:1' as CaipChainId, - isSelected: true, - imageSource: { uri: 'ethereum' }, - }, - { - id: '137', - name: 'Polygon', - caipChainId: 'eip155:137' as CaipChainId, - isSelected: true, - imageSource: { uri: 'polygon' }, - }, -]; + return { + TRENDING_NETWORKS_LIST: mockNetworks, + }; +}); describe('useTrendingRequest', () => { beforeEach(() => { jest.clearAllMocks(); - mockUsePopularNetworks.mockReturnValue(mockDefaultNetworks); }); it('returns trending tokens results when fetch succeeds', async () => { @@ -220,7 +223,6 @@ describe('useTrendingRequest', () => { expect(spyGetTrendingTokens).toHaveBeenCalledTimes(1); }); - expect(mockUsePopularNetworks).toHaveBeenCalled(); expect(spyGetTrendingTokens).toHaveBeenCalledWith( expect.objectContaining({ chainIds: ['eip155:1', 'eip155:137'], diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts index 8061980d5555..6ef157e8a094 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts @@ -5,8 +5,7 @@ import { SortTrendingBy, } from '@metamask/assets-controllers'; import { useStableArray } from '../../../Perps/hooks/useStableArray'; -import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { usePopularNetworks } from '../usePopularNetworks/usePopularNetworks'; +import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; /** * Hook for handling trending tokens request @@ -31,18 +30,13 @@ export const useTrendingRequest = (options: { maxMarketCap, } = options; - // Get popular networks for filtering - const popularNetworks = usePopularNetworks(); - - // Use provided chainIds or default to popular networks + // Use provided chainIds or default to trending networks const chainIds = useMemo((): CaipChainId[] => { if (providedChainIds.length > 0) { return providedChainIds; } - return popularNetworks.map( - (network: ProcessedNetwork) => network.caipChainId, - ); - }, [providedChainIds, popularNetworks]); + return TRENDING_NETWORKS_LIST.map((network) => network.caipChainId); + }, [providedChainIds]); // Track the current request ID to prevent stale results from overwriting current ones const requestIdRef = useRef(0); diff --git a/app/components/UI/Trending/utils/trendingNetworksList.ts b/app/components/UI/Trending/utils/trendingNetworksList.ts new file mode 100644 index 000000000000..d33751890ef0 --- /dev/null +++ b/app/components/UI/Trending/utils/trendingNetworksList.ts @@ -0,0 +1,125 @@ +import { + ///: BEGIN:ONLY_INCLUDE_IF(tron) + TrxScope, + ///: END:ONLY_INCLUDE_IF +} from '@metamask/keyring-api'; +import { ProcessedNetwork } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { getNetworkImageSource } from '../../../../util/networks'; +import { NetworkToCaipChainId } from '../../NetworkMultiSelector/NetworkMultiSelector.constants'; + +/** + * Static list of popular networks for trending features. + * Returns ProcessedNetwork objects similar to usePopularNetworks hook. + * This is a static constant that doesn't depend on Redux state. + */ +// Before adding a network, you MUST make sure it is supported on both `searchAPI` and `trendingAPI` +export const TRENDING_NETWORKS_LIST: ProcessedNetwork[] = [ + { + id: NetworkToCaipChainId.ETHEREUM, + name: 'Ethereum', + caipChainId: NetworkToCaipChainId.ETHEREUM, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.ETHEREUM, + }), + }, + { + id: NetworkToCaipChainId.LINEA, + name: 'Linea', + caipChainId: NetworkToCaipChainId.LINEA, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.LINEA, + }), + }, + { + id: NetworkToCaipChainId.BASE, + name: 'Base', + caipChainId: NetworkToCaipChainId.BASE, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.BASE, + }), + }, + { + id: NetworkToCaipChainId.ARBITRUM, + name: 'Arbitrum', + caipChainId: NetworkToCaipChainId.ARBITRUM, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.ARBITRUM, + }), + }, + { + id: NetworkToCaipChainId.BNB, + name: 'BNB Chain', + caipChainId: NetworkToCaipChainId.BNB, + isSelected: false, + imageSource: getNetworkImageSource({ chainId: NetworkToCaipChainId.BNB }), + }, + { + id: NetworkToCaipChainId.OPTIMISM, + name: 'OP', + caipChainId: NetworkToCaipChainId.OPTIMISM, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.OPTIMISM, + }), + }, + { + id: NetworkToCaipChainId.POLYGON, + name: 'Polygon', + caipChainId: NetworkToCaipChainId.POLYGON, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.POLYGON, + }), + }, + { + id: NetworkToCaipChainId.SEI, + name: 'Sei', + caipChainId: NetworkToCaipChainId.SEI, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.SEI, + }), + }, + { + id: NetworkToCaipChainId.AVALANCHE, + name: 'Avalanche', + caipChainId: NetworkToCaipChainId.AVALANCHE, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.AVALANCHE, + }), + }, + { + id: NetworkToCaipChainId.ZKSYNC_ERA, + name: 'zkSync Era', + caipChainId: NetworkToCaipChainId.ZKSYNC_ERA, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.ZKSYNC_ERA, + }), + }, + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + { + id: NetworkToCaipChainId.SOLANA, + name: 'Solana', + caipChainId: NetworkToCaipChainId.SOLANA, + isSelected: false, + imageSource: getNetworkImageSource({ + chainId: NetworkToCaipChainId.SOLANA, + }), + }, + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(tron) + { + id: TrxScope.Mainnet, + name: 'Tron', + caipChainId: TrxScope.Mainnet, + isSelected: false, + imageSource: getNetworkImageSource({ chainId: TrxScope.Mainnet }), + }, + ///: END:ONLY_INCLUDE_IF +]; diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index 9f76f2ead989..70dc0c158bb4 100644 --- a/app/components/Views/AssetOptions/AssetOptions.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.tsx @@ -30,9 +30,8 @@ import { } from '../../../util/networks'; import { isPortfolioUrl } from '../../../util/url'; import { BrowserTab, TokenI } from '../../../components/UI/Tokens/types'; -import { RootState } from '../../../reducers'; import { CaipAssetType, Hex } from '@metamask/utils'; -import { appendURLParams } from '../../../util/browser'; +import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl'; import InAppBrowser from 'react-native-inappbrowser-reborn'; import { isNonEvmChainId } from '../../../core/Multichain/utils'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; @@ -109,13 +108,11 @@ const AssetOptions = (props: Props) => { const chainId = useSelector(selectEvmChainId); // eslint-disable-next-line @typescript-eslint/no-explicit-any const browserTabs = useSelector((state: any) => state.browser.tabs); - const isDataCollectionForMarketingEnabled = useSelector( - (state: RootState) => state.security.dataCollectionForMarketing, - ); // Get the selected account for the current network (works for all non-EVM chains) const selectInternalAccountByScope = useSelector( selectSelectedInternalAccountByScope, ); + const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); const assets = useSelector(selectAssetsBySelectedAccountGroup); // Check if token exists in state @@ -166,7 +163,7 @@ const AssetOptions = (props: Props) => { networkConfigurations, providerConfigTokenExplorer, ); - const { trackEvent, isEnabled, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useMetrics(); const goToBrowserUrl = (url: string, title: string) => { modalRef.current?.dismissModal(() => { @@ -248,13 +245,9 @@ const AssetOptions = (props: Props) => { if (existingPortfolioTab) { existingTabId = existingPortfolioTab.id; } else { - const analyticsEnabled = isEnabled(); - - const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, { - metamaskEntry: 'mobile', - metricsEnabled: analyticsEnabled, - marketingEnabled: isDataCollectionForMarketingEnabled ?? false, - }); + const portfolioUrl = buildPortfolioUrlWithMetrics( + AppConstants.PORTFOLIO.URL, + ); newTabUrl = portfolioUrl.href; } diff --git a/app/components/Views/Browser/index.js b/app/components/Views/Browser/index.js index c307c40c7de9..fc271a1c16a5 100644 --- a/app/components/Views/Browser/index.js +++ b/app/components/Views/Browser/index.js @@ -40,10 +40,8 @@ import URL from 'url-parse'; import { useMetrics } from '../../hooks/useMetrics'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { - appendURLParams, - isTokenDiscoveryBrowserEnabled, -} from '../../../util/browser'; +import { isTokenDiscoveryBrowserEnabled } from '../../../util/browser'; +import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl'; import { THUMB_WIDTH, THUMB_HEIGHT, @@ -78,7 +76,7 @@ export const Browser = (props) => { const previousTabs = useRef(null); const { top: topInset } = useSafeAreaInsets(); const { styles } = useStyles(styleSheet, { topInset }); - const { trackEvent, createEventBuilder, isEnabled } = useMetrics(); + const { trackEvent, createEventBuilder } = useMetrics(); const { toastRef } = useContext(ToastContext); const browserUrl = props.route?.params?.url; const linkType = props.route?.params?.linkType; @@ -89,18 +87,13 @@ export const Browser = (props) => { const accountAvatarType = useSelector(selectAvatarAccountType); - const isDataCollectionForMarketingEnabled = useSelector( - (state) => state.security.dataCollectionForMarketing, - ); const permittedAccountsList = useSelector(selectPermissionControllerState); + const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); + const homePageUrl = useCallback( - () => - appendURLParams(AppConstants.HOMEPAGE_URL, { - metricsEnabled: isEnabled(), - marketingEnabled: isDataCollectionForMarketingEnabled ?? false, - }).href, - [isEnabled, isDataCollectionForMarketingEnabled], + () => buildPortfolioUrlWithMetrics(AppConstants.HOMEPAGE_URL).href, + [buildPortfolioUrlWithMetrics], ); const newTab = useCallback( @@ -120,7 +113,6 @@ export const Browser = (props) => { ); const [currentUrl, setCurrentUrl] = useState(browserUrl || homePageUrl()); - const updateTabInfo = useCallback( (tabID, info) => { updateTab(tabID, info); diff --git a/app/components/Views/Settings/AppInformation/index.js b/app/components/Views/Settings/AppInformation/index.js index 0301cbe7bdaa..7b09e6b70a53 100644 --- a/app/components/Views/Settings/AppInformation/index.js +++ b/app/components/Views/Settings/AppInformation/index.js @@ -24,6 +24,7 @@ import { updateId, checkAutomatically, } from 'expo-updates'; +import { connect } from 'react-redux'; import { PROJECT_ID, getFullVersion } from '../../../../constants/ota'; import { fontStyles } from '../../../../styles/common'; import PropTypes from 'prop-types'; @@ -37,6 +38,7 @@ import { getFeatureFlagAppDistribution, getFeatureFlagAppEnvironment, } from '../../../../core/Engine/controllers/remote-feature-flag-controller/utils'; +import { getPreinstalledSnapsMetadata } from '../../../../selectors/snaps'; const createStyles = (colors) => StyleSheet.create({ @@ -102,12 +104,13 @@ const foxImage = require('../../../../images/branding/fox.png'); // eslint-disab /** * View that contains app information */ -export default class AppInformation extends PureComponent { +class AppInformation extends PureComponent { static propTypes = { /** /* navigation object required to push new views */ navigation: PropTypes.object, + preinstalledSnaps: PropTypes.array, }; state = { @@ -265,6 +268,12 @@ export default class AppInformation extends PureComponent { )} + + {this.props.preinstalledSnaps.map((snap) => ( + + {snap.name}: {snap.version} ({snap.status}) + + ))} )} @@ -311,3 +320,9 @@ export default class AppInformation extends PureComponent { } AppInformation.contextType = ThemeContext; + +const mapStateToProps = (state) => ({ + preinstalledSnaps: getPreinstalledSnapsMetadata(state), +}); + +export default connect(mapStateToProps)(AppInformation); diff --git a/app/components/Views/Settings/AppInformation/index.test.tsx b/app/components/Views/Settings/AppInformation/index.test.tsx index 543d1aa1000d..a17c5c6074b5 100644 --- a/app/components/Views/Settings/AppInformation/index.test.tsx +++ b/app/components/Views/Settings/AppInformation/index.test.tsx @@ -1,8 +1,12 @@ import { waitFor, fireEvent } from '@testing-library/react-native'; import { Image, TouchableOpacity } from 'react-native'; -import { renderScreen } from '../../../../util/test/renderWithProvider'; +import { + DeepPartial, + renderScreen, +} from '../../../../util/test/renderWithProvider'; import AppInformation from './'; import { AboutMetaMaskSelectorsIDs } from '../../../../../e2e/selectors/Settings/AboutMetaMask.selectors'; +import { RootState } from '../../../../reducers'; // Mock device info const mockGetApplicationName = jest.fn(); @@ -32,6 +36,28 @@ jest.mock( }), ); +const MOCK_STATE = { + engine: { + backgroundState: { + SnapController: { + snaps: { + 'npm:@metamask/solana-snap': { + id: 'npm:@metamask/solana-snap', + enabled: true, + version: '1.7.0', + status: 'running', + manifest: { + proposedName: 'Solana', + description: 'Manage Solana using MetaMask', + }, + preinstalled: true, + }, + }, + }, + }, + }, +} as DeepPartial; + describe('AppInformation', () => { beforeEach(() => { jest.clearAllMocks(); @@ -47,7 +73,7 @@ describe('AppInformation', () => { const { toJSON } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); expect(toJSON()).toMatchSnapshot(); }); @@ -56,7 +82,7 @@ describe('AppInformation', () => { const { getByTestId } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); expect(getByTestId(AboutMetaMaskSelectorsIDs.CONTAINER)).toBeTruthy(); @@ -66,7 +92,7 @@ describe('AppInformation', () => { const { getByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given the device info is mocked @@ -82,7 +108,7 @@ describe('AppInformation', () => { const { getByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given the component is rendered @@ -100,7 +126,7 @@ describe('AppInformation', () => { const { getByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); expect(getByText(/Links/)).toBeTruthy(); @@ -109,7 +135,11 @@ describe('AppInformation', () => { describe('Component Lifecycle', () => { it('fetches device info on mount', async () => { - renderScreen(AppInformation, { name: 'AppInformation' }, { state: {} }); + renderScreen( + AppInformation, + { name: 'AppInformation' }, + { state: MOCK_STATE }, + ); // Given the component is mounted // When the componentDidMount lifecycle method runs @@ -129,7 +159,7 @@ describe('AppInformation', () => { const { getByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given device info returns specific values @@ -146,7 +176,7 @@ describe('AppInformation', () => { const { UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given the component is rendered @@ -173,7 +203,7 @@ describe('AppInformation', () => { const { queryByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // When the component renders @@ -192,7 +222,7 @@ describe('AppInformation', () => { const { getByText, UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // When the user long-presses the fox icon @@ -213,6 +243,7 @@ describe('AppInformation', () => { expect( getByText('Remote Feature Flag Distribution: main'), ).toBeTruthy(); + expect(getByText('Solana: 1.7.0 (running)')).toBeTruthy(); }); }); @@ -225,7 +256,7 @@ describe('AppInformation', () => { const { queryByText, getByText, UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // When initially rendered @@ -262,7 +293,7 @@ describe('AppInformation', () => { const { getByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // When the component renders @@ -284,7 +315,7 @@ describe('AppInformation', () => { const { UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given the component is rendered @@ -308,7 +339,7 @@ describe('AppInformation', () => { const { queryByText, getByText, UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given environment info is initially hidden @@ -342,7 +373,7 @@ describe('AppInformation', () => { const { getByText, queryByText, UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // Given environment info is initially hidden @@ -384,7 +415,7 @@ describe('AppInformation', () => { const { getByText, UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); // When the fox icon is long-pressed @@ -412,7 +443,7 @@ describe('AppInformation', () => { const { queryByText } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); expect(queryByText(/Expo Project ID:/)).toBeNull(); @@ -428,7 +459,7 @@ describe('AppInformation', () => { const { getByText, UNSAFE_getAllByType } = renderScreen( AppInformation, { name: 'AppInformation' }, - { state: {} }, + { state: MOCK_STATE }, ); const touchableOpacities = UNSAFE_getAllByType(TouchableOpacity); diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index 68e5bbd9086e..7146ddb1bbe7 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -48,7 +48,7 @@ jest.mock('../../../components/hooks/useMetrics', () => ({ })); jest.mock('../../../util/browser', () => ({ - appendURLParams: jest.fn((url) => ({ + buildPortfolioUrl: jest.fn((url) => ({ href: `${url}?metamaskEntry=mobile&metricsEnabled=true&marketingEnabled=false`, })), })); diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 7c6b5a6ec35c..9c9402f159e0 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; import { createStackNavigator } from '@react-navigation/stack'; +import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -15,8 +15,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import AppConstants from '../../../core/AppConstants'; -import { appendURLParams } from '../../../util/browser'; -import { useMetrics } from '../../hooks/useMetrics'; +import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl'; import { useTheme } from '../../../util/theme'; import Routes from '../../../constants/navigation/Routes'; import { @@ -37,7 +36,7 @@ const TrendingFeed: React.FC = () => { const tw = useTailwind(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { isEnabled } = useMetrics(); + const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); const { colors } = useTheme(); const [refreshing, setRefreshing] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -51,25 +50,14 @@ const TrendingFeed: React.FC = () => { return unsubscribe; }, [navigation]); - const isDataCollectionForMarketingEnabled = useSelector( - (state: { security: { dataCollectionForMarketing?: boolean } }) => - state.security.dataCollectionForMarketing, - ); + const portfolioUrl = buildPortfolioUrlWithMetrics(AppConstants.PORTFOLIO.URL); const browserTabsCount = useSelector( (state: { browser: { tabs: unknown[] } }) => state.browser.tabs.length, ); - // check if basic functionality toggle is on const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, ); - - const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, { - metamaskEntry: 'mobile', - metricsEnabled: isEnabled(), - marketingEnabled: isDataCollectionForMarketingEnabled ?? false, - }); - const handleBrowserPress = useCallback(() => { updateLastTrendingScreen('TrendingBrowser'); navigation.navigate('TrendingBrowser', { diff --git a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx index ba1833ad81a1..cbf53569645a 100644 --- a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx +++ b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx @@ -18,7 +18,7 @@ import { strings } from '../../../../../../locales/i18n'; import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config'; import { useNavigation } from '@react-navigation/native'; -interface SectionHeaderProps { +export interface SectionHeaderProps { sectionId: SectionId; } diff --git a/app/components/hooks/useBuildPortfolioUrl.test.ts b/app/components/hooks/useBuildPortfolioUrl.test.ts new file mode 100644 index 000000000000..1e2b12a72468 --- /dev/null +++ b/app/components/hooks/useBuildPortfolioUrl.test.ts @@ -0,0 +1,185 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useBuildPortfolioUrl } from './useBuildPortfolioUrl'; +import { useMetrics } from './useMetrics'; +import { buildPortfolioUrl } from '../../util/browser'; + +// Mock dependencies +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('./useMetrics', () => ({ + useMetrics: jest.fn(), +})); + +jest.mock('../../util/browser', () => ({ + buildPortfolioUrl: jest.fn(), +})); + +describe('useBuildPortfolioUrl', () => { + const mockIsEnabled = jest.fn(); + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + const mockUseMetrics = useMetrics as jest.MockedFunction; + const mockBuildPortfolioUrl = buildPortfolioUrl as jest.MockedFunction< + typeof buildPortfolioUrl + >; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mocks + mockUseMetrics.mockReturnValue({ + isEnabled: mockIsEnabled, + trackEvent: jest.fn(), + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createDataDeletionTask: jest.fn(), + checkDataDeleteStatus: jest.fn(), + getDeleteRegulationCreationDate: jest.fn(), + getDeleteRegulationId: jest.fn(), + isDataRecorded: jest.fn(), + getMetaMetricsId: jest.fn(), + createEventBuilder: jest.fn(), + }); + }); + + it('should build portfolio URL with metrics enabled and marketing enabled', () => { + // Arrange + mockIsEnabled.mockReturnValue(true); + mockUseSelector.mockReturnValue(true); // isDataCollectionForMarketingEnabled + const mockUrl = new URL('https://portfolio.metamask.io'); + mockBuildPortfolioUrl.mockReturnValue(mockUrl); + + // Act + const { result } = renderHook(() => useBuildPortfolioUrl()); + const buildUrl = result.current; + const portfolioUrl = buildUrl('https://portfolio.metamask.io'); + + // Assert + expect(mockBuildPortfolioUrl).toHaveBeenCalledWith( + 'https://portfolio.metamask.io', + { + marketingEnabled: true, + metricsEnabled: true, + }, + ); + expect(portfolioUrl).toBe(mockUrl); + }); + + it('should build portfolio URL with metrics disabled and marketing disabled', () => { + // Arrange + mockIsEnabled.mockReturnValue(false); + mockUseSelector.mockReturnValue(false); // isDataCollectionForMarketingEnabled + const mockUrl = new URL('https://portfolio.metamask.io'); + mockBuildPortfolioUrl.mockReturnValue(mockUrl); + + // Act + const { result } = renderHook(() => useBuildPortfolioUrl()); + const buildUrl = result.current; + const portfolioUrl = buildUrl('https://portfolio.metamask.io'); + + // Assert + expect(mockBuildPortfolioUrl).toHaveBeenCalledWith( + 'https://portfolio.metamask.io', + { + marketingEnabled: false, + metricsEnabled: false, + }, + ); + expect(portfolioUrl).toBe(mockUrl); + }); + + it('should handle null marketing enabled state', () => { + // Arrange + mockIsEnabled.mockReturnValue(true); + mockUseSelector.mockReturnValue(null); // isDataCollectionForMarketingEnabled is null + const mockUrl = new URL('https://portfolio.metamask.io'); + mockBuildPortfolioUrl.mockReturnValue(mockUrl); + + // Act + const { result } = renderHook(() => useBuildPortfolioUrl()); + const buildUrl = result.current; + const portfolioUrl = buildUrl('https://portfolio.metamask.io'); + + // Assert + expect(mockBuildPortfolioUrl).toHaveBeenCalledWith( + 'https://portfolio.metamask.io', + { + marketingEnabled: false, + metricsEnabled: true, + }, + ); + expect(portfolioUrl).toBe(mockUrl); + }); + + it('should pass additional params to buildPortfolioUrl', () => { + // Arrange + mockIsEnabled.mockReturnValue(true); + mockUseSelector.mockReturnValue(true); + const mockUrl = new URL('https://portfolio.metamask.io'); + mockBuildPortfolioUrl.mockReturnValue(mockUrl); + + // Act + const { result } = renderHook(() => useBuildPortfolioUrl()); + const buildUrl = result.current; + const portfolioUrl = buildUrl('https://portfolio.metamask.io', { + srcChain: 1, + token: '0x123', + }); + + // Assert + expect(mockBuildPortfolioUrl).toHaveBeenCalledWith( + 'https://portfolio.metamask.io', + { + marketingEnabled: true, + metricsEnabled: true, + srcChain: 1, + token: '0x123', + }, + ); + expect(portfolioUrl).toBe(mockUrl); + }); + + it('should memoize the returned function based on dependencies', () => { + // Arrange + mockIsEnabled.mockReturnValue(true); + mockUseSelector.mockReturnValue(true); + const mockUrl = new URL('https://portfolio.metamask.io'); + mockBuildPortfolioUrl.mockReturnValue(mockUrl); + + // Act + const { result, rerender } = renderHook(() => useBuildPortfolioUrl()); + const firstBuildUrl = result.current; + + // Rerender without changing dependencies + rerender(); + const secondBuildUrl = result.current; + + // Assert - function should be the same instance + expect(firstBuildUrl).toBe(secondBuildUrl); + }); + + it('should create new function when dependencies change', () => { + // Arrange + mockIsEnabled.mockReturnValue(true); + let marketingEnabled = true; + mockUseSelector.mockImplementation(() => marketingEnabled); + const mockUrl = new URL('https://portfolio.metamask.io'); + mockBuildPortfolioUrl.mockReturnValue(mockUrl); + + // Act + const { result, rerender } = renderHook(() => useBuildPortfolioUrl()); + const firstBuildUrl = result.current; + + // Change dependency + marketingEnabled = false; + rerender(); + const secondBuildUrl = result.current; + + // Assert - function should be different instance + expect(firstBuildUrl).not.toBe(secondBuildUrl); + }); +}); diff --git a/app/components/hooks/useBuildPortfolioUrl.ts b/app/components/hooks/useBuildPortfolioUrl.ts new file mode 100644 index 000000000000..d2179a790071 --- /dev/null +++ b/app/components/hooks/useBuildPortfolioUrl.ts @@ -0,0 +1,43 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useMetrics } from './useMetrics'; +import { buildPortfolioUrl } from '../../util/browser'; +import type { RootState } from '../../reducers'; + +/** + * Hook to build Portfolio URLs with metrics parameters + * + * This hook automatically includes the user's marketing and metrics consent + * preferences when building Portfolio URLs. + * + * @returns A function that builds a Portfolio URL with the appropriate parameters + * + * @example + * const buildUrl = useBuildPortfolioUrl(); + * const portfolioUrl = buildUrl(AppConstants.PORTFOLIO.URL, { + * srcChain: chainId, + * token: tokenAddress, + * }); + */ +export const useBuildPortfolioUrl = () => { + const { isEnabled } = useMetrics(); + const isDataCollectionForMarketingEnabled = useSelector( + (state: RootState) => state.security.dataCollectionForMarketing, + ); + + return useCallback( + ( + baseUrl: string, + additionalParams?: Record, + ): URL => { + const params: Record = { + marketingEnabled: isDataCollectionForMarketingEnabled ?? false, + metricsEnabled: isEnabled(), + ...additionalParams, + }; + + return buildPortfolioUrl(baseUrl, params); + }, + [isDataCollectionForMarketingEnabled, isEnabled], + ); +}; diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index a92d312ebc0d..58c7a70ec096 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -173,6 +173,8 @@ import { loggingControllerInit } from './controllers/logging-controller-init'; import { phishingControllerInit } from './controllers/phishing-controller-init'; import { addressBookControllerInit } from './controllers/address-book-controller-init'; import { multichainRouterInit } from './controllers/multichain-router-init'; +import { profileMetricsControllerInit } from './controllers/profile-metrics-controller-init'; +import { profileMetricsServiceInit } from './controllers/profile-metrics-service-init'; import { Messenger, MessengerEvents } from '@metamask/messenger'; // TODO: Replace "any" with type @@ -361,6 +363,8 @@ export class Engine { RewardsDataService: rewardsDataServiceInit, DelegationController: DelegationControllerInit, AddressBookController: addressBookControllerInit, + ProfileMetricsController: profileMetricsControllerInit, + ProfileMetricsService: profileMetricsServiceInit, }, persistedState: initialState as EngineState, baseControllerMessenger: this.controllerMessenger, @@ -393,6 +397,8 @@ export class Engine { const preferencesController = controllersByName.PreferencesController; const delegationController = controllersByName.DelegationController; const addressBookController = controllersByName.AddressBookController; + const profileMetricsController = controllersByName.ProfileMetricsController; + const profileMetricsService = controllersByName.ProfileMetricsService; // Backwards compatibility for existing references this.accountsController = accountsController; @@ -539,6 +545,8 @@ export class Engine { PredictController: predictController, RewardsController: rewardsController, DelegationController: delegationController, + ProfileMetricsController: profileMetricsController, + ProfileMetricsService: profileMetricsService, }; const childControllers = Object.assign({}, this.context); @@ -1329,6 +1337,7 @@ export default { MultichainBalancesController, MultichainTransactionsController, ///: END:ONLY_INCLUDE_IF + ProfileMetricsController, } = instance.datamodel.state; return { @@ -1390,6 +1399,7 @@ export default { MultichainBalancesController, MultichainTransactionsController, ///: END:ONLY_INCLUDE_IF + ProfileMetricsController, }; }, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index 2659e4013561..6736e77a436a 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -15,6 +15,7 @@ export const STATELESS_NON_CONTROLLER_NAMES = [ 'BackendWebSocketService', 'AccountActivityService', 'MultichainAccountService', + 'ProfileMetricsService', ] as const; export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ @@ -78,6 +79,7 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'NetworkEnablementController:stateChange', 'PredictController:stateChange', 'DelegationController:stateChange', + 'ProfileMetricsController:stateChange', ] as const; export const swapsSupportedChainIds = [ diff --git a/app/core/Engine/controllers/profile-metrics-controller-init.test.ts b/app/core/Engine/controllers/profile-metrics-controller-init.test.ts new file mode 100644 index 000000000000..50aae78ce0db --- /dev/null +++ b/app/core/Engine/controllers/profile-metrics-controller-init.test.ts @@ -0,0 +1,116 @@ +import { + ProfileMetricsController, + ProfileMetricsControllerMessenger, +} from '@metamask/profile-metrics-controller'; +import { ControllerInitRequest } from '../types'; +import { profileMetricsControllerInit } from './profile-metrics-controller-init'; +import { ExtendedMessenger } from '../../ExtendedMessenger'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { getProfileMetricsControllerMessenger } from '../messengers/profile-metrics-controller-messenger'; +import { buildControllerInitRequestMock } from '../utils/test-utils'; +import { MetaMetrics } from '../../Analytics'; + +jest.mock('@metamask/profile-metrics-controller'); + +function getInitRequestMock({ + metaMetricsId, + remoteFeatureFlag, + metaMetricsEnabled, +}: { + metaMetricsId: string; + remoteFeatureFlag: boolean; + metaMetricsEnabled: boolean; +}): jest.Mocked> { + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + jest.spyOn(MetaMetrics, 'getInstance').mockReturnValue({ + isEnabled: () => metaMetricsEnabled, + } as MetaMetrics); + + const mockGetController = jest.fn().mockReturnValue({ + state: { + remoteFeatureFlags: { extensionUxPna25: remoteFeatureFlag }, + }, + }); + + const requestMock = { + ...buildControllerInitRequestMock(baseMessenger), + controllerMessenger: getProfileMetricsControllerMessenger(baseMessenger), + initMessenger: undefined, + metaMetricsId, + getController: mockGetController, + }; + + return requestMock; +} + +describe.each([ + { + metaMetricsId: 'dd6395a5-7a84-47b8-8bc3-713170c2f3e8', + remoteFeatureFlag: true, + metaMetricsEnabled: true, + }, + { + metaMetricsId: '898cbad5-7a5e-4ea1-8ca0-822bb4804665', + remoteFeatureFlag: false, + metaMetricsEnabled: false, + }, + { + metaMetricsId: '9c9fe89c-76c3-4ad6-89f8-b76061159458', + remoteFeatureFlag: true, + metaMetricsEnabled: false, + }, + { + metaMetricsId: '5aed4107-f430-4bb0-84c9-1e7031599cc2', + remoteFeatureFlag: false, + metaMetricsEnabled: true, + }, +])( + 'profileMetricsControllerInit', + ({ metaMetricsId, remoteFeatureFlag, metaMetricsEnabled }) => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe(`when metaMetricsId is ${metaMetricsId}, the feature flag value is ${remoteFeatureFlag} and MetaMetrics is ${metaMetricsEnabled ? 'enabled' : 'disabled'}`, () => { + it('initializes the controller', () => { + const { controller } = profileMetricsControllerInit( + getInitRequestMock({ + metaMetricsId, + remoteFeatureFlag, + metaMetricsEnabled, + }), + ); + + expect(controller).toBeInstanceOf(ProfileMetricsController); + }); + + it('passes the proper arguments to the controller', () => { + profileMetricsControllerInit( + getInitRequestMock({ + metaMetricsId, + remoteFeatureFlag, + metaMetricsEnabled, + }), + ); + + const controllerMock = jest.mocked(ProfileMetricsController); + + expect(controllerMock).toHaveBeenCalledWith({ + messenger: expect.any(Object), + state: undefined, + assertUserOptedIn: expect.any(Function), + getMetaMetricsId: expect.any(Function), + }); + expect(controllerMock.mock.calls[0][0].assertUserOptedIn()).toBe( + metaMetricsEnabled && remoteFeatureFlag, + ); + expect(controllerMock.mock.calls[0][0].getMetaMetricsId()).toBe( + metaMetricsId, + ); + }); + }); + }, +); diff --git a/app/core/Engine/controllers/profile-metrics-controller-init.ts b/app/core/Engine/controllers/profile-metrics-controller-init.ts new file mode 100644 index 000000000000..5990908d9c92 --- /dev/null +++ b/app/core/Engine/controllers/profile-metrics-controller-init.ts @@ -0,0 +1,39 @@ +import { + ProfileMetricsController, + ProfileMetricsControllerMessenger, +} from '@metamask/profile-metrics-controller'; +import { ControllerInitFunction } from '../types'; +import { MetaMetrics } from '../../Analytics'; + +/** + * Initialize the profile metrics controller. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the controller. + * @param request.persistedState - The persisted state to use for the + * controller. + * @param request.getController - A function to get other initialized controllers. + * @returns The initialized controller. + */ +export const profileMetricsControllerInit: ControllerInitFunction< + ProfileMetricsController, + ProfileMetricsControllerMessenger +> = ({ controllerMessenger, persistedState, getController, metaMetricsId }) => { + const remoteFeatureFlagController = getController( + 'RemoteFeatureFlagController', + ); + const assertUserOptedIn = () => + remoteFeatureFlagController.state.remoteFeatureFlags.extensionUxPna25 === + true && MetaMetrics.getInstance().isEnabled() === true; + + const controller = new ProfileMetricsController({ + messenger: controllerMessenger, + state: persistedState.ProfileMetricsController, + assertUserOptedIn, + getMetaMetricsId: () => metaMetricsId, + }); + + return { + controller, + }; +}; diff --git a/app/core/Engine/controllers/profile-metrics-service-init.test.ts b/app/core/Engine/controllers/profile-metrics-service-init.test.ts new file mode 100644 index 000000000000..42cb978a3478 --- /dev/null +++ b/app/core/Engine/controllers/profile-metrics-service-init.test.ts @@ -0,0 +1,47 @@ +import { + ProfileMetricsService, + ProfileMetricsServiceMessenger, +} from '@metamask/profile-metrics-controller'; +import { SDK } from '@metamask/profile-sync-controller'; +import { ControllerInitRequest } from '../types'; +import { buildControllerInitRequestMock } from '../utils/test-utils'; +import { profileMetricsServiceInit } from './profile-metrics-service-init'; +import { ExtendedMessenger } from '../../ExtendedMessenger'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { getProfileMetricsServiceMessenger } from '../messengers/profile-metrics-service-messenger'; + +jest.mock('@metamask/profile-metrics-controller'); + +function getInitRequestMock(): jest.Mocked< + ControllerInitRequest +> { + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const requestMock = { + ...buildControllerInitRequestMock(baseMessenger), + controllerMessenger: getProfileMetricsServiceMessenger(baseMessenger), + initMessenger: undefined, + }; + + return requestMock; +} + +describe('profileMetricsServiceInit', () => { + it('initializes the service', () => { + const { controller } = profileMetricsServiceInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(ProfileMetricsService); + }); + + it('passes the proper arguments to the controller', () => { + profileMetricsServiceInit(getInitRequestMock()); + + const controllerMock = jest.mocked(ProfileMetricsService); + expect(controllerMock).toHaveBeenCalledWith({ + messenger: expect.any(Object), + fetch: expect.any(Function), + env: SDK.Env.PRD, + }); + }); +}); diff --git a/app/core/Engine/controllers/profile-metrics-service-init.ts b/app/core/Engine/controllers/profile-metrics-service-init.ts new file mode 100644 index 000000000000..64b86974af31 --- /dev/null +++ b/app/core/Engine/controllers/profile-metrics-service-init.ts @@ -0,0 +1,31 @@ +import { + ProfileMetricsService, + ProfileMetricsServiceMessenger, +} from '@metamask/profile-metrics-controller'; +import { ControllerInitFunction } from '../types'; +import { SDK } from '@metamask/profile-sync-controller'; + +/** + * Initialize the profile metrics service. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @returns The initialized controller. + */ +export const profileMetricsServiceInit: ControllerInitFunction< + ProfileMetricsService, + ProfileMetricsServiceMessenger +> = ({ controllerMessenger }) => { + // The environment must be the same used by AuthenticationController. + const env = SDK.Env.PRD; + + const controller = new ProfileMetricsService({ + messenger: controllerMessenger, + fetch: fetch.bind(globalThis), + env, + }); + + return { + controller, + }; +}; diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index 4dba0037bce7..057ebe79764c 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -96,9 +96,6 @@ export const TransactionControllerInit: ControllerInitFunction< gasFeeController.fetchGasFeeEstimates(...args), getNetworkClientRegistry: (...args) => networkController.getNetworkClientRegistry(...args), - // @ts-expect-error Type mismatch due to @metamask/network-controller version mismatch. - // The latest version (v27.0.0+) adds NetworkStatus.Degraded enum value - // See: https://github.com/MetaMask/core/pull/7186 getNetworkState: () => networkController.state, hooks: { // @ts-expect-error - TransactionController actually sends a signedTx as a second argument, but its type doesn't reflect that. diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 3c7444870bf2..3df5a56a17d6 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -122,6 +122,8 @@ import { getTransactionPayControllerInitMessenger, getTransactionPayControllerMessenger, } from './transaction-pay-controller-messenger'; +import { getProfileMetricsControllerMessenger } from './profile-metrics-controller-messenger'; +import { getProfileMetricsServiceMessenger } from './profile-metrics-service-messenger'; /** * The messengers for the controllers that have been. @@ -397,4 +399,12 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getAccountActivityServiceMessenger, getInitMessenger: noop, }, + ProfileMetricsController: { + getMessenger: getProfileMetricsControllerMessenger, + getInitMessenger: noop, + }, + ProfileMetricsService: { + getMessenger: getProfileMetricsServiceMessenger, + getInitMessenger: noop, + }, } as const; diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts new file mode 100644 index 000000000000..e94dab2f47fc --- /dev/null +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts @@ -0,0 +1,30 @@ +import { + MOCK_ANY_NAMESPACE, + Messenger, + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { getProfileMetricsControllerMessenger } from './profile-metrics-controller-messenger'; +import { ProfileMetricsControllerMessenger } from '@metamask/profile-metrics-controller'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const getRootMessenger = (): RootMessenger => + new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + +describe('getProfileMetricsControllerMessenger', () => { + it('returns a restricted messenger', () => { + const messenger = getRootMessenger(); + const profileMetricsControllerMessenger = + getProfileMetricsControllerMessenger(messenger); + + expect(profileMetricsControllerMessenger).toBeInstanceOf(Messenger); + }); +}); diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts new file mode 100644 index 000000000000..ab88f2c4ec0c --- /dev/null +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts @@ -0,0 +1,43 @@ +import { ProfileMetricsControllerMessenger } from '@metamask/profile-metrics-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { RootMessenger } from '../types'; + +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +/** + * Create a messenger restricted to the allowed actions and events of the + * accounts controller. + * + * @param messenger - The base messenger used to create the restricted + * messenger. + */ +export function getProfileMetricsControllerMessenger(messenger: RootMessenger) { + const profileMetricsControllerMessenger = new Messenger< + 'ProfileMetricsController', + AllowedActions, + AllowedEvents, + typeof messenger + >({ + namespace: 'ProfileMetricsController', + parent: messenger, + }); + messenger.delegate({ + messenger: profileMetricsControllerMessenger, + actions: [ + 'AccountsController:listAccounts', + 'ProfileMetricsService:submitMetrics', + ], + events: [ + 'AccountsController:accountAdded', + 'KeyringController:lock', + 'KeyringController:unlock', + ], + }); + return profileMetricsControllerMessenger; +} diff --git a/app/core/Engine/messengers/profile-metrics-service-messenger.test.ts b/app/core/Engine/messengers/profile-metrics-service-messenger.test.ts new file mode 100644 index 000000000000..c3e727b08d06 --- /dev/null +++ b/app/core/Engine/messengers/profile-metrics-service-messenger.test.ts @@ -0,0 +1,30 @@ +import { + MOCK_ANY_NAMESPACE, + Messenger, + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { getProfileMetricsServiceMessenger } from './profile-metrics-service-messenger'; +import { ProfileMetricsServiceMessenger } from '@metamask/profile-metrics-controller'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const getRootMessenger = (): RootMessenger => + new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + +describe('getProfileMetricsServiceMessenger', () => { + it('returns a restricted messenger', () => { + const messenger = getRootMessenger(); + const userProfileServiceMessenger = + getProfileMetricsServiceMessenger(messenger); + + expect(userProfileServiceMessenger).toBeInstanceOf(Messenger); + }); +}); diff --git a/app/core/Engine/messengers/profile-metrics-service-messenger.ts b/app/core/Engine/messengers/profile-metrics-service-messenger.ts new file mode 100644 index 000000000000..2a9bc83e731b --- /dev/null +++ b/app/core/Engine/messengers/profile-metrics-service-messenger.ts @@ -0,0 +1,37 @@ +import { ProfileMetricsServiceMessenger } from '@metamask/profile-metrics-controller'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { RootMessenger } from '../types'; + +type AllowedActions = MessengerActions; + +type AllowedEvents = MessengerEvents; + +/** + * Create a messenger restricted to the allowed actions and events of the + * accounts controller. + * + * @param messenger - The base messenger used to create the restricted + * messenger. + */ +export function getProfileMetricsServiceMessenger( + messenger: RootMessenger, +): ProfileMetricsServiceMessenger { + const serviceMessenger = new Messenger< + 'ProfileMetricsService', + AllowedActions, + AllowedEvents, + typeof messenger + >({ + namespace: 'ProfileMetricsService', + parent: messenger, + }); + messenger.delegate({ + messenger: serviceMessenger, + actions: ['AuthenticationController:getBearerToken'], + }); + return serviceMessenger; +} diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 30ce89c834c2..b623a749e4d7 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -352,6 +352,15 @@ import { ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { NFTDetectionControllerState } from '@metamask/assets-controllers/dist/NftDetectionController.cjs'; +import { + ProfileMetricsController, + ProfileMetricsControllerActions, + ProfileMetricsControllerEvents, + ProfileMetricsControllerState, + ProfileMetricsService, + ProfileMetricsServiceActions, + ProfileMetricsServiceEvents, +} from '@metamask/profile-metrics-controller'; type NftDetectionControllerActions = ControllerGetStateAction< 'NftDetectionController', @@ -486,7 +495,9 @@ type GlobalActions = | ErrorReportingServiceActions | DelegationControllerActions | SeedlessOnboardingControllerActions - | NftDetectionControllerActions; + | NftDetectionControllerActions + | ProfileMetricsControllerActions + | ProfileMetricsServiceActions; type GlobalEvents = ///: BEGIN:ONLY_INCLUDE_IF(sample-feature) @@ -555,7 +566,9 @@ type GlobalEvents = | DeFiPositionsControllerEvents | AccountTreeControllerEvents | DelegationControllerEvents - | NftDetectionControllerEvents; + | NftDetectionControllerEvents + | ProfileMetricsControllerEvents + | ProfileMetricsServiceEvents; /** * Type definition for the messenger used in the Engine. @@ -665,6 +678,8 @@ export type Controllers = { SeedlessOnboardingController: SeedlessOnboardingController; GatorPermissionsController: GatorPermissionsController; DelegationController: DelegationController; + ProfileMetricsController: ProfileMetricsController; + ProfileMetricsService: ProfileMetricsService; }; /** @@ -739,6 +754,7 @@ export type EngineState = { ///: END:ONLY_INCLUDE_IF GatorPermissionsController: GatorPermissionsControllerState; DelegationController: DelegationControllerState; + ProfileMetricsController: ProfileMetricsControllerState; }; /** Controller names */ @@ -838,7 +854,9 @@ export type ControllersToInitialize = | 'RewardsDataService' | 'GatorPermissionsController' | 'DelegationController' - | 'SelectedNetworkController'; + | 'SelectedNetworkController' + | 'ProfileMetricsController' + | 'ProfileMetricsService'; /** * Callback that returns a controller messenger for a specific controller. diff --git a/app/selectors/snaps/snapController.ts b/app/selectors/snaps/snapController.ts index 50cb2f0e3c52..9ea3ebf94f96 100644 --- a/app/selectors/snaps/snapController.ts +++ b/app/selectors/snaps/snapController.ts @@ -18,7 +18,16 @@ export const selectSnapsMetadata = createDeepEqualSelector( selectSnaps, (snaps) => Object.values(snaps).reduce< - Record + Record< + string, + { + name: string; + description: string; + version: string; + status: string; + preinstalled?: boolean; + } + > >((snapsMetadata, snap) => { const snapId = snap.id; const manifest = snap.localizationFiles @@ -33,11 +42,19 @@ export const selectSnapsMetadata = createDeepEqualSelector( snapsMetadata[snapId] = { name: manifest.proposedName, description: manifest.description, + version: snap.version, + status: snap.status, + preinstalled: snap.preinstalled, }; return snapsMetadata; }, {}), ); +export const getPreinstalledSnapsMetadata = createDeepEqualSelector( + selectSnapsMetadata, + (metadata) => Object.values(metadata).filter((snap) => snap.preinstalled), +); + export const getEnabledSnaps = createDeepEqualSelector(selectSnaps, (snaps) => Object.values(snaps).reduce>((acc, cur) => { if (cur.enabled) { diff --git a/app/util/browser/index.test.ts b/app/util/browser/index.test.ts index c7f62d8e0c7c..92ecea1e0acc 100644 --- a/app/util/browser/index.test.ts +++ b/app/util/browser/index.test.ts @@ -8,6 +8,7 @@ import { getHost, appendURLParams, processUrlForBrowser, + buildPortfolioUrl, } from '.'; import { strings } from '../../../locales/i18n'; @@ -353,3 +354,67 @@ describe('Browser utils :: appendURLParams', () => { expect(result.toString()).toBe('https://metamask.io/'); }); }); + +describe('Browser utils :: buildPortfolioUrl', () => { + it('should build portfolio URL with metamaskEntry parameter', () => { + const baseUrl = 'https://portfolio.metamask.io'; + + const result = buildPortfolioUrl(baseUrl); + + expect(result.toString()).toBe( + 'https://portfolio.metamask.io/?metamaskEntry=mobile', + ); + }); + + it('should build portfolio URL with additional parameters', () => { + const baseUrl = 'https://portfolio.metamask.io'; + const additionalParams = { + marketingEnabled: true, + metricsEnabled: true, + }; + + const result = buildPortfolioUrl(baseUrl, additionalParams); + + expect(result.toString()).toBe( + 'https://portfolio.metamask.io/?metamaskEntry=mobile&marketingEnabled=true&metricsEnabled=true', + ); + }); + + it('should build portfolio URL with metrics disabled', () => { + const baseUrl = 'https://portfolio.metamask.io'; + const additionalParams = { + marketingEnabled: false, + metricsEnabled: false, + }; + + const result = buildPortfolioUrl(baseUrl, additionalParams); + + expect(result.toString()).toBe( + 'https://portfolio.metamask.io/?metamaskEntry=mobile&marketingEnabled=false&metricsEnabled=false', + ); + }); + + it('should build portfolio URL with mixed parameters', () => { + const baseUrl = 'https://portfolio.metamask.io/bridge'; + const additionalParams = { + marketingEnabled: true, + metricsEnabled: false, + srcChain: 1, + token: '0x123', + }; + + const result = buildPortfolioUrl(baseUrl, additionalParams); + + expect(result.toString()).toBe( + 'https://portfolio.metamask.io/bridge?metamaskEntry=mobile&marketingEnabled=true&metricsEnabled=false&srcChain=1&token=0x123', + ); + }); + + it('should return URL object', () => { + const baseUrl = 'https://portfolio.metamask.io'; + + const result = buildPortfolioUrl(baseUrl); + + expect(result).toBeInstanceOf(URL); + }); +}); diff --git a/app/util/browser/index.ts b/app/util/browser/index.ts index 8f7bf996ecee..b5e0993add19 100644 --- a/app/util/browser/index.ts +++ b/app/util/browser/index.ts @@ -202,5 +202,25 @@ export const appendURLParams = ( return url; }; +/** + * Builds a Portfolio URL with standard parameters including user tracking consent + * + * @param baseUrl - Base Portfolio URL string + * @param userAcceptedTracking - User's basic usage data tracking consent state (true, false, or null) + * @param additionalParams - Optional additional parameters to append + * @returns - URL object with all parameters appended + */ +export const buildPortfolioUrl = ( + baseUrl: string, + additionalParams?: Record, +): URL => { + const params: Record = { + metamaskEntry: 'mobile', + ...additionalParams, + }; + + return appendURLParams(baseUrl, params); +}; + export const isTokenDiscoveryBrowserEnabled = () => AppConstants.TOKEN_DISCOVERY_BROWSER_ENABLED; diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index e45c84472743..6129331ef9e6 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -546,6 +546,10 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "useTokenDetection": true, "useTransactionSimulations": true, }, + "ProfileMetricsController": { + "initialEnqueueCompleted": false, + "syncQueue": {}, + }, "RemoteFeatureFlagController": { "cacheTimestamp": 0, "remoteFeatureFlags": {}, @@ -1289,6 +1293,10 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "useTokenDetection": true, "useTransactionSimulations": true, }, + "ProfileMetricsController": { + "initialEnqueueCompleted": false, + "syncQueue": {}, + }, "RemoteFeatureFlagController": { "cacheTimestamp": 0, "remoteFeatureFlags": {}, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 2632734f4683..6b1a42ae0553 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -541,6 +541,10 @@ "TransactionPayController": { "transactionData": {} }, + "ProfileMetricsController": { + "initialEnqueueCompleted": false, + "syncQueue": {} + }, "UserStorageController": { "isBackupAndSyncEnabled": true, "isBackupAndSyncUpdateLoading": false, diff --git a/app/util/transactions/__snapshots__/delegation.test.ts.snap b/app/util/transactions/__snapshots__/delegation.test.ts.snap index c9beff25db67..18bca59e1b40 100644 --- a/app/util/transactions/__snapshots__/delegation.test.ts.snap +++ b/app/util/transactions/__snapshots__/delegation.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Transaction Delegation Utils returns delegation data 1`] = ` +exports[`Transaction Delegation Utils getDelegationTransaction returns delegation data 1`] = ` { "authorizationList": [ { diff --git a/app/util/transactions/delegation.test.ts b/app/util/transactions/delegation.test.ts index a211a53136fb..e021e77b4c13 100644 --- a/app/util/transactions/delegation.test.ts +++ b/app/util/transactions/delegation.test.ts @@ -73,7 +73,7 @@ describe('Transaction Delegation Utils', () => { mockIsAtomicBatchSupported.mockResolvedValue([ { chainId: TRANSACTION_META_MOCK.chainId, - isSupported: true, + isSupported: false, upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, }, ]); @@ -84,21 +84,92 @@ describe('Transaction Delegation Utils', () => { }); }); - it('returns delegation data', async () => { - const result = await getDelegationTransaction( - messengerMock, - TRANSACTION_META_MOCK, - ); + describe('getDelegationTransaction', () => { + it('returns delegation data', async () => { + const result = await getDelegationTransaction( + messengerMock, + TRANSACTION_META_MOCK, + ); - expect(result).toMatchSnapshot(); - }); + expect(result).toMatchSnapshot(); + }); + + it('does not include authorization if already upgraded', async () => { + mockIsAtomicBatchSupported.mockResolvedValue([ + { + chainId: TRANSACTION_META_MOCK.chainId, + delegationAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + const result = await getDelegationTransaction(messengerMock, { + ...TRANSACTION_META_MOCK, + delegationAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }); + + expect(result.authorizationList).toBeUndefined(); + }); + + it('includes authorization if upgraded to different contract', async () => { + mockIsAtomicBatchSupported.mockResolvedValue([ + { + chainId: TRANSACTION_META_MOCK.chainId, + delegationAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + isSupported: false, + upgradeContractAddress: '0x789' as Hex, + }, + ]); + + const result = await getDelegationTransaction(messengerMock, { + ...TRANSACTION_META_MOCK, + delegationAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }); + + expect(result.authorizationList).toHaveLength(1); + }); + + it('calls DelegationController to sign delegation', async () => { + await getDelegationTransaction(messengerMock, TRANSACTION_META_MOCK); - it('does not include authorization if already upgraded', async () => { - const result = await getDelegationTransaction(messengerMock, { - ...TRANSACTION_META_MOCK, - delegationAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + expect(signDelegationMock).toHaveBeenCalledWith({ + chainId: TRANSACTION_META_MOCK.chainId, + delegation: expect.any(Object), + }); + }); + + it('calls KeyringController to sign authorization', async () => { + await getDelegationTransaction(messengerMock, TRANSACTION_META_MOCK); + + expect(sign7702Mock).toHaveBeenCalledWith({ + chainId: 1, + contractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + from: TRANSACTION_META_MOCK.txParams.from, + nonce: NONCE_MOCK, + }); }); - expect(result.authorizationList).toBeUndefined(); + it('throws if chain does not support EIP-7702', async () => { + mockIsAtomicBatchSupported.mockResolvedValue([]); + + await expect( + getDelegationTransaction(messengerMock, TRANSACTION_META_MOCK), + ).rejects.toThrow('Chain does not support EIP-7702'); + }); + + it('throws if upgrade contract address is not found', async () => { + mockIsAtomicBatchSupported.mockResolvedValue([ + { + chainId: TRANSACTION_META_MOCK.chainId, + isSupported: false, + upgradeContractAddress: undefined, + }, + ]); + + await expect( + getDelegationTransaction(messengerMock, TRANSACTION_META_MOCK), + ).rejects.toThrow('Upgrade contract address not found'); + }); }); }); diff --git a/app/util/transactions/delegation.ts b/app/util/transactions/delegation.ts index 259f04e03665..47b8388c0c39 100644 --- a/app/util/transactions/delegation.ts +++ b/app/util/transactions/delegation.ts @@ -95,27 +95,39 @@ async function buildAuthorizationList( messenger: MessengerType, ): Promise { const { TransactionController } = Engine.context; - - const { chainId, delegationAddress, networkClientId, txParams } = - transactionMeta; - + const { chainId, networkClientId, txParams } = transactionMeta; const { from } = txParams; - if (delegationAddress) { - log('Skipping authorization list as already upgraded'); - return undefined; - } - - log('Including authorization as not upgraded'); - const atomicBatchResult = await TransactionController.isAtomicBatchSupported({ address: from as Hex, chainIds: [chainId], }); - const upgradeContractAddress = atomicBatchResult.find( + const chainResult = atomicBatchResult.find( (r) => r.chainId.toLowerCase() === chainId.toLowerCase(), - )?.upgradeContractAddress; + ); + + if (!chainResult) { + throw new Error('Chain does not support EIP-7702'); + } + + const { delegationAddress, isSupported, upgradeContractAddress } = + chainResult; + + if (isSupported) { + log('Skipping authorization as already upgraded'); + return undefined; + } + + if (!delegationAddress) { + log('Upgrading account to EIP-7702', { from, upgradeContractAddress }); + } else { + log('Overwriting authorization as already upgraded', { + from, + current: delegationAddress, + new: upgradeContractAddress, + }); + } if (!upgradeContractAddress) { throw new Error('Upgrade contract address not found'); diff --git a/package.json b/package.json index 65277c6c33a5..675d533d3397 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "@scure/bip32": "1.7.0", "@metamask/snaps-sdk": "^10.0.0", "react-native@0.76.9": "patch:react-native@npm%3A0.76.9#./.yarn/patches/react-native-npm-0.76.9-1c25352097.patch", - "@metamask/transaction-controller@npm:^62.3.1": "patch:@metamask/transaction-controller@npm%3A62.3.1#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-controller@npm:^62.4.0": "patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -258,6 +258,7 @@ "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^21.0.0", "@metamask/preinstalled-example-snap": "^0.7.2", + "@metamask/profile-metrics-controller": "^1.0.0", "@metamask/profile-sync-controller": "^26.0.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", @@ -287,8 +288,8 @@ "@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.3.1#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", - "@metamask/transaction-pay-controller": "^10.2.0", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", + "@metamask/transaction-pay-controller": "^10.3.0", "@metamask/tron-wallet-snap": "^1.13.0", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/shim.js b/shim.js index 230d9aaa116b..2006631c3869 100644 --- a/shim.js +++ b/shim.js @@ -203,13 +203,27 @@ if (enableApiCallLogs || isTest) { } // if mockServer is off we route to original destination - global.fetch = async (url, options) => - isMockServerAvailable + global.fetch = async (url, options) => { + // Extract URL string from Request or URL objects + let urlString; + if (typeof url === 'string') { + urlString = url; + } else if (url instanceof URL) { + urlString = url.href; + } else if (url && typeof url === 'object' && url.url) { + // Request object has a 'url' property + urlString = url.url; + } else { + urlString = String(url); + } + + return isMockServerAvailable ? originalFetch( - `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(url)}`, + `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(urlString)}`, options, ).catch(() => originalFetch(url, options)) : originalFetch(url, options); + }; if (isMockServerAvailable) { // Patch XMLHttpRequest for Axios and other libraries diff --git a/yarn.lock b/yarn.lock index 5bdebfff865f..cea8774d4f11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9201,6 +9201,23 @@ __metadata: languageName: node linkType: hard +"@metamask/profile-metrics-controller@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/profile-metrics-controller@npm:1.0.0" + dependencies: + "@metamask/accounts-controller": "npm:^35.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/keyring-controller": "npm:^25.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/polling-controller": "npm:^16.0.0" + "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/utils": "npm:^11.8.1" + async-mutex: "npm:^0.5.0" + checksum: 10/187ceb47a7247b0c801148313f6f6dd1c58d1bc5572f85ae6b8447c9fcea1feccf9a470074234cace0a4f4699a9c71cf3f1d180ddfcc08218e2615ee79cf3214 + languageName: node + linkType: hard + "@metamask/profile-sync-controller@npm:^26.0.0": version: 26.0.0 resolution: "@metamask/profile-sync-controller@npm:26.0.0" @@ -9872,9 +9889,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:62.3.1, @metamask/transaction-controller@npm:^62.3.0": - version: 62.3.1 - resolution: "@metamask/transaction-controller@npm:62.3.1" +"@metamask/transaction-controller@npm:62.4.0, @metamask/transaction-controller@npm:^62.3.0": + version: 62.4.0 + resolution: "@metamask/transaction-controller@npm:62.4.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9891,7 +9908,7 @@ __metadata: "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" "@metamask/remote-feature-flag-controller": "npm:^2.0.1" "@metamask/rpc-errors": "npm:^7.0.2" @@ -9906,7 +9923,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/bf1fc4b305fcdf295fdc75ff5ebc21014cbd00c7b7fb29d4b34deb3e95c4b621c3139e1426980bc2ca6fa6855b0b47046471d62b527841fe8309f8467133fd7f + checksum: 10/36a816c881babf7b71542857be50045cb25b1a5cf7fa5444c0ad0c101da3c6718cfd83942ad5f868b53088aa2601c234dcf47e324173014ee5037c084f783438 languageName: node linkType: hard @@ -9948,9 +9965,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.3.1#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": - version: 62.3.1 - resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.3.1#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.3.1&hash=1a3342" +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": + version: 62.4.0 + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.4.0&hash=1a3342" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9967,7 +9984,7 @@ __metadata: "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" "@metamask/remote-feature-flag-controller": "npm:^2.0.1" "@metamask/rpc-errors": "npm:^7.0.2" @@ -9982,13 +9999,13 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/2413d51478ee4af037b0eff190fb89747c9bea61087087b3d00ee82e0dd0ffeb27a941da1a7b86293ba6129e5b660e58aea36e1a478e3b6f09235b238a293b5e + checksum: 10/de9c227ae3d846e60b7f4860c65d8ea75fe6c399cf51750a2baf96fe361b3453e22ab614b8f937a71ffa7dc60d86f17b408a2d23f38baf59919f307ea60ac7d2 languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^10.2.0": - version: 10.2.0 - resolution: "@metamask/transaction-pay-controller@npm:10.2.0" +"@metamask/transaction-pay-controller@npm:^10.3.0": + version: 10.3.0 + resolution: "@metamask/transaction-pay-controller@npm:10.3.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -10000,15 +10017,15 @@ __metadata: "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^26.0.0" + "@metamask/network-controller": "npm:^27.0.0" "@metamask/remote-feature-flag-controller": "npm:^2.0.1" - "@metamask/transaction-controller": "npm:^62.3.1" + "@metamask/transaction-controller": "npm:^62.4.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/20b251ae57cf48f1ed4da018c638b0b380a6a1c1fc051a976fc5e37ee5ebbb755c03d3d261418b63f9f926a602be6614160f860102b8628d69d5f744ce57cf8e + checksum: 10/511f7f58791b31a752e80229e35749fc86a5b1333aa3dc956b6b294f0680d0881464548eaf9b7e659a85977a2dae00928e80a5bcf84638cefb9b408ed3336701 languageName: node linkType: hard @@ -36012,6 +36029,7 @@ __metadata: "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^21.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.2" + "@metamask/profile-metrics-controller": "npm:^1.0.0" "@metamask/profile-sync-controller": "npm:^26.0.0" "@metamask/providers": "npm:^18.3.1" "@metamask/react-native-acm": "npm:^1.0.1" @@ -36045,8 +36063,8 @@ __metadata: "@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.3.1#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" - "@metamask/transaction-pay-controller": "npm:^10.2.0" + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.4.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-pay-controller": "npm:^10.3.0" "@metamask/tron-wallet-snap": "npm:^1.13.0" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6"