diff --git a/app/scripts/lib/network-controller/messenger-action-handlers.test.ts b/app/scripts/lib/network-controller/messenger-action-handlers.test.ts index 879337e870fb..6844458f4154 100644 --- a/app/scripts/lib/network-controller/messenger-action-handlers.test.ts +++ b/app/scripts/lib/network-controller/messenger-action-handlers.test.ts @@ -1,5 +1,5 @@ import { HttpError } from '@metamask/controller-utils'; -import * as networkUtilsModule from '../../../../shared/lib/network-utils'; +import * as utilModule from '../util'; import { onRpcEndpointDegraded, onRpcEndpointUnavailable, @@ -14,8 +14,8 @@ describe('onRpcEndpointUnavailable', () => { Parameters >; let isPublicEndpointUrlMock: jest.SpyInstance< - ReturnType, - Parameters + ReturnType, + Parameters >; beforeEach(() => { @@ -24,10 +24,7 @@ describe('onRpcEndpointUnavailable', () => { 'shouldCreateRpcServiceEvents', ); - isPublicEndpointUrlMock = jest.spyOn( - networkUtilsModule, - 'isPublicEndpointUrl', - ); + isPublicEndpointUrlMock = jest.spyOn(utilModule, 'isPublicEndpointUrl'); }); it('calls shouldCreateRpcServiceEvents with the correct parameters', () => { @@ -170,8 +167,8 @@ describe('onRpcEndpointDegraded', () => { Parameters >; let isPublicEndpointUrlMock: jest.SpyInstance< - ReturnType, - Parameters + ReturnType, + Parameters >; beforeEach(() => { @@ -180,10 +177,7 @@ describe('onRpcEndpointDegraded', () => { 'shouldCreateRpcServiceEvents', ); - isPublicEndpointUrlMock = jest.spyOn( - networkUtilsModule, - 'isPublicEndpointUrl', - ); + isPublicEndpointUrlMock = jest.spyOn(utilModule, 'isPublicEndpointUrl'); }); it('calls shouldCreateRpcServiceEvents with the correct parameters', () => { diff --git a/app/scripts/lib/network-controller/messenger-action-handlers.ts b/app/scripts/lib/network-controller/messenger-action-handlers.ts index bc156bf665ad..4f1c8b0372a4 100644 --- a/app/scripts/lib/network-controller/messenger-action-handlers.ts +++ b/app/scripts/lib/network-controller/messenger-action-handlers.ts @@ -5,7 +5,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { onlyKeepHost } from '../../../../shared/lib/only-keep-host'; -import { isPublicEndpointUrl } from '../../../../shared/lib/network-utils'; +import { isPublicEndpointUrl } from '../util'; import MetaMetricsController from '../../controllers/metametrics-controller'; import { shouldCreateRpcServiceEvents } from './utils'; diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index 67a7a97c313f..846f75817161 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -30,6 +30,8 @@ import { extractRpcDomain, isKnownDomain, initializeRpcProviderDomains, + isPublicEndpointUrl, + isSpecialUseDomain, } from './util'; // Mock the module @@ -608,4 +610,145 @@ describe('app utils', () => { expect(extractRpcDomain('https://')).toBe('invalid'); }); }); + + describe('isPublicEndpointUrl', () => { + const MOCK_INFURA_PROJECT_ID = 'test-project-id'; + + it('should return true for Infura URLs', () => { + expect( + isPublicEndpointUrl( + `https://mainnet.infura.io/v3/${MOCK_INFURA_PROJECT_ID}`, + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(true); + }); + + it('should return false for unknown URLs', () => { + expect( + isPublicEndpointUrl( + 'https://unknown.example.com', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + + describe('localhost and IP addresses', () => { + it('should return false for localhost', () => { + expect( + isPublicEndpointUrl('http://localhost:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('should return false for any IPv4 address', () => { + // Loopback + expect( + isPublicEndpointUrl('http://127.0.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + // Private ranges + expect( + isPublicEndpointUrl('http://10.0.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://192.168.1.1:8545', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + // Public IPs should also return false (public providers use domain names) + expect( + isPublicEndpointUrl('http://8.8.8.8:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('should return false for any IPv6 address', () => { + expect( + isPublicEndpointUrl('http://[::1]:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://[2001:db8::1]:8545', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + }); + }); + + describe('isSpecialUseDomain', () => { + describe('RFC 6761 special-use TLDs', () => { + it('should return true for .test TLD', () => { + expect(isSpecialUseDomain('myapp.test')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.test')).toBe(true); + }); + + it('should return true for .localhost TLD', () => { + expect(isSpecialUseDomain('myapp.localhost')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.localhost')).toBe(true); + }); + + it('should return true for .invalid TLD', () => { + expect(isSpecialUseDomain('myapp.invalid')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.invalid')).toBe(true); + }); + + it('should return true for .example TLD', () => { + expect(isSpecialUseDomain('myapp.example')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.example')).toBe(true); + }); + + it('should return true for .local TLD', () => { + expect(isSpecialUseDomain('myapp.local')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.local')).toBe(true); + }); + }); + + describe('RFC 6761 reserved example domains', () => { + it('should return true for example.com', () => { + expect(isSpecialUseDomain('example.com')).toBe(true); + expect(isSpecialUseDomain('rpc.example.com')).toBe(true); + expect(isSpecialUseDomain('api.rpc.example.com')).toBe(true); + }); + + it('should return true for example.net', () => { + expect(isSpecialUseDomain('example.net')).toBe(true); + expect(isSpecialUseDomain('rpc.example.net')).toBe(true); + expect(isSpecialUseDomain('api.rpc.example.net')).toBe(true); + }); + + it('should return true for example.org', () => { + expect(isSpecialUseDomain('example.org')).toBe(true); + expect(isSpecialUseDomain('rpc.example.org')).toBe(true); + expect(isSpecialUseDomain('api.rpc.example.org')).toBe(true); + }); + }); + + describe('case insensitivity', () => { + it('should be case insensitive', () => { + expect(isSpecialUseDomain('EXAMPLE.COM')).toBe(true); + expect(isSpecialUseDomain('MyApp.TEST')).toBe(true); + expect(isSpecialUseDomain('RPC.Example.Org')).toBe(true); + }); + }); + + describe('valid public domains', () => { + it('should return false for regular domains', () => { + expect(isSpecialUseDomain('infura.io')).toBe(false); + expect(isSpecialUseDomain('mainnet.infura.io')).toBe(false); + expect(isSpecialUseDomain('alchemy.com')).toBe(false); + expect(isSpecialUseDomain('rpc.ankr.com')).toBe(false); + }); + + it('should return false for domains containing but not ending with special TLDs', () => { + expect(isSpecialUseDomain('test-rpc.com')).toBe(false); + expect(isSpecialUseDomain('example-provider.io')).toBe(false); + expect(isSpecialUseDomain('localhost-rpc.net')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for empty string', () => { + expect(isSpecialUseDomain('')).toBe(false); + }); + }); + }); }); diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index 784c6fdbace8..42a83bca2059 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -1,4 +1,5 @@ import urlLib from 'url'; +import ipRegex from 'ip-regex'; import { AccessList } from '@ethereumjs/tx'; import BN from 'bn.js'; import { memoize } from 'lodash'; @@ -25,7 +26,12 @@ import { import { CHAIN_IDS, TEST_CHAINS } from '../../../shared/constants/network'; import { stripHexPrefix } from '../../../shared/modules/hexstring-utils'; import { getMethodDataAsync } from '../../../shared/lib/four-byte'; -import { getSafeChainsListFromCacheOnly } from '../../../shared/lib/network-utils'; +import { + getSafeChainsListFromCacheOnly, + getIsMetaMaskInfuraEndpointUrl, + getIsQuicknodeEndpointUrl, + KNOWN_CUSTOM_ENDPOINT_URLS, +} from '../../../shared/lib/network-utils'; /** * @see {@link getEnvironmentType} @@ -479,7 +485,95 @@ let knownDomainsSet: Set | null = null; let initPromise: Promise | null = null; /** - * Initialize the set of known domains from the chains list + * Extracts the hostname from a URL. + * + * @param url - The URL to extract the hostname from. + * @returns The lowercase hostname, or null if the URL is invalid. + */ +function extractHostname(url: string): string | null { + try { + const parsedUrl = new URL(url); + return parsedUrl.hostname.toLowerCase(); + } catch { + return null; + } +} + +/** + * Check if a hostname is localhost or an IP address. + * Public RPC providers use domain names, not raw IP addresses. + * These should never be considered "public" endpoints even if they appear in chainlist. + * + * @param hostname - The hostname to check. + * @returns True if the hostname is localhost or an IP address (v4 or v6). + */ +function isLocalhostOrIPAddress(hostname: string): boolean { + const lowerHostname = hostname.toLowerCase(); + + // Check for localhost + if (lowerHostname === 'localhost') { + return true; + } + + // Remove brackets from IPv6 addresses for testing (e.g., [::1] -> ::1) + const hostnameWithoutBrackets = lowerHostname.replace(/^\[|\]$/gu, ''); + + // Check for IP address (v4 or v6) + if (ipRegex({ exact: true }).test(hostnameWithoutBrackets)) { + return true; + } + + return false; +} + +// RFC 6761 special-use TLDs that should never be used by real public RPC providers +const SPECIAL_USE_TLDS = [ + '.test', + '.localhost', + '.invalid', + '.example', + '.local', +] as const; + +// RFC 6761 reserved example domains +const RESERVED_EXAMPLE_DOMAINS = ['example.com', 'example.net', 'example.org']; + +/** + * Check if a hostname is a special-use domain per RFC 6761. + * These domains are reserved and should never be used by real public RPC providers. + * + * @param hostname - The hostname to check. + * @returns True if the hostname is a special-use domain. + * @see https://datatracker.ietf.org/doc/html/rfc6761 + */ +export function isSpecialUseDomain(hostname: string): boolean { + const lowerHostname = hostname.toLowerCase(); + + // Check special-use TLDs + if (SPECIAL_USE_TLDS.some((tld) => lowerHostname.endsWith(tld))) { + return true; + } + + // Check reserved example domains (exact match or subdomain) + if ( + RESERVED_EXAMPLE_DOMAINS.some( + (domain) => + lowerHostname === domain || lowerHostname.endsWith(`.${domain}`), + ) + ) { + return true; + } + + return false; +} + +/** + * Initialize the set of known domains from the safe chainlist cache. + * This should be called once at startup in the background context. + * Localhost and private IP addresses are filtered out to prevent leaking + * private network information to analytics. + * + * @returns A promise that resolves when initialization is complete. */ export async function initializeRpcProviderDomains(): Promise { if (initPromise) { @@ -494,12 +588,15 @@ export async function initializeRpcProviderDomains(): Promise { for (const chain of chainsList) { if (chain.rpc && Array.isArray(chain.rpc)) { for (const rpcUrl of chain.rpc) { - try { - const url = new URL(rpcUrl); - knownDomainsSet.add(url.hostname); - } catch (e) { - // Skip invalid URLs - continue; + const hostname = extractHostname(rpcUrl); + // Filter out localhost, IP addresses, and RFC 6761 special-use domains + // Public providers use real domain names + if ( + hostname && + !isLocalhostOrIPAddress(hostname) && + !isSpecialUseDomain(hostname) + ) { + knownDomainsSet.add(hostname); } } } @@ -516,12 +613,60 @@ export async function initializeRpcProviderDomains(): Promise { /** * Check if a domain is in the known domains list * - * @param domain - The domain to check + * @param domain - The domain to check. + * @returns True if the domain is found in the chainlist cache. */ export function isKnownDomain(domain: string): boolean { return knownDomainsSet?.has(domain?.toLowerCase()) ?? false; } +/** + * Check if an RPC endpoint URL has a "known" domain, i.e. the domain is listed + * in a public registry. Localhost and private IPs are filtered out during + * initialization of the known domains set. + * + * @param endpointUrl - The URL of the RPC endpoint. + * @returns True if the endpoint's domain is listed in a public registry. + */ +function isKnownEndpointUrl(endpointUrl: string): boolean { + const hostname = extractHostname(endpointUrl); + if (!hostname) { + return false; + } + return isKnownDomain(hostname); +} + +/** + * Some URLs that users add as networks refer to private servers, and we do not + * want to report these in Segment (or any other data collection service). This + * function returns whether the given RPC endpoint is safe to share. + * + * @param endpointUrl - The URL of the endpoint. + * @param infuraProjectId - Our Infura project ID. + * @returns True if the endpoint URL is safe to share with external data + * collection services, false otherwise. + */ +export function isPublicEndpointUrl( + endpointUrl: string, + infuraProjectId: string, +): boolean { + const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( + endpointUrl, + infuraProjectId, + ); + const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); + const isKnownCustomEndpointUrl = + KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); + const isKnownEndpoint = isKnownEndpointUrl(endpointUrl); + + return ( + isMetaMaskInfuraEndpointUrl || + isQuicknodeEndpointUrl || + isKnownCustomEndpointUrl || + isKnownEndpoint + ); +} + /** * Extracts the domain from an RPC endpoint URL with privacy considerations * diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 75aa4503cb10..9310ed51d381 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -264,6 +264,7 @@ import { getMethodDataName, previousValueComparator, initializeRpcProviderDomains, + isPublicEndpointUrl, getPlatform, getBooleanFlag, } from './lib/util'; @@ -2498,6 +2499,8 @@ export default class MetamaskController extends EventEmitter { getProviderConfig({ metamask: this.networkController.state, }), + isPublicEndpointUrl: (endpointUrl) => + isPublicEndpointUrl(endpointUrl, this.opts.infuraProjectId), grantPermissionsIncremental: this.permissionController.grantPermissionsIncremental.bind( this.permissionController, diff --git a/package.json b/package.json index 49c11333ce5d..e63a8a24717e 100644 --- a/package.json +++ b/package.json @@ -448,6 +448,7 @@ "human-standard-token-abi": "^2.0.0", "humanize-duration": "^3.32.1", "immer": "^9.0.6", + "ip-regex": "^4.3.0", "is-retry-allowed": "^2.2.0", "jest-junit": "^14.0.1", "labeled-stream-splicer": "^2.0.2", diff --git a/shared/lib/network-utils.test.ts b/shared/lib/network-utils.test.ts index cafdb57a3e5d..c928d01ed9d2 100644 --- a/shared/lib/network-utils.test.ts +++ b/shared/lib/network-utils.test.ts @@ -1,13 +1,7 @@ -import { BUILT_IN_CUSTOM_NETWORKS_RPC } from '@metamask/controller-utils'; - -import { - FEATURED_RPCS, - QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME, -} from '../constants/network'; +import { QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME } from '../constants/network'; import { getIsMetaMaskInfuraEndpointUrl, getIsQuicknodeEndpointUrl, - isPublicEndpointUrl, } from './network-utils'; jest.mock('../constants/network', () => ({ @@ -54,8 +48,6 @@ jest.mock('@metamask/controller-utils', () => ({ }, })); -const MOCK_METAMASK_INFURA_PROJECT_ID = 'metamask-infura-project-id'; - describe('getIsMetaMaskInfuraEndpointUrl', () => { it('returns true given an Infura v3 URL with the MetaMask API key at the end', () => { expect( @@ -158,47 +150,3 @@ describe('getIsQuicknodeEndpointUrl', () => { expect(getIsQuicknodeEndpointUrl('')).toBe(false); }); }); - -describe('isPublicEndpointUrl', () => { - it('returns true for Infura URLs', () => { - expect( - isPublicEndpointUrl( - `https://mainnet.infura.io/v3/${MOCK_METAMASK_INFURA_PROJECT_ID}`, - MOCK_METAMASK_INFURA_PROJECT_ID, - ), - ).toBe(true); - }); - - it('returns true for Quicknode URLs', () => { - const quicknodeUrl = - QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME['ethereum-mainnet'](); - expect( - // We can assume this is set. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - isPublicEndpointUrl(quicknodeUrl!, MOCK_METAMASK_INFURA_PROJECT_ID), - ).toBe(true); - }); - - it('returns true for featured RPC endpoints', () => { - const featuredUrl = FEATURED_RPCS[0].rpcEndpoints[0].url; - expect( - isPublicEndpointUrl(featuredUrl, MOCK_METAMASK_INFURA_PROJECT_ID), - ).toBe(true); - }); - - it('returns true for built-in custom endpoints', () => { - const builtInUrl = Object.values(BUILT_IN_CUSTOM_NETWORKS_RPC)[0]; - expect( - isPublicEndpointUrl(builtInUrl, MOCK_METAMASK_INFURA_PROJECT_ID), - ).toBe(true); - }); - - it('returns false for unknown URLs', () => { - expect( - isPublicEndpointUrl( - 'https://unknown.example.com', - MOCK_METAMASK_INFURA_PROJECT_ID, - ), - ).toBe(false); - }); -}); diff --git a/shared/lib/network-utils.ts b/shared/lib/network-utils.ts index ac88803fdf83..bacd4cdc05c5 100644 --- a/shared/lib/network-utils.ts +++ b/shared/lib/network-utils.ts @@ -87,30 +87,6 @@ export function getIsQuicknodeEndpointUrl(endpointUrl: string): boolean { } /** - * Some URLs that users add as networks refer to private servers, and we do not - * want to report these in Segment (or any other data collection service). This - * function returns whether the given RPC endpoint is safe to share. - * - * @param endpointUrl - The URL of the endpoint. - * @param infuraProjectId - Our Infura project ID. - * @returns True if the endpoint URL is safe to share with external data - * collection services, false otherwise. + * The list of known unofficial endpoint URLs. */ -export function isPublicEndpointUrl( - endpointUrl: string, - infuraProjectId: string, -) { - const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( - endpointUrl, - infuraProjectId, - ); - const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); - const isKnownCustomEndpointUrl = - KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); - - return ( - isMetaMaskInfuraEndpointUrl || - isQuicknodeEndpointUrl || - isKnownCustomEndpointUrl - ); -} +export { KNOWN_CUSTOM_ENDPOINT_URLS }; diff --git a/ui/hooks/useNetworkConnectionBanner.test.ts b/ui/hooks/useNetworkConnectionBanner.test.ts index ad717b0ac9d5..46b6c3414aa7 100644 --- a/ui/hooks/useNetworkConnectionBanner.test.ts +++ b/ui/hooks/useNetworkConnectionBanner.test.ts @@ -65,6 +65,13 @@ jest.mock('../../shared/modules/selectors/networks', () => { }; }); +jest.mock('../store/background-connection', () => { + return { + ...jest.requireActual('../store/background-connection'), + submitRequestToBackground: jest.fn().mockResolvedValue(true), + }; +}); + const mockSelectFirstUnavailableEvmNetwork = jest.mocked( selectFirstUnavailableEvmNetwork, ); @@ -186,7 +193,7 @@ describe('useNetworkConnectionBanner', () => { }); }); - it('creates a MetaMetrics event to capture that the status changed', () => { + it('creates a MetaMetrics event to capture that the status changed', async () => { mockSelectFirstUnavailableEvmNetwork.mockReturnValue({ networkName: 'Ethereum Mainnet', networkClientId: 'mainnet', @@ -204,8 +211,10 @@ describe('useNetworkConnectionBanner', () => { undefined, () => mockTrackEvent, ); - act(() => { + await act(async () => { jest.advanceTimersByTime(5000); + // Flush microtask queue to allow async trackNetworkBannerEvent to complete + await Promise.resolve(); }); expect(mockTrackEvent).toHaveBeenCalledWith({ @@ -293,7 +302,7 @@ describe('useNetworkConnectionBanner', () => { }); }); - it('creates a MetaMetrics event to capture that the status changed', () => { + it('creates a MetaMetrics event to capture that the status changed', async () => { mockSelectFirstUnavailableEvmNetwork.mockReturnValue({ networkName: 'Ethereum Mainnet', networkClientId: 'mainnet', @@ -318,8 +327,10 @@ describe('useNetworkConnectionBanner', () => { undefined, () => mockTrackEvent, ); - act(() => { + await act(async () => { jest.advanceTimersByTime(25000); + // Flush microtask queue to allow async trackNetworkBannerEvent to complete + await Promise.resolve(); }); expect(mockTrackEvent).toHaveBeenCalledWith({ diff --git a/ui/hooks/useNetworkConnectionBanner.ts b/ui/hooks/useNetworkConnectionBanner.ts index b3423ae9bcc3..b91ef24a8d99 100644 --- a/ui/hooks/useNetworkConnectionBanner.ts +++ b/ui/hooks/useNetworkConnectionBanner.ts @@ -12,10 +12,9 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../shared/constants/metametrics'; -import { infuraProjectId } from '../../shared/constants/network'; import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { onlyKeepHost } from '../../shared/lib/only-keep-host'; -import { isPublicEndpointUrl } from '../../shared/lib/network-utils'; +import { submitRequestToBackground } from '../store/background-connection'; import { NetworkConnectionBanner } from '../../shared/constants/app-state'; import { setShowInfuraSwitchToast } from '../components/app/toast-master/utils'; @@ -76,7 +75,7 @@ export const useNetworkConnectionBanner = }, [clearDegradedTimer, clearUnavailableTimer]); const trackNetworkBannerEvent = useCallback( - ({ + async ({ bannerType, eventName, networkClientId, @@ -85,54 +84,54 @@ export const useNetworkConnectionBanner = eventName: string; networkClientId: string; }) => { - if (!infuraProjectId) { - console.warn( - 'Infura project ID not found, cannot track network banner event', - ); - return; - } - - let foundNetwork: { chainId: Hex; url: string } | undefined; - for (const networkConfiguration of Object.values( - networkConfigurationsByChainId, - )) { - const rpcEndpoint = networkConfiguration.rpcEndpoints.find( - (endpoint) => endpoint.networkClientId === networkClientId, - ); - if (rpcEndpoint) { - foundNetwork = { - chainId: networkConfiguration.chainId, - url: rpcEndpoint.url, - }; - break; + try { + let foundNetwork: { chainId: Hex; url: string } | undefined; + for (const networkConfiguration of Object.values( + networkConfigurationsByChainId, + )) { + const rpcEndpoint = networkConfiguration.rpcEndpoints.find( + (endpoint) => endpoint.networkClientId === networkClientId, + ); + if (rpcEndpoint) { + foundNetwork = { + chainId: networkConfiguration.chainId, + url: rpcEndpoint.url, + }; + break; + } + } + if (!foundNetwork) { + console.warn( + `RPC endpoint not found for network client ID: ${networkClientId}`, + ); + return; } - } - if (!foundNetwork) { - console.warn( - `RPC endpoint not found for network client ID: ${networkClientId}`, - ); - return; - } - const rpcUrl = foundNetwork.url; - const chainIdAsDecimal = hexToNumber(foundNetwork.chainId); - const sanitizedRpcUrl = isPublicEndpointUrl(rpcUrl, infuraProjectId) - ? onlyKeepHost(rpcUrl) - : 'custom'; + const rpcUrl = foundNetwork.url; + const chainIdAsDecimal = hexToNumber(foundNetwork.chainId); + const isPublic = await submitRequestToBackground( + 'isPublicEndpointUrl', + [rpcUrl], + ); + const sanitizedRpcUrl = isPublic ? onlyKeepHost(rpcUrl) : 'custom'; - trackEvent({ - category: MetaMetricsEventCategory.Network, - event: eventName, - // The names of Segment properties have a particular case. - /* eslint-disable @typescript-eslint/naming-convention */ - properties: { - banner_type: bannerType, - chain_id_caip: `eip155:${chainIdAsDecimal}`, - rpc_domain: sanitizedRpcUrl, - rpc_endpoint_url: sanitizedRpcUrl, // @deprecated - Will be removed in a future release. - }, - /* eslint-enable @typescript-eslint/naming-convention */ - }); + trackEvent({ + category: MetaMetricsEventCategory.Network, + event: eventName, + // The names of Segment properties have a particular case. + /* eslint-disable @typescript-eslint/naming-convention */ + properties: { + banner_type: bannerType, + chain_id_caip: `eip155:${chainIdAsDecimal}`, + rpc_domain: sanitizedRpcUrl, + rpc_endpoint_url: sanitizedRpcUrl, // @deprecated - Will be removed in a future release. + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } catch (error) { + // Analytics tracking failed - don't surface this error since it's non-critical + console.error('Failed to track network banner event:', error); + } }, [networkConfigurationsByChainId, trackEvent], ); diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.test.js b/ui/pages/settings/networks-tab/networks-form/networks-form.test.js index 10af58ba95b1..7107e925fe6c 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.test.js +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.test.js @@ -30,6 +30,18 @@ jest.mock('../../../../../ui/store/actions', () => ({ .mockReturnValue(jest.fn().mockResolvedValue()), })); +jest.mock('../../../../store/background-connection', () => ({ + ...jest.requireActual('../../../../store/background-connection'), + submitRequestToBackground: jest.fn().mockImplementation((method, args) => { + if (method === 'isPublicEndpointUrl') { + const url = args[0]; + // Return true for Infura URLs, false for everything else + return Promise.resolve(url?.includes('infura.io') ?? false); + } + return Promise.resolve(); + }), +})); + const renderComponent = (props) => { const store = configureMockStore([thunk])({ metamask: { diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx index 8a2d1c1bf273..0ace2ef1e921 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx @@ -27,7 +27,7 @@ import { isSafeChainId, } from '../../../../../shared/modules/network.utils'; import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; -import { isPublicEndpointUrl } from '../../../../../shared/lib/network-utils'; +import { submitRequestToBackground } from '../../../../store/background-connection'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; @@ -314,37 +314,52 @@ export const NetworksForm = ({ } // Track RPC update from network connection banner + // Wrapped in try-catch to prevent analytics failures from affecting the UI + // since the network update has already succeeded at this point if (trackRpcUpdateFromBanner) { - const newRpcEndpoint = - networkPayload.rpcEndpoints[ - networkPayload.defaultRpcEndpointIndex - ]; - const oldRpcEndpoint = - existingNetwork.rpcEndpoints?.[ - existingNetwork.defaultRpcEndpointIndex ?? 0 - ]; - - const chainIdAsDecimal = hexToNumber(chainIdHex); - - const sanitizeRpcUrl = (url: string) => - isPublicEndpointUrl(url, infuraProjectId ?? '') - ? onlyKeepHost(url) - : 'custom'; - - trackEvent({ - category: MetaMetricsEventCategory.Network, - event: MetaMetricsEventName.NetworkConnectionBannerRpcUpdated, - // The names of Segment properties have a particular case. - /* eslint-disable @typescript-eslint/naming-convention */ - properties: { - chain_id_caip: `eip155:${chainIdAsDecimal}`, - from_rpc_domain: oldRpcEndpoint?.url + try { + const newRpcEndpoint = + networkPayload.rpcEndpoints[ + networkPayload.defaultRpcEndpointIndex + ]; + const oldRpcEndpoint = + existingNetwork.rpcEndpoints?.[ + existingNetwork.defaultRpcEndpointIndex ?? 0 + ]; + + const chainIdAsDecimal = hexToNumber(chainIdHex); + + const sanitizeRpcUrl = async (url: string) => { + const isPublic = await submitRequestToBackground( + 'isPublicEndpointUrl', + [url], + ); + return isPublic ? onlyKeepHost(url) : 'custom'; + }; + + const [fromRpcDomain, toRpcDomain] = await Promise.all([ + oldRpcEndpoint?.url ? sanitizeRpcUrl(oldRpcEndpoint.url) - : 'unknown', - to_rpc_domain: sanitizeRpcUrl(newRpcEndpoint.url), - }, - /* eslint-enable @typescript-eslint/naming-convention */ - }); + : Promise.resolve('unknown'), + sanitizeRpcUrl(newRpcEndpoint.url), + ]); + + trackEvent({ + category: MetaMetricsEventCategory.Network, + event: MetaMetricsEventName.NetworkConnectionBannerRpcUpdated, + // The names of Segment properties have a particular case. + /* eslint-disable @typescript-eslint/naming-convention */ + properties: { + chain_id_caip: `eip155:${chainIdAsDecimal}`, + from_rpc_domain: fromRpcDomain, + to_rpc_domain: toRpcDomain, + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } catch (error) { + // Analytics tracking failed, but network update succeeded - don't surface this error + console.error('Failed to track RPC update analytics:', error); + } } } else { await dispatch(addNetwork(networkPayload)); diff --git a/yarn.lock b/yarn.lock index d499fb6e607e..97b85851e860 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29462,7 +29462,7 @@ __metadata: languageName: node linkType: hard -"ip-regex@npm:^4.0.0": +"ip-regex@npm:^4.0.0, ip-regex@npm:^4.3.0": version: 4.3.0 resolution: "ip-regex@npm:4.3.0" checksum: 10/7ff904b891221b1847f3fdf3dbb3e6a8660dc39bc283f79eb7ed88f5338e1a3d1104b779bc83759159be266249c59c2160e779ee39446d79d4ed0890dfd06f08 @@ -33928,6 +33928,7 @@ __metadata: husky: "npm:^8.0.3" immer: "npm:^9.0.6" ini: "npm:^3.0.0" + ip-regex: "npm:^4.3.0" is-retry-allowed: "npm:^2.2.0" jest: "npm:^29.7.0" jest-canvas-mock: "npm:^2.3.1"