From 5f125ba1c7ee1ce0b682ad65ced0609960d1f759 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 18 Nov 2025 15:55:54 +0100 Subject: [PATCH] feat: track RPC update from network connection banner - Add trackRpcUpdateFromBanner parameter to NetworkSettings handleNetworkUpdate - Track NetworkConnectionBannerRpcUpdated event when user updates RPC from banner - Include chain_id_caip, from_rpc_domain, and to_rpc_domain in event properties - Pass trackRpcUpdateFromBanner flag through navigation params - Add NetworkConnectionBannerRpcUpdated to MetaMetrics events --- .../NetworksSettings/NetworkSettings/index.js | 41 ++- .../NetworkSettings/index.test.tsx | 295 +++++++++++++++++- .../useNetworkConnectionBanner.test.tsx | 1 + .../useNetworkConnectionBanner.ts | 1 + app/core/Analytics/MetaMetrics.events.ts | 4 + 5 files changed, 335 insertions(+), 7 deletions(-) diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 5ba833a4d429..0e49cbc92bc6 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -32,6 +32,7 @@ import sanitizeUrl, { compareSanitizedUrl, } from '../../../../../util/sanitizeUrl'; import onlyKeepHost from '../../../../../util/onlyKeepHost'; +import { isPublicEndpointUrl } from '../../../../../core/Engine/controllers/network-controller/utils'; import { themeAppearanceLight } from '../../../../../constants/storage'; import CustomNetwork from './CustomNetworkView/CustomNetwork'; import Button, { @@ -48,6 +49,7 @@ import { selectIsRpcFailoverEnabled } from '../../../../../selectors/featureFlag import { regex } from '../../../../../../app/util/regex'; import { NetworksViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/NetworksView.selectors'; import { isSafeChainId, toHex } from '@metamask/controller-utils'; +import { hexToNumber } from '@metamask/utils'; import { CustomDefaultNetworkIDs } from '../../../../../../e2e/selectors/Onboarding/CustomDefaultNetwork.selectors'; import { updateIncomingTransactions } from '../../../../../util/transaction-controller'; import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics'; @@ -83,7 +85,7 @@ import Tag from '../../../../../component-library/components/Tags/Tag/Tag'; import { CellComponentSelectorsIDs } from '../../../../../../e2e/selectors/wallet/CellComponent.selectors'; import stripProtocol from '../../../../../util/stripProtocol'; import stripKeyFromInfuraUrl from '../../../../../util/stripKeyFromInfuraUrl'; -import { MetaMetrics } from '../../../../../core/Analytics'; +import { MetaMetrics, MetaMetricsEvents } from '../../../../../core/Analytics'; import { addItemToChainIdList, removeItemFromChainIdList, @@ -532,6 +534,7 @@ export class NetworkSettings extends PureComponent { isCustomMainnet, shouldNetworkSwitchPopToWallet, navigation, + trackRpcUpdateFromBanner, }) => { const { NetworkController } = Engine.context; @@ -569,6 +572,38 @@ export class NetworkSettings extends PureComponent { } : undefined, ); + + // Track RPC update from network connection banner + if (trackRpcUpdateFromBanner) { + const newRpcEndpoint = + networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex]; + const oldRpcEndpoint = + existingNetwork.rpcEndpoints?.[ + existingNetwork.defaultRpcEndpointIndex ?? 0 + ]; + + const chainIdAsDecimal = hexToNumber(chainId); + + const sanitizeRpcUrl = (url) => + isPublicEndpointUrl(url, infuraProjectId) + ? onlyKeepHost(url) + : 'custom'; + + this.props.metrics.trackEvent( + this.props.metrics + .createEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: `eip155:${chainIdAsDecimal}`, + from_rpc_domain: oldRpcEndpoint?.url + ? sanitizeRpcUrl(oldRpcEndpoint.url) + : 'unknown', + to_rpc_domain: sanitizeRpcUrl(newRpcEndpoint.url), + }) + .build(), + ); + } } else { await NetworkController.addNetwork({ ...networkConfig, @@ -614,6 +649,9 @@ export class NetworkSettings extends PureComponent { const shouldNetworkSwitchPopToWallet = route.params?.shouldNetworkSwitchPopToWallet ?? true; + + const trackRpcUpdateFromBanner = + route.params?.trackRpcUpdateFromBanner ?? false; // Check if CTA is disabled const isCtaDisabled = !enableAction || this.disabledByChainId() || this.disabledBySymbol(); @@ -684,6 +722,7 @@ export class NetworkSettings extends PureComponent { networkType, networkUrl, showNetworkOnboarding, + trackRpcUpdateFromBanner, }); }; diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx index 21847e0f60c5..b2cc6d34a4bb 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx @@ -24,6 +24,7 @@ import * as jsonRequest from '../../../../../util/jsonRpcRequest'; import Logger from '../../../../../util/Logger'; import Engine from '../../../../../core/Engine'; import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; const { PreferencesController } = Engine.context; jest.mock( @@ -38,14 +39,28 @@ jest.mock( }), ); +const mockTrackEvent = jest.fn(); + +const mockCreateEventBuilder = jest.fn((eventName) => { + let properties = {}; + return { + addProperties(props: Record) { + properties = { ...properties, ...props }; + return this; + }, + build() { + return { + name: eventName, + properties, + }; + }, + }; +}); + jest.mock('../../../../../components/hooks/useMetrics', () => ({ useMetrics: () => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn(() => ({ - addProperties: jest.fn(() => ({ - build: jest.fn(), - })), - })), + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }), withMetricsAwareness: (Component: unknown) => Component, })); @@ -1410,6 +1425,274 @@ describe('NetworkSettings', () => { { replacementSelectedRpcEndpointIndex: 0 }, ); }); + + it('tracks RPC update event when trackRpcUpdateFromBanner is true', async () => { + const PROPS_WITH_METRICS = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x64', + rpcEndpoints: [ + { + networkClientId: 'custom', + type: 'custom', + url: 'https://mainnet.infura.io/v3/', + }, + ], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper5 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper5.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://monad-mainnet.infura.io/v3/', + rpcUrls: [ + { + url: 'https://monad-mainnet.infura.io/v3/', + type: 'custom', + name: 'Monad RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: true, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith( + mockCreateEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: 'eip155:100', + from_rpc_domain: 'mainnet.infura.io', + to_rpc_domain: 'monad-mainnet.infura.io', + }) + .build(), + ); + }); + + it('does not track RPC update event when trackRpcUpdateFromBanner is false', async () => { + const PROPS_WITHOUT_TRACKING = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x64', + rpcEndpoints: [ + { + networkClientId: 'custom', + type: 'custom', + url: 'https://mainnet.infura.io/v3/', + }, + ], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper6 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper6.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://monad-mainnet.infura.io/v3/', + rpcUrls: [ + { + url: 'https://monad-mainnet.infura.io/v3/', + type: 'custom', + name: 'Monad RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: false, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('sanitizes custom RPC URLs as "custom" in tracking event', async () => { + const PROPS_WITH_CUSTOM_RPC = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x64', + rpcEndpoints: [ + { + networkClientId: 'custom', + type: 'custom', + url: 'https://my-private-rpc.com', + }, + ], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper7 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper7.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://another-private-rpc.com', + rpcUrls: [ + { + url: 'https://another-private-rpc.com', + type: 'custom', + name: 'Another Custom RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: true, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith( + mockCreateEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: 'eip155:100', + from_rpc_domain: 'custom', + to_rpc_domain: 'custom', + }) + .build(), + ); + }); + + it('tracks unknown for missing old RPC endpoint', async () => { + const PROPS_WITHOUT_OLD_ENDPOINT = { + ...SAMPLE_PROPS, + metrics: { + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }, + networkConfigurations: { + '0x64': { + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: undefined, + chainId: '0x64', + rpcEndpoints: [], + name: 'Custom Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapper8 = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instance = wrapper8.instance() as NetworkSettings; + + await instance.handleNetworkUpdate({ + rpcUrl: 'https://new-rpc.infura.io/v3/', + rpcUrls: [ + { + url: 'https://new-rpc.infura.io/v3/', + type: 'custom', + name: 'New RPC', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + blockExplorerUrl: 'https://etherscan.io', + nickname: 'Custom Network', + ticker: 'ETH', + isNetworkExists: [], + chainId: '0x64', + navigation: mockNavigation, + isCustomMainnet: false, + shouldNetworkSwitchPopToWallet: true, + trackRpcUpdateFromBanner: true, + }); + + expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith( + mockCreateEventBuilder( + MetaMetricsEvents.NetworkConnectionBannerRpcUpdated, + ) + .addProperties({ + chain_id_caip: 'eip155:100', + from_rpc_domain: 'unknown', + to_rpc_domain: 'new-rpc.infura.io', + }) + .build(), + ); + }); }); describe('checkIfRpcUrlExists', () => { diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx index 94ef0fa137ee..b310f5e227b9 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx @@ -226,6 +226,7 @@ describe('useNetworkConnectionBanner', () => { network: rpcUrl, shouldNetworkSwitchPopToWallet: false, shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }, ); }); diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts index 4cae292f9294..0e4f2c518cff 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts @@ -65,6 +65,7 @@ const useNetworkConnectionBanner = (): { network: rpcUrl, shouldNetworkSwitchPopToWallet: false, shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }); trackEvent( diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 3c3783c2035e..104af28f0f0e 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -514,6 +514,7 @@ enum EVENT_NAME { // NETWORK CONNECTION BANNER NETWORK_CONNECTION_BANNER_SHOWN = 'Network Connection Banner Shown', NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED = 'Network Connection Banner Update RPC Clicked', + NetworkConnectionBannerRpcUpdated = 'Network Connection Banner RPC Updated', // Deep Link Modal Viewed DEEP_LINK_PRIVATE_MODAL_VIEWED = 'Deep Link Private Modal Viewed', @@ -1332,6 +1333,9 @@ const events = { NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED: generateOpt( EVENT_NAME.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, ), + NetworkConnectionBannerRpcUpdated: generateOpt( + EVENT_NAME.NetworkConnectionBannerRpcUpdated, + ), // Multi SRP IMPORT_SECRET_RECOVERY_PHRASE_CLICKED: generateOpt(