From 5bafed167d8f0487c1f261b656320f0f844616dc Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 2 Dec 2025 13:55:09 +0100 Subject: [PATCH 1/6] feat: Add Snaps debug information to the about page (#23535) ## **Description** Add Snaps debugging information to the hidden menu on the about page. ## **Changelog** CHANGELOG entry: null ## **Manual testing steps** 1. Long press the fox icon on the about screen ## **Screenshots/Recordings** ### **After** Simulator Screenshot - iPhone 16 Pro
- 2025-12-02 at 12 23 24 --- > [!NOTE] > Connects About screen to Redux to show preinstalled Snaps (name, version, status) in the hidden debug section, with new selector support and updated tests. > > - **Settings/About (`app/components/Views/Settings/AppInformation`)**: > - Connects component to Redux and injects `preinstalledSnaps` via `getPreinstalledSnapsMetadata`. > - Displays each preinstalled Snap as `name: version (status)` in the hidden environment info (long-press fox). > - **Selectors (`app/selectors/snaps/snapController.ts`)**: > - Extends `selectSnapsMetadata` to include `version`, `status`, and `preinstalled`. > - Adds `getPreinstalledSnapsMetadata` to filter preinstalled Snaps. > - **Tests (`index.test.tsx`)**: > - Provide mock state with a preinstalled Snap; assert its display in the debug section. > - Update utilities/types imports to support mocked Redux state. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3767b00fa54afac9998af1fa407f443178e20343. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Settings/AppInformation/index.js | 17 ++++- .../Settings/AppInformation/index.test.tsx | 69 ++++++++++++++----- app/selectors/snaps/snapController.ts | 19 ++++- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/app/components/Views/Settings/AppInformation/index.js b/app/components/Views/Settings/AppInformation/index.js index 0301cbe7bda..7b09e6b70a5 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 543d1aa1000..a17c5c6074b 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/selectors/snaps/snapController.ts b/app/selectors/snaps/snapController.ts index 50cb2f0e3c5..9ea3ebf94f9 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) { From b63b52ffee2c2f20341b0f0378c1f89f7c491dae Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Tue, 2 Dec 2025 13:02:40 +0000 Subject: [PATCH 2/6] fix: fetch handling for mock server routing (#23529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Fix for mock server url string ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Normalizes `fetch` input (string/URL/Request) to a URL string before routing via the mock server proxy, with fallback to original `fetch`. > > - **E2E Shim (`shim.js`)**: > - Update `global.fetch` wrapper to normalize input (`string`, `URL`, or `Request`) into a URL string before proxying to `MOCKTTP_URL`. > - Continue proxying via `/proxy?url=...` with fallback to original `fetch` on error. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2a7dce5d7ddb2feaa4340b427f894046fd90446a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- shim.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/shim.js b/shim.js index 230d9aaa116..2006631c386 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 From 66e5e4a13a6a21566feece5c8891eaca4e9f3c7e Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 2 Dec 2025 15:06:18 +0100 Subject: [PATCH 3/6] fix: remove usePopularNetworks hook and replace with const (#23525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** PR to remove the usePopularNetworks hook in favor of constant list for trending. ## **Changelog** CHANGELOG entry: Removes usePopularNetworks hook and replace it with constant. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/ASSETS/boards/1567?assignee=61a5edc8b0b630006a140ec1&selectedIssue=ASSETS-1916 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Replaces the dynamic popular networks hook with a static TRENDING_NETWORKS_LIST used across trending UI and hooks, removes the hook/tests, and adds SEI to network constants. > > - **Trending networks source**: > - Introduce static `TRENDING_NETWORKS_LIST` in `app/components/UI/Trending/utils/trendingNetworksList.ts` with predefined `ProcessedNetwork` entries (incl. Solana/Tron guards). > - Add `SEI` to `NetworkToCaipChainId` (`app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts`). > - **Hook and component updates**: > - Replace `usePopularNetworks` with `TRENDING_NETWORKS_LIST` in: > - `useTrendingRequest.ts` > - `useSearchRequest.ts` > - `TrendingTokenNetworkBottomSheet.tsx` > - **Removals**: > - Delete `usePopularNetworks` hook and its tests. > - **Tests**: > - Update tests to mock `TRENDING_NETWORKS_LIST` and expectations in: > - `TrendingTokenNetworkBottomSheet.test.tsx` > - `TrendingTokenRowItem.test.tsx` > - `useTrendingRequest.test.ts` > - `useSearchRequest.test.ts` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 02a71605412cee0a78c4dc41781c0e7961fc7a47. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../NetworkMultiSelector.constants.ts | 1 + .../TrendingTokenRowItem.test.tsx | 5 + .../TrendingTokenNetworkBottomSheet.test.tsx | 52 +-- .../TrendingTokenNetworkBottomSheet.tsx | 4 +- .../usePopularNetworks.test.ts | 345 ------------------ .../usePopularNetworks/usePopularNetworks.ts | 141 ------- .../useSearchRequest/useSearchRequest.test.ts | 16 - .../useSearchRequest/useSearchRequest.ts | 14 +- .../useTrendingRequest.test.ts | 56 +-- .../useTrendingRequest/useTrendingRequest.ts | 14 +- .../UI/Trending/utils/trendingNetworksList.ts | 125 +++++++ 11 files changed, 196 insertions(+), 577 deletions(-) delete mode 100644 app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts delete mode 100644 app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.ts create mode 100644 app/components/UI/Trending/utils/trendingNetworksList.ts diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants.ts index 4edcb9a547e..32d90b00a6a 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/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index c901c19dc47..c563d700c69 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 ec3554312d4..d8334233b56 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 b7b2e94382f..0b027f38e3f 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 b20e30f2c58..00000000000 --- 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 5050c9f8826..00000000000 --- 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 43388999af3..396d0caa19e 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 49343fd3f28..323260277fa 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 85d47b73949..d1ecdbf5156 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 8061980d555..6ef157e8a09 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 00000000000..d33751890ef --- /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 +]; From 509b61d342cd0b9a8fef1e4060c55d2d84936c7f Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 2 Dec 2025 14:18:02 +0000 Subject: [PATCH 4/6] fix: cp-7.60.2 overwrite account upgrade in metamask pay (#23521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When generating delegation data for the `TransactionPayController`, overwrite any existing EIP-7702 delegation if not supported. Also bump the controller version to include missing authorization lists on Predict deposit source transactions, when payment token is on Polygon. ## **Changelog** CHANGELOG entry: Fixed bugs causing failed Perps and Predict deposits due to unsupported or missing account upgrades ## **Related issues** Fixes: #23494 #23514 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Refactors delegation to correctly build/overwrite EIP-7702 authorizations with validation, integrates Transaction Pay and Delegation 7702 hooks into publish flow, and bumps transaction-related controllers. > > - **Engine/TransactionController init**: > - Integrates `TransactionPayPublishHook` and `Delegation7702PublishHook` with fallback logic before smart transactions. > - Adds `isEIP7702GasFeeTokensEnabled`, `publicKeyEIP7702`, and `getNextNonce` helper. > - Exposes `getNetworkState` via `networkController.state`. > - **Delegation utils (`app/util/transactions/delegation.ts`)**: > - Refactors `buildAuthorizationList` to use `isAtomicBatchSupported` result per-chain, throwing on unsupported chains and missing `upgradeContractAddress`. > - Skips authorization if already upgraded; overwrites authorization when upgraded to a different contract. > - Signs EIP-7702 authorization using nonce lock and builds `AuthorizationList`. > - **Tests**: > - Expands `delegation.test.ts` with cases for already upgraded, different upgrade target, signing calls, and error paths; updates snapshot name. > - **Dependencies**: > - Bumps `@metamask/transaction-controller` to `62.4.0` and `@metamask/transaction-pay-controller` to `^10.3.0` (and associated `@metamask/network-controller` peer to `^27`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 923e8798b4e3b0cc9c8c8b1ae5396210ac8082ee. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../transaction-controller-init.ts | 3 - .../__snapshots__/delegation.test.ts.snap | 2 +- app/util/transactions/delegation.test.ts | 97 ++++++++++++++++--- app/util/transactions/delegation.ts | 38 +++++--- package.json | 6 +- yarn.lock | 36 +++---- 6 files changed, 131 insertions(+), 51 deletions(-) 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 4dba0037bce..057ebe79764 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/util/transactions/__snapshots__/delegation.test.ts.snap b/app/util/transactions/__snapshots__/delegation.test.ts.snap index c9beff25db6..18bca59e1b4 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 a211a53136f..e021e77b4c1 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 259f04e0366..47b8388c0c3 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 65277c6c33a..5475925bafd 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", @@ -287,8 +287,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/yarn.lock b/yarn.lock index 5bdebfff865..6c8ef27fdcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9872,9 +9872,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 +9891,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 +9906,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 +9948,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 +9967,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 +9982,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 +10000,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 @@ -36045,8 +36045,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" From 25288a64138010855a87c217cda93de606de0e5d Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:58:29 +0100 Subject: [PATCH 5/6] feat: cp-7.61.0 add `@metamask/profile-metrics-controller` (#23247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `@metamask/profile-metrics-controller` package is being added to the Mobile client. The package ships two new components and their messengers: - `ProfileMetricsController` - `ProfileMetricsService` Preview build coming from https://github.com/MetaMask/core/pull/7196 ## **Changelog** CHANGELOG entry: null ## **Related issues** Related to https://consensyssoftware.atlassian.net/browse/WPC-179 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Integrates `ProfileMetricsController` and `ProfileMetricsService` across Engine init, messengers, types, state, and tests, and adds `@metamask/profile-metrics-controller` dependency. > > - **Engine**: > - Initialize and register `ProfileMetricsController` and `ProfileMetricsService` in `Engine` init, add to `context`, and expose `ProfileMetricsController` in `state`. > - **Controllers/Services**: > - Add `profile-metrics-controller-init` (uses `RemoteFeatureFlagController` flag `extensionUxPna25` + `MetaMetrics.isEnabled` and `metaMetricsId`). > - Add `profile-metrics-service-init` (binds `fetch`, uses `SDK.Env.PRD`). > - **Messengers**: > - New restricted messengers `profile-metrics-controller-messenger` and `profile-metrics-service-messenger`; register both in `CONTROLLER_MESSENGERS`. > - **Types/Constants**: > - Extend global actions/events, controller names, `EngineState`, `ControllersToInitialize` for profile metrics; add `ProfileMetricsService` to `STATELESS_NON_CONTROLLER_NAMES`; add `ProfileMetricsController:stateChange` to background events. > - **State/Fixtures**: > - Include `ProfileMetricsController` default state in snapshots and initial background state. > - **Tests**: > - Add init and messenger tests for controller/service. > - **Dependencies**: > - Add `@metamask/profile-metrics-controller` to `package.json`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d0e0e4acce4087be259ccc14a5bcfeed2081cfe6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/Engine/Engine.ts | 10 ++ app/core/Engine/constants.ts | 2 + .../profile-metrics-controller-init.test.ts | 116 ++++++++++++++++++ .../profile-metrics-controller-init.ts | 39 ++++++ .../profile-metrics-service-init.test.ts | 47 +++++++ .../profile-metrics-service-init.ts | 31 +++++ app/core/Engine/messengers/index.ts | 10 ++ ...ofile-metrics-controller-messenger.test.ts | 30 +++++ .../profile-metrics-controller-messenger.ts | 43 +++++++ .../profile-metrics-service-messenger.test.ts | 30 +++++ .../profile-metrics-service-messenger.ts | 37 ++++++ app/core/Engine/types.ts | 24 +++- .../logs/__snapshots__/index.test.ts.snap | 8 ++ app/util/test/initial-background-state.json | 4 + package.json | 1 + yarn.lock | 18 +++ 16 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 app/core/Engine/controllers/profile-metrics-controller-init.test.ts create mode 100644 app/core/Engine/controllers/profile-metrics-controller-init.ts create mode 100644 app/core/Engine/controllers/profile-metrics-service-init.test.ts create mode 100644 app/core/Engine/controllers/profile-metrics-service-init.ts create mode 100644 app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts create mode 100644 app/core/Engine/messengers/profile-metrics-controller-messenger.ts create mode 100644 app/core/Engine/messengers/profile-metrics-service-messenger.test.ts create mode 100644 app/core/Engine/messengers/profile-metrics-service-messenger.ts diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index a92d312ebc0..58c7a70ec09 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 2659e401356..6736e77a436 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 00000000000..50aae78ce0d --- /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 00000000000..5990908d9c9 --- /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 00000000000..42cb978a347 --- /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 00000000000..64b86974af3 --- /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/messengers/index.ts b/app/core/Engine/messengers/index.ts index 3c7444870bf..3df5a56a17d 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 00000000000..e94dab2f47f --- /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 00000000000..ab88f2c4ec0 --- /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 00000000000..c3e727b08d0 --- /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 00000000000..2a9bc83e731 --- /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 30ce89c834c..b623a749e4d 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/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index e45c8447274..6129331ef9e 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 2632734f468..6b1a42ae055 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/package.json b/package.json index 5475925bafd..675d533d339 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 6c8ef27fdcc..cea8774d4f1 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" @@ -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" From 4372187cd3f47cd7acd8b7ccfbe05b9dc8ad057f Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Tue, 2 Dec 2025 10:24:46 -0700 Subject: [PATCH 6/6] fix: add useBuildPortfolioUrl hook for centralized metrics tracking (#22683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Problem We are asking the user to opt into metrics/marketing twice, first time is when the user installs the app, and the second time when the user opens the web browser within the app. We have query params being passed into Portfolio called `marketingEnabled` and `metricsEnabled` which SHOULD opt in the user's tracking preferences thus dismissing the second opt-in modal. The issue is that we are inconsistently passing these params into various entry points ### Solution Pass in the opted in metrics from mobile into the portfolio web app via query params. Created a new `useBuildPortfolioUrl` hook that automatically includes `marketingEnabled` and `metricsEnabled` query parameters when building Portfolio URLs. This centralizes the logic for including user consent preferences and ensures consistency across the app. ### Changes New Hook - Created `app/components/hooks/useBuildPortfolioUrl.ts` - Subscribes to `isDataCollectionForMarketingEnabled` from Redux state - Subscribes to `metricsEnabled` from the `useMetrics` hook - Returns a memoized function that builds Portfolio URLs with consent parameters automatically included - Supports additional parameters via the second argument - Updated all Portfolio URL entry points to use new hook Before: ```typescript const isDataCollectionForMarketingEnabled = useSelector( (state) => state.security.dataCollectionForMarketing, ); const { isEnabled } = useMetrics(); const portfolioUrl = buildPortfolioUrl( AppConstants.PORTFOLIO.URL, { marketingEnabled: isDataCollectionForMarketingEnabled ?? false, metricsEnabled: isEnabled(), srcChain: chainId, }, ); ``` After: ```typescript const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); const portfolioUrl = buildPortfolioUrlWithMetrics( AppConstants.PORTFOLIO.URL, { srcChain: chainId }, ); ``` ## **Changelog** CHANGELOG entry: Improved Portfolio integration by passing tracking consent from Mobile app ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-45 ## **Manual testing steps** ```gherkin Feature: Portfolio tracking consent parameter Scenario: user with no tracking preference opens Portfolio Given user has not set tracking preference in app settings When user taps the Portfolio link from wallet home screen Then Portfolio URL should not include `marketingEnabled` and `metricsEnabled` parameter And Portfolio website should display the consent modal Scenario: user who accepted tracking opens Portfolio Given user has accepted tracking in app settings When user taps the Portfolio link from wallet home screen Then Portfolio URL should include `marketingEnabled` and `metricsEnabled` as true And Portfolio website should skip the consent modal Scenario: user who declined tracking opens Portfolio Given user has declined tracking in app settings When user taps the Portfolio link from wallet home screen Then Portfolio URL should include `marketingEnabled` and `metricsEnabled` as false And Portfolio website should skip the consent modal Scenario: user opens Portfolio Bridge with tracking consent Given user has accepted tracking in app settings When user taps "Bridge" from token asset options Then Portfolio Bridge URL should include `marketingEnabled` and `metricsEnabled` is true And Portfolio Bridge URL should include srcChain and token parameters ``` ## **Screenshots/Recordings** `~` ### **Before** https://github.com/user-attachments/assets/4cdcdbec-0ffc-4b36-827e-eb442316eb33 ### **After** #### No Tracking Accepted https://github.com/user-attachments/assets/58b4ec4b-6fcd-4d52-9f84-f3c4e8de9720 #### Tracking Accepted https://github.com/user-attachments/assets/60921b12-87eb-40bb-9561-135d7e16742e ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a reusable hook and util to append metrics/marketing consent params to Portfolio URLs and updates Portfolio, Bridge, Stake, Browser, Trending, and AssetOptions flows to use it, with tests. > > - **Hooks/Utils**: > - Add `useBuildPortfolioUrl` to auto-include `marketingEnabled` and `metricsEnabled` when building Portfolio URLs. > - Add `buildPortfolioUrl` in `util/browser` to append standard params; deprecates scattered `appendURLParams` usage for Portfolio. > - **UI Integrations**: > - Update `AccountOverview` to build Portfolio URL with consent and map `security.dataCollectionForMarketing` to props. > - Update Bridge navigation (`useGoToPortfolioBridge`) to use hook and include `srcChain`/`token`. > - Update `StakeButton` to open Stake via hook-built URL when ineligible. > - Update `AssetOptions` Portfolio navigation to use hook. > - Update `Browser` homepage URL generation to use hook. > - Update `TrendingView` to use hook for browser button navigation. > - Export `SectionHeaderProps` interface. > - **Tests**: > - Add tests for `useBuildPortfolioUrl` and `buildPortfolioUrl`. > - Update `StakeButton` and `TrendingView` tests to reflect new URL params. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 57608daf312485b6f7754d9f3881a25ecbc81e86. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/AccountOverview/index.js | 20 +- .../UI/Bridge/hooks/useGoToPortfolioBridge.ts | 21 +- .../StakeButton/StakeButton.test.tsx | 12 +- .../UI/Stake/components/StakeButton/index.tsx | 5 +- .../Views/AssetOptions/AssetOptions.tsx | 19 +- app/components/Views/Browser/index.js | 22 +-- .../Views/TrendingView/TrendingView.test.tsx | 2 +- .../Views/TrendingView/TrendingView.tsx | 20 +- .../SectionHeader/SectionHeader.tsx | 2 +- .../hooks/useBuildPortfolioUrl.test.ts | 185 ++++++++++++++++++ app/components/hooks/useBuildPortfolioUrl.ts | 43 ++++ app/util/browser/index.test.ts | 65 ++++++ app/util/browser/index.ts | 20 ++ 13 files changed, 381 insertions(+), 55 deletions(-) create mode 100644 app/components/hooks/useBuildPortfolioUrl.test.ts create mode 100644 app/components/hooks/useBuildPortfolioUrl.ts diff --git a/app/components/UI/AccountOverview/index.js b/app/components/UI/AccountOverview/index.js index 91bc8793d59..715884466a1 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 151b96c1e72..89889af8b7d 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/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 29e8e7088ed..be251c99871 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 7c1d28bc1c3..67b4ccefb46 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/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index 9f76f2ead98..70dc0c158bb 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 c307c40c7de..fc271a1c16a 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/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index 68e5bbd9086..7146ddb1bbe 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 7c6b5a6ec35..9c9402f159e 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 ba1833ad81a..cbf53569645 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 00000000000..1e2b12a7246 --- /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 00000000000..d2179a79007 --- /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/util/browser/index.test.ts b/app/util/browser/index.test.ts index c7f62d8e0c7..92ecea1e0ac 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 8f7bf996ece..b5e0993add1 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;