From 1de2b2e5677e85d5abadb470a085dcd6d2f3a081 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 9 Jun 2026 11:27:53 +0200 Subject: [PATCH 1/4] feat: register NetworkConnectionBannerController in metamask-controller --- .../messenger-client-init/controller-list.ts | 4 ++- .../messenger-client-init/messengers/index.ts | 5 +++ .../network-connection-banner/index.ts | 1 + ...-connection-banner-controller-messenger.ts | 27 +++++++++++++++ .../network-connection-banner/index.ts | 1 + ...twork-connection-banner-controller-init.ts | 34 +++++++++++++++++++ app/scripts/metamask-controller.js | 2 ++ package.json | 2 ++ yarn.lock | 17 ++++++++++ 9 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 app/scripts/messenger-client-init/messengers/network-connection-banner/index.ts create mode 100644 app/scripts/messenger-client-init/messengers/network-connection-banner/network-connection-banner-controller-messenger.ts create mode 100644 app/scripts/messenger-client-init/network-connection-banner/index.ts create mode 100644 app/scripts/messenger-client-init/network-connection-banner/network-connection-banner-controller-init.ts diff --git a/app/scripts/messenger-client-init/controller-list.ts b/app/scripts/messenger-client-init/controller-list.ts index c4eadbf6e486..55f35084dfbe 100644 --- a/app/scripts/messenger-client-init/controller-list.ts +++ b/app/scripts/messenger-client-init/controller-list.ts @@ -87,6 +87,7 @@ import { import { ClaimsController, ClaimsService } from '@metamask/claims-controller'; import { ClientController } from '@metamask/client-controller'; import { ConnectivityController } from '@metamask/connectivity-controller'; +import { NetworkConnectionBannerController } from '@metamask/network-connection-banner-controller'; import { ProfileMetricsController, ProfileMetricsService, @@ -233,7 +234,8 @@ export type MessengerClient = | StaticAssetsController | ProfileMetricsController | ProfileMetricsService - | ConnectivityController; + | ConnectivityController + | NetworkConnectionBannerController; /** * Flat state object for all messenger clients supporting or required by modular initialization. diff --git a/app/scripts/messenger-client-init/messengers/index.ts b/app/scripts/messenger-client-init/messengers/index.ts index 304ed5bf4264..c9002cb04688 100644 --- a/app/scripts/messenger-client-init/messengers/index.ts +++ b/app/scripts/messenger-client-init/messengers/index.ts @@ -92,6 +92,7 @@ import { getSubscriptionControllerMessenger, } from './subscription'; import { getConnectivityControllerMessenger } from './connectivity'; +import { getNetworkConnectionBannerControllerMessenger } from './network-connection-banner'; import { getGatorPermissionsControllerMessenger } from './gator-permissions/gator-permissions-controller-messenger'; import { getMetaMetricsControllerMessenger } from './metametrics-controller-messenger'; import { getUserStorageControllerInitMessenger } from './identity/user-storage-controller-messenger'; @@ -402,6 +403,10 @@ export const MESSENGER_FACTORIES = { getMessenger: getConnectivityControllerMessenger, getInitMessenger: noop, }, + NetworkConnectionBannerController: { + getMessenger: getNetworkConnectionBannerControllerMessenger, + getInitMessenger: noop, + }, ClaimsController: { getMessenger: getClaimsControllerMessenger, getInitMessenger: getClaimsControllerInitMessenger, diff --git a/app/scripts/messenger-client-init/messengers/network-connection-banner/index.ts b/app/scripts/messenger-client-init/messengers/network-connection-banner/index.ts new file mode 100644 index 000000000000..efa53dc129cf --- /dev/null +++ b/app/scripts/messenger-client-init/messengers/network-connection-banner/index.ts @@ -0,0 +1 @@ +export { getNetworkConnectionBannerControllerMessenger } from './network-connection-banner-controller-messenger'; diff --git a/app/scripts/messenger-client-init/messengers/network-connection-banner/network-connection-banner-controller-messenger.ts b/app/scripts/messenger-client-init/messengers/network-connection-banner/network-connection-banner-controller-messenger.ts new file mode 100644 index 000000000000..bc3cd2b2ddcf --- /dev/null +++ b/app/scripts/messenger-client-init/messengers/network-connection-banner/network-connection-banner-controller-messenger.ts @@ -0,0 +1,27 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; +import type { NetworkConnectionBannerControllerMessenger } from '@metamask/network-connection-banner-controller'; +import { RootMessenger } from '../../../lib/messenger'; + +/** + * Get a messenger for the NetworkConnectionBannerController. + * + * @param messenger - The root messenger. + * @returns The messenger for NetworkConnectionBannerController. + */ +export function getNetworkConnectionBannerControllerMessenger( + messenger: RootMessenger< + MessengerActions, + MessengerEvents + >, +): NetworkConnectionBannerControllerMessenger { + const controllerMessenger: NetworkConnectionBannerControllerMessenger = + new Messenger({ + namespace: 'NetworkConnectionBannerController', + parent: messenger, + }); + return controllerMessenger; +} diff --git a/app/scripts/messenger-client-init/network-connection-banner/index.ts b/app/scripts/messenger-client-init/network-connection-banner/index.ts new file mode 100644 index 000000000000..4d3cb9583baa --- /dev/null +++ b/app/scripts/messenger-client-init/network-connection-banner/index.ts @@ -0,0 +1 @@ +export { NetworkConnectionBannerControllerInit } from './network-connection-banner-controller-init'; diff --git a/app/scripts/messenger-client-init/network-connection-banner/network-connection-banner-controller-init.ts b/app/scripts/messenger-client-init/network-connection-banner/network-connection-banner-controller-init.ts new file mode 100644 index 000000000000..c22805435f6d --- /dev/null +++ b/app/scripts/messenger-client-init/network-connection-banner/network-connection-banner-controller-init.ts @@ -0,0 +1,34 @@ +import { + NetworkConnectionBannerController, + type NetworkConnectionBannerControllerMessenger, +} from '@metamask/network-connection-banner-controller'; + +import { MessengerClientInitFunction } from '../types'; + +/** + * Initialize the NetworkConnectionBannerController. + * + * Encapsulates the show/hide rule and 5s/30s timer state machine for the + * "Still connecting" / "Unable to connect" banner. Subscribes to + * NetworkController, NetworkEnablementController, and ConnectivityController + * state via the messenger. + * + * @param request - The controller init request. + * @param request.controllerMessenger - The messenger for the controller. + * @returns The controller init result. + */ +export const NetworkConnectionBannerControllerInit: MessengerClientInitFunction< + NetworkConnectionBannerController, + NetworkConnectionBannerControllerMessenger +> = (request) => { + const { controllerMessenger } = request; + + const messengerClient = new NetworkConnectionBannerController({ + messenger: controllerMessenger, + }); + + return { + messengerClient, + persistedStateKey: null, + }; +}; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d97b4581c959..01ad5376de67 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -406,6 +406,7 @@ import { SubscriptionServiceInit, } from './messenger-client-init/subscription'; import { ConnectivityControllerInit } from './messenger-client-init/connectivity'; +import { NetworkConnectionBannerControllerInit } from './messenger-client-init/network-connection-banner'; import { AccountTrackerControllerInit } from './messenger-client-init/account-tracker-controller-init'; import { OnboardingControllerInit } from './messenger-client-init/onboarding-controller-init'; import { RemoteFeatureFlagControllerInit } from './messenger-client-init/remote-feature-flag-controller-init'; @@ -703,6 +704,7 @@ export default class MetamaskController extends EventEmitter { SubscriptionController: SubscriptionControllerInit, SubscriptionService: SubscriptionServiceInit, ConnectivityController: ConnectivityControllerInit, + NetworkConnectionBannerController: NetworkConnectionBannerControllerInit, NetworkOrderController: NetworkOrderControllerInit, ShieldController: ShieldControllerInit, ClaimsController: ClaimsControllerInit, diff --git a/package.json b/package.json index acdf111f3bce..80f2f157eb73 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "yarn-binary:hydrate": "corepack hydrate .yarn/yarn-corepack.tgz --activate" }, "resolutions": { + "@metamask/network-connection-banner-controller": "npm:@metamask-previews/network-connection-banner-controller@0.1.0-preview-7507a11", "@metamask/bridge-controller": "73.2.0", "@metamask/messenger@npm:^0.3.0": "^1.2.0", "@metamask/network-controller": "32.0.0", @@ -397,6 +398,7 @@ "@metamask/multichain-network-controller": "^3.1.0", "@metamask/multichain-transactions-controller": "^7.1.0", "@metamask/name-controller": "^9.1.0", + "@metamask/network-connection-banner-controller": "^0.1.0", "@metamask/network-controller": "^32.0.0", "@metamask/network-enablement-controller": "^5.3.0", "@metamask/notification-services-controller": "^23.1.0", diff --git a/yarn.lock b/yarn.lock index f76cdf132a47..b80008be81fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7466,6 +7466,22 @@ __metadata: languageName: node linkType: hard +"@metamask/network-connection-banner-controller@npm:@metamask-previews/network-connection-banner-controller@0.1.0-preview-7507a11": + version: 0.1.0-preview-7507a11 + resolution: "@metamask-previews/network-connection-banner-controller@npm:0.1.0-preview-7507a11" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/connectivity-controller": "npm:^0.2.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/network-controller": "npm:^32.0.0" + "@metamask/network-enablement-controller": "npm:^5.3.0" + "@metamask/utils": "npm:^11.9.0" + ip-regex: "npm:^4.3.0" + psl: "npm:^1.15.0" + checksum: 10/a3a8bb5f0e14aa503c72ed4d0b16ebd1763817bbdf3fb04f5dc4c6156ca8c0f02284fe73989676043b48fc198c366b6171f369fbafd1ceb906b397ccc22dcd11 + languageName: node + linkType: hard + "@metamask/network-controller@npm:32.0.0": version: 32.0.0 resolution: "@metamask/network-controller@npm:32.0.0" @@ -33538,6 +33554,7 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.1.0" "@metamask/multichain-transactions-controller": "npm:^7.1.0" "@metamask/name-controller": "npm:^9.1.0" + "@metamask/network-connection-banner-controller": "npm:^0.1.0" "@metamask/network-controller": "npm:^32.0.0" "@metamask/network-enablement-controller": "npm:^5.3.0" "@metamask/notification-services-controller": "npm:^23.1.0" From d78693a925c69ac6ed6b656c1d2e856e94cd45af Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 9 Jun 2026 11:48:56 +0200 Subject: [PATCH 2/4] refactor: drop in-app banner rule and consume NetworkConnectionBannerController --- ...pp-state-controller-method-action-types.ts | 11 - .../controllers/app-state-controller.ts | 26 - app/scripts/metamask-controller.js | 4 - shared/types/background.ts | 1 - .../network-connection-banner.test.tsx | 3 - ui/hooks/useNetworkConnectionBanner.test.ts | 793 +++--------------- ui/hooks/useNetworkConnectionBanner.ts | 179 +--- ui/selectors/multichain/networks.test.ts | 776 ----------------- ui/selectors/multichain/networks.ts | 196 ----- ui/selectors/selectors.js | 42 +- ui/store/actions.ts | 15 +- 11 files changed, 159 insertions(+), 1887 deletions(-) diff --git a/app/scripts/controllers/app-state-controller-method-action-types.ts b/app/scripts/controllers/app-state-controller-method-action-types.ts index 6b287e577eec..3f6f4c6bb94d 100644 --- a/app/scripts/controllers/app-state-controller-method-action-types.ts +++ b/app/scripts/controllers/app-state-controller-method-action-types.ts @@ -296,16 +296,6 @@ export type AppStateControllerSetProductTourAction = { handler: AppStateController['setProductTour']; }; -/** - * Updates the network connection banner state - * - * @param networkConnectionBanner - The new banner state - */ -export type AppStateControllerUpdateNetworkConnectionBannerAction = { - type: `AppStateController:updateNetworkConnectionBanner`; - handler: AppStateController['updateNetworkConnectionBanner']; -}; - /** * Sets a unique ID for the current extension popup * @@ -587,7 +577,6 @@ export type AppStateControllerMethodActions = | AppStateControllerSetMusdConversionEducationSeenAction | AppStateControllerAddMusdConversionDismissedCtaKeyAction | AppStateControllerSetProductTourAction - | AppStateControllerUpdateNetworkConnectionBannerAction | AppStateControllerSetCurrentExtensionPopupIdAction | AppStateControllerSetTrezorModelAction | AppStateControllerUpdateNftDropDownStateAction diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 4a55a498afb7..d4271213ab93 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -42,7 +42,6 @@ import { SecurityAlertResponse } from '../lib/ppom/types'; import { AccountOverviewTabKey, CarouselSlide, - NetworkConnectionBanner, StorageWriteErrorType, } from '../../../shared/constants/app-state'; import type { @@ -114,7 +113,6 @@ export type AppStateControllerState = { lastUpdatedAt: number | null; lastUpdatedFromVersion: string | null; lastViewedUserSurvey: number | null; - networkConnectionBanner: NetworkConnectionBanner; newPrivacyPolicyToastClickedOrClosed: boolean | null; newPrivacyPolicyToastShownDate: number | null; pna25Acknowledged: boolean; @@ -260,7 +258,6 @@ type AppStateControllerInitState = Partial< | 'signatureSecurityAlertResponses' | 'addressSecurityAlertResponses' | 'currentExtensionPopupId' - | 'networkConnectionBanner' > >; @@ -334,9 +331,6 @@ function getInitialStateOverrides() { currentExtensionPopupId: 0, nftsDropdownState: {}, signatureSecurityAlertResponses: {}, - networkConnectionBanner: { - status: 'unknown' as const, - }, }; } @@ -439,12 +433,6 @@ const controllerMetadata: StateMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, - networkConnectionBanner: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, newPrivacyPolicyToastClickedOrClosed: { includeInStateLogs: true, persist: true, @@ -749,7 +737,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'setTermsOfUseLastAgreed', 'setTrezorModel', 'setUpdateModalLastDismissedAt', - 'updateNetworkConnectionBanner', 'updateNftDropDownState', 'updateSlides', 'updateThrottledOriginState', @@ -1349,19 +1336,6 @@ export class AppStateController extends BaseController< }); } - /** - * Updates the network connection banner state - * - * @param networkConnectionBanner - The new banner state - */ - updateNetworkConnectionBanner( - networkConnectionBanner: AppStateControllerState['networkConnectionBanner'], - ): void { - this.update((state) => { - state.networkConnectionBanner = networkConnectionBanner; - }); - } - /** * Sets a unique ID for the current extension popup * diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 01ad5376de67..541f3382b754 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3328,10 +3328,6 @@ export default class MetamaskController extends EventEmitter { appStateController.addMusdConversionDismissedCtaKey.bind( appStateController, ), - updateNetworkConnectionBanner: - appStateController.updateNetworkConnectionBanner.bind( - appStateController, - ), setShowShieldEntryModalOnce: appStateController.setShowShieldEntryModalOnce.bind(appStateController), setPendingShieldCohort: diff --git a/shared/types/background.ts b/shared/types/background.ts index 15c679d88a6c..7a3f9cbf8606 100644 --- a/shared/types/background.ts +++ b/shared/types/background.ts @@ -145,7 +145,6 @@ export type ControllerStatePropertiesEnumerated = { pendingShieldCohort: AppStateControllerState['pendingShieldCohort']; pendingShieldCohortTxType: AppStateControllerState['pendingShieldCohortTxType']; throttledOrigins: AppStateControllerState['throttledOrigins']; - networkConnectionBanner: AppStateControllerState['networkConnectionBanner']; isWalletResetInProgress: AppStateControllerState['isWalletResetInProgress']; sidePanelGasPollTokens: AppStateControllerState['sidePanelGasPollTokens']; passkeyAutoUnlockSuppressed: AppStateControllerState['passkeyAutoUnlockSuppressed']; diff --git a/ui/components/app/network-connection-banner/network-connection-banner.test.tsx b/ui/components/app/network-connection-banner/network-connection-banner.test.tsx index f35f14aa6246..c554ef1e80a8 100644 --- a/ui/components/app/network-connection-banner/network-connection-banner.test.tsx +++ b/ui/components/app/network-connection-banner/network-connection-banner.test.tsx @@ -11,9 +11,6 @@ import { NETWORKS_ROUTE } from '../../../helpers/constants/routes'; import { NetworkConnectionBanner } from './network-connection-banner'; jest.mock('../../../store/actions', () => ({ - updateNetworkConnectionBanner: jest.fn(() => ({ - type: 'UPDATE_NETWORK_CONNECTION_BANNER', - })), setEditedNetwork: jest.fn(() => ({ type: 'SET_EDITED_NETWORK', })), diff --git a/ui/hooks/useNetworkConnectionBanner.test.ts b/ui/hooks/useNetworkConnectionBanner.test.ts index 8e0eb75a848b..0d3f602c157f 100644 --- a/ui/hooks/useNetworkConnectionBanner.test.ts +++ b/ui/hooks/useNetworkConnectionBanner.test.ts @@ -1,85 +1,45 @@ import { act } from '@testing-library/react'; import { RpcEndpointType } from '@metamask/network-controller'; import { renderHookWithProviderTyped } from '../../test/lib/render-helpers-navigate'; -import { selectFirstFailedNetworkForNetworkConnectionBanner } from '../selectors/multichain/networks'; -import { - getNetworkConnectionBanner, - getIsDeviceOffline, -} from '../selectors/selectors'; -import { updateNetworkConnectionBanner, updateNetwork } from '../store/actions'; +import { getNetworkConnectionBanner } from '../selectors/selectors'; +import { updateNetwork } from '../store/actions'; import { setShowInfuraSwitchToast } from '../components/app/toast-master/utils'; import mockState from '../../test/data/mock-state.json'; import { MetaMetricsEventName } from '../../shared/constants/metametrics'; import { getNetworkConfigurationsByChainId } from '../../shared/lib/selectors/networks'; import { useNetworkConnectionBanner } from './useNetworkConnectionBanner'; -jest.mock('../../shared/constants/network', () => { - return { - ...jest.requireActual('../../shared/constants/network'), - infuraProjectId: 'mock-infura-project-id', - }; -}); - -jest.mock('../selectors/multichain/networks', () => { - return { - ...jest.requireActual('../selectors/multichain/networks'), - selectFirstFailedNetworkForNetworkConnectionBanner: jest.fn(), - }; -}); - -jest.mock('../selectors/selectors', () => { - return { - ...jest.requireActual('../selectors/selectors'), - getNetworkConnectionBanner: jest.fn(), - getIsDeviceOffline: jest.fn(), - }; -}); - -jest.mock('../store/actions', () => { - return { - ...jest.requireActual('../store/actions'), - updateNetworkConnectionBanner: jest.fn(() => ({ - type: 'UPDATE_NETWORK_CONNECTION_BANNER', - })), - // Return a thunk-like function that returns a promise - updateNetwork: jest.fn( - () => () => Promise.resolve({ type: 'UPDATE_NETWORK' }), - ), - }; -}); - -jest.mock('../components/app/toast-master/utils', () => { - return { - ...jest.requireActual('../components/app/toast-master/utils'), - setShowInfuraSwitchToast: jest.fn((value) => ({ - type: 'SET_SHOW_INFURA_SWITCH_TOAST', - payload: value, - })), - }; -}); - -jest.mock('../../shared/lib/selectors/networks', () => { - return { - ...jest.requireActual('../../shared/lib/selectors/networks'), - getNetworkConfigurationsByChainId: jest.fn(), - }; -}); - -jest.mock('../store/background-connection', () => { - return { - ...jest.requireActual('../store/background-connection'), - submitRequestToBackground: jest.fn().mockResolvedValue(true), - }; -}); +jest.mock('../selectors/selectors', () => ({ + ...jest.requireActual('../selectors/selectors'), + getNetworkConnectionBanner: jest.fn(), +})); + +jest.mock('../store/actions', () => ({ + ...jest.requireActual('../store/actions'), + updateNetwork: jest.fn( + () => () => Promise.resolve({ type: 'UPDATE_NETWORK' }), + ), +})); + +jest.mock('../components/app/toast-master/utils', () => ({ + ...jest.requireActual('../components/app/toast-master/utils'), + setShowInfuraSwitchToast: jest.fn((value) => ({ + type: 'SET_SHOW_INFURA_SWITCH_TOAST', + payload: value, + })), +})); + +jest.mock('../../shared/lib/selectors/networks', () => ({ + ...jest.requireActual('../../shared/lib/selectors/networks'), + getNetworkConfigurationsByChainId: jest.fn(), +})); + +jest.mock('../store/background-connection', () => ({ + ...jest.requireActual('../store/background-connection'), + submitRequestToBackground: jest.fn().mockResolvedValue(true), +})); -const mockSelectFirstFailedNetworkForNetworkConnectionBanner = jest.mocked( - selectFirstFailedNetworkForNetworkConnectionBanner, -); const mockGetNetworkConnectionBanner = jest.mocked(getNetworkConnectionBanner); -const mockGetIsDeviceOffline = jest.mocked(getIsDeviceOffline); -const mockUpdateNetworkConnectionBanner = jest.mocked( - updateNetworkConnectionBanner, -); const mockGetNetworkConfigurationsByChainId = jest.mocked( getNetworkConfigurationsByChainId, ); @@ -89,17 +49,17 @@ const mockSetShowInfuraSwitchToast = jest.mocked(setShowInfuraSwitchToast); describe('useNetworkConnectionBanner', () => { beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); - - // Default to online - mockGetIsDeviceOffline.mockReturnValue(false); - mockGetNetworkConfigurationsByChainId.mockReturnValue({ '0x1': { - name: 'Ethereum Mainnet', chainId: '0x1', + name: 'Ethereum Mainnet', nativeCurrency: 'ETH', rpcEndpoints: [ + { + networkClientId: 'custom-client', + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + type: RpcEndpointType.Custom, + }, { networkClientId: 'mainnet', url: 'https://mainnet.infura.io/v3/{infuraProjectId}', @@ -108,54 +68,33 @@ describe('useNetworkConnectionBanner', () => { ], defaultRpcEndpointIndex: 0, blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, }, }); }); - afterEach(() => { - jest.useRealTimers(); - }); - - describe('when all networks are available', () => { - it("updates the status of the banner to 'available' if not already updated", () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'available', - }); - }); + it('returns the banner state from the selector', () => { + mockGetNetworkConnectionBanner.mockReturnValue({ status: 'available' }); - it("does not update the status of the banner to 'available' if already updated", () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'available', - }); + const { result } = renderHookWithProviderTyped( + () => useNetworkConnectionBanner(), + mockState, + ); - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); + expect(result.current.status).toBe('available'); + }); - expect(mockUpdateNetworkConnectionBanner).not.toHaveBeenCalled(); + it('fires the banner-shown analytics event when transitioning to a visible status', async () => { + const mockTrackEvent = jest.fn(); + mockGetNetworkConnectionBanner.mockReturnValue({ + status: 'degraded', + networkName: 'Ethereum Mainnet', + networkClientId: 'mainnet', + chainId: '0x1', + isInfuraEndpoint: true, }); - it('does not create a MetaMetrics event', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - const mockTrackEvent = jest.fn(); - + await act(async () => { renderHookWithProviderTyped( () => useNetworkConnectionBanner(), mockState, @@ -163,480 +102,41 @@ describe('useNetworkConnectionBanner', () => { undefined, () => mockTrackEvent, ); - - expect(mockTrackEvent).not.toHaveBeenCalled(); - }); - }); - - describe('when at least one network is not available yet', () => { - describe('if the status of the banner is "unknown"', () => { - describe('if at least one network is still not available after 5 seconds', () => { - it('updates the status of the banner to "degraded"', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - { - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }, - ); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - act(() => { - jest.advanceTimersByTime(5000); - }); - - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - }); - - it('creates a MetaMetrics event to capture that the status changed', async () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - { - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }, - ); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - const mockTrackEvent = jest.fn(); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - undefined, - undefined, - () => mockTrackEvent, - ); - await act(async () => { - jest.advanceTimersByTime(5000); - // Flush microtask queue to allow async trackNetworkBannerEvent to complete - await Promise.resolve(); - }); - - expect(mockTrackEvent).toHaveBeenCalledWith({ - category: 'Network', - event: MetaMetricsEventName.NetworkConnectionBannerShown, - properties: { - // The names of Segment properties have a particular case. - /* eslint-disable @typescript-eslint/naming-convention */ - banner_type: 'degraded', - chain_id_caip: 'eip155:1', - rpc_domain: 'mainnet.infura.io', - rpc_endpoint_url: 'mainnet.infura.io', - /* eslint-enable @typescript-eslint/naming-convention */ - }, - }); - }); - }); - }); - - describe('if the status of the banner is "available"', () => { - it('updates the status of the banner to "degraded" after 5 seconds', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'available', - }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - act(() => { - jest.advanceTimersByTime(5000); - }); - - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - }); - }); - - describe('if the status of the banner is "degraded"', () => { - it('updates the status of the banner to "unavailable" after 25 seconds', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - act(() => { - jest.advanceTimersByTime(25000); - }); - - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'unavailable', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - }); - - it('creates a MetaMetrics event to capture that the status changed', async () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - const mockTrackEvent = jest.fn(); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - undefined, - undefined, - () => mockTrackEvent, - ); - await act(async () => { - jest.advanceTimersByTime(25000); - // Flush microtask queue to allow async trackNetworkBannerEvent to complete - await Promise.resolve(); - }); - - expect(mockTrackEvent).toHaveBeenCalledWith({ - category: 'Network', - event: MetaMetricsEventName.NetworkConnectionBannerShown, - properties: { - // The names of Segment properties have a particular case. - /* eslint-disable @typescript-eslint/naming-convention */ - banner_type: 'unavailable', - chain_id_caip: 'eip155:1', - rpc_domain: 'mainnet.infura.io', - rpc_endpoint_url: 'mainnet.infura.io', - /* eslint-enable @typescript-eslint/naming-convention */ - }, - }); - }); + // Flush microtasks for the analytics dispatch. + await Promise.resolve(); + await Promise.resolve(); }); - }); - - describe('when some network is unavailable and then all become available', () => { - it('clears timers and updates the status of the banner to "available"', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - - const { rerender } = renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); - rerender(); - act(() => { - jest.advanceTimersByTime(30000); - }); - // Would have updated status to "degraded" if not for resetting timers - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'available', - }); - }); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: MetaMetricsEventName.NetworkConnectionBannerShown, + properties: expect.objectContaining({ + /* eslint-disable @typescript-eslint/naming-convention */ + banner_type: 'degraded', + chain_id_caip: 'eip155:1', + /* eslint-enable @typescript-eslint/naming-convention */ + }), + }), + ); }); - describe('on unmount', () => { - it('clears any timers to show the degraded and unavailable banners', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); + it('does not fire analytics when the banner status stays available', () => { + const mockTrackEvent = jest.fn(); + mockGetNetworkConnectionBanner.mockReturnValue({ status: 'available' }); - const { unmount } = renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - unmount(); - act(() => { - jest.advanceTimersByTime(5000); - }); + renderHookWithProviderTyped( + () => useNetworkConnectionBanner(), + mockState, + undefined, + undefined, + () => mockTrackEvent, + ); - // Would have updated status to "degraded" if not for resetting timers - expect(mockUpdateNetworkConnectionBanner).not.toHaveBeenCalled(); - }); - }); - - describe('when device is offline', () => { - beforeEach(() => { - mockGetIsDeviceOffline.mockReturnValue(true); - }); - - it('does not show degraded banner even if network is unavailable', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - act(() => { - jest.advanceTimersByTime(5000); - }); - - // Should reset to available, not show degraded - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'available', - }); - expect(mockUpdateNetworkConnectionBanner).not.toHaveBeenCalledWith( - expect.objectContaining({ status: 'degraded' }), - ); - }); - - it('resets banner to available when device goes offline while showing degraded', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'available', - }); - }); - - it('does not update banner if already available when offline', () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'available' }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - expect(mockUpdateNetworkConnectionBanner).not.toHaveBeenCalled(); - }); - - it('does not progress from degraded to unavailable when device goes offline', () => { - // Device is offline with degraded banner showing - mockGetIsDeviceOffline.mockReturnValue(true); - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - - renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - // Clear to only track new calls - mockUpdateNetworkConnectionBanner.mockClear(); - - // Wait for what would have been the unavailable timeout - act(() => { - jest.advanceTimersByTime(30000); - }); - - // Should NOT progress to unavailable since device is offline - expect(mockUpdateNetworkConnectionBanner).not.toHaveBeenCalledWith( - expect.objectContaining({ status: 'unavailable' }), - ); - }); - - it('resumes normal behavior when device comes back online', () => { - // Start offline - mockGetIsDeviceOffline.mockReturnValue(true); - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - // Use 'unknown' so it will try to start timers when coming back online - mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unknown' }); - - const { rerender } = renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - // Clear any calls from initial render - mockUpdateNetworkConnectionBanner.mockClear(); - - // Device comes back online - mockGetIsDeviceOffline.mockReturnValue(false); - rerender(); - - // Advance timer to trigger degraded - act(() => { - jest.advanceTimersByTime(5000); - }); - - // Should now show degraded banner - expect(mockUpdateNetworkConnectionBanner).toHaveBeenCalledWith({ - status: 'degraded', - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - }); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); describe('switchToInfura', () => { - it('calls updateNetwork and shows toast when infuraEndpointIndex is available', async () => { - const networkConfig = { - '0xa4b1': { - name: 'Arbitrum One', - chainId: '0xa4b1' as const, - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'custom-arbitrum', - url: 'https://custom.arbitrum.rpc', - type: RpcEndpointType.Custom, - }, - { - networkClientId: 'arbitrum-mainnet' as const, - url: 'https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}', - type: RpcEndpointType.Infura, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://arbiscan.io'], - defaultBlockExplorerUrlIndex: 0, - }, - }; - - mockGetNetworkConfigurationsByChainId.mockReturnValue( - networkConfig as unknown as ReturnType< - typeof mockGetNetworkConfigurationsByChainId - >, - ); - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'unavailable', - networkName: 'Arbitrum One', - networkClientId: 'custom-arbitrum', - chainId: '0xa4b1', - isInfuraEndpoint: false, - infuraEndpointIndex: 1, - }); - - const { result } = renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect(mockUpdateNetwork).toHaveBeenCalledWith( - expect.objectContaining({ - chainId: '0xa4b1', - defaultRpcEndpointIndex: 1, - }), - { replacementSelectedRpcEndpointIndex: 1 }, - ); - expect(mockSetShowInfuraSwitchToast).toHaveBeenCalledWith(true); - }); - - it('does nothing when status is available', async () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); + it('is a no-op when the banner is not visible', async () => { mockGetNetworkConnectionBanner.mockReturnValue({ status: 'available' }); const { result } = renderHookWithProviderTyped( @@ -649,20 +149,15 @@ describe('useNetworkConnectionBanner', () => { }); expect(mockUpdateNetwork).not.toHaveBeenCalled(); - expect(mockSetShowInfuraSwitchToast).not.toHaveBeenCalled(); }); - it('does nothing when infuraEndpointIndex is undefined', async () => { - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); + it('is a no-op when no Infura endpoint index is available', async () => { mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'unavailable', - networkName: 'Custom Network', - networkClientId: 'custom-network', - chainId: '0x1000', + status: 'degraded', + networkName: 'Ethereum Mainnet', + networkClientId: 'mainnet', + chainId: '0x1', isInfuraEndpoint: false, - infuraEndpointIndex: undefined, }); const { result } = renderHookWithProviderTyped( @@ -675,51 +170,14 @@ describe('useNetworkConnectionBanner', () => { }); expect(mockUpdateNetwork).not.toHaveBeenCalled(); - expect(mockSetShowInfuraSwitchToast).not.toHaveBeenCalled(); }); - it('does not show toast when updateNetwork fails', async () => { - // Mock updateNetwork to return a thunk that rejects - mockUpdateNetwork.mockImplementationOnce( - () => () => Promise.reject(new Error('Network update failed')), - ); - - const networkConfig = { - '0xa4b1': { - name: 'Arbitrum One', - chainId: '0xa4b1' as const, - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'custom-arbitrum', - url: 'https://custom.arbitrum.rpc', - type: RpcEndpointType.Custom, - }, - { - networkClientId: 'arbitrum-mainnet' as const, - url: 'https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}', - type: RpcEndpointType.Infura, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://arbiscan.io'], - defaultBlockExplorerUrlIndex: 0, - }, - }; - - mockGetNetworkConfigurationsByChainId.mockReturnValue( - networkConfig as unknown as ReturnType< - typeof mockGetNetworkConfigurationsByChainId - >, - ); - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue( - null, - ); + it('updates the network with the Infura endpoint as default and shows the success toast', async () => { mockGetNetworkConnectionBanner.mockReturnValue({ status: 'unavailable', - networkName: 'Arbitrum One', - networkClientId: 'custom-arbitrum', - chainId: '0xa4b1', + networkName: 'Ethereum Mainnet', + networkClientId: 'custom-client', + chainId: '0x1', isInfuraEndpoint: false, infuraEndpointIndex: 1, }); @@ -733,75 +191,14 @@ describe('useNetworkConnectionBanner', () => { await result.current.switchToInfura(); }); - expect(mockUpdateNetwork).toHaveBeenCalled(); - // Toast should NOT be shown when update fails - expect(mockSetShowInfuraSwitchToast).not.toHaveBeenCalled(); - }); - - it('returns fresh network details from selector to prevent stale Switch to MetaMask default RPC button', async () => { - const networkConfig = { - '0xa4b1': { - name: 'Arbitrum One', - chainId: '0xa4b1' as const, - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'custom-arbitrum', - url: 'https://custom.arbitrum.rpc', - type: RpcEndpointType.Custom, - }, - { - networkClientId: 'arbitrum-mainnet' as const, - url: 'https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}', - type: RpcEndpointType.Infura, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://arbiscan.io'], - defaultBlockExplorerUrlIndex: 0, - }, - }; - - mockGetNetworkConfigurationsByChainId.mockReturnValue( - networkConfig as unknown as ReturnType< - typeof mockGetNetworkConfigurationsByChainId - >, - ); - - // Banner state still has old custom endpoint details (stale) - mockGetNetworkConnectionBanner.mockReturnValue({ - status: 'unavailable', - networkName: 'Arbitrum One', - networkClientId: 'custom-arbitrum', - chainId: '0xa4b1', - isInfuraEndpoint: false, - infuraEndpointIndex: 1, - }); - - // But selector now returns Infura endpoint (fresh data after switch) - mockSelectFirstFailedNetworkForNetworkConnectionBanner.mockReturnValue({ - networkName: 'Arbitrum One', - networkClientId: 'arbitrum-mainnet', - chainId: '0xa4b1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - - const { result } = renderHookWithProviderTyped( - () => useNetworkConnectionBanner(), - mockState, - ); - - // Hook should return fresh data from selector, not stale Redux state - // This prevents showing "Switch to MetaMask default RPC" when already on Infura - expect(result.current).toStrictEqual( + expect(mockUpdateNetwork).toHaveBeenCalledWith( expect.objectContaining({ - status: 'unavailable', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - networkClientId: 'arbitrum-mainnet', + chainId: '0x1', + defaultRpcEndpointIndex: 1, }), + { replacementSelectedRpcEndpointIndex: 1 }, ); + expect(mockSetShowInfuraSwitchToast).toHaveBeenCalledWith(true); }); }); }); diff --git a/ui/hooks/useNetworkConnectionBanner.ts b/ui/hooks/useNetworkConnectionBanner.ts index ecf4029204bb..c7c7731ee6a4 100644 --- a/ui/hooks/useNetworkConnectionBanner.ts +++ b/ui/hooks/useNetworkConnectionBanner.ts @@ -1,12 +1,8 @@ -import { useEffect, useCallback, useRef, useContext } from 'react'; +import { useCallback, useContext, useEffect, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { Hex, hexToNumber } from '@metamask/utils'; -import { selectFirstFailedNetworkForNetworkConnectionBanner } from '../selectors/multichain/networks'; -import { - getNetworkConnectionBanner, - getIsDeviceOffline, -} from '../selectors/selectors'; -import { updateNetworkConnectionBanner, updateNetwork } from '../store/actions'; +import { getNetworkConnectionBanner } from '../selectors/selectors'; +import { updateNetwork } from '../store/actions'; import { MetaMetricsContext } from '../contexts/metametrics'; import { MetaMetricsEventCategory, @@ -32,17 +28,10 @@ type UseNetworkConnectionBannerResult = NetworkConnectionBanner & { switchToInfura: () => Promise; }; -const DEGRADED_BANNER_TIMEOUT = 5 * 1000; -const UNAVAILABLE_BANNER_TIMEOUT = 30 * 1000; - export const useNetworkConnectionBanner = (): UseNetworkConnectionBannerResult => { const dispatch = useDispatch(); const { trackEvent } = useContext(MetaMetricsContext); - const isOffline = useSelector(getIsDeviceOffline); - const failedNetwork = useSelector( - selectFirstFailedNetworkForNetworkConnectionBanner, - ); const networkConnectionBannerState = useSelector( getNetworkConnectionBanner, ); @@ -50,30 +39,6 @@ export const useNetworkConnectionBanner = getNetworkConfigurationsByChainId, ); - const timersRef = useRef<{ - degradedTimer?: NodeJS.Timeout; - unavailableTimer?: NodeJS.Timeout; - }>({}); - - const clearDegradedTimer = useCallback(() => { - if (timersRef.current.degradedTimer) { - clearTimeout(timersRef.current.degradedTimer); - timersRef.current.degradedTimer = undefined; - } - }, []); - - const clearUnavailableTimer = useCallback(() => { - if (timersRef.current.unavailableTimer) { - clearTimeout(timersRef.current.unavailableTimer); - timersRef.current.unavailableTimer = undefined; - } - }, []); - - const clearTimers = useCallback(() => { - clearDegradedTimer(); - clearUnavailableTimer(); - }, [clearDegradedTimer, clearUnavailableTimer]); - const trackNetworkBannerEvent = useCallback( async ({ bannerType, @@ -136,107 +101,29 @@ export const useNetworkConnectionBanner = [networkConfigurationsByChainId, trackEvent], ); - const startUnavailableTimer = useCallback(() => { - clearUnavailableTimer(); - - timersRef.current.unavailableTimer = setTimeout(() => { - if (failedNetwork) { - trackNetworkBannerEvent({ - bannerType: 'unavailable', - eventName: MetaMetricsEventName.NetworkConnectionBannerShown, - networkClientId: failedNetwork.networkClientId, - }); - dispatch( - updateNetworkConnectionBanner({ - status: 'unavailable', - networkName: failedNetwork.networkName, - networkClientId: failedNetwork.networkClientId, - chainId: failedNetwork.chainId, - isInfuraEndpoint: failedNetwork.isInfuraEndpoint, - infuraEndpointIndex: failedNetwork.infuraEndpointIndex, - }), - ); - } - }, UNAVAILABLE_BANNER_TIMEOUT - DEGRADED_BANNER_TIMEOUT); - }, [ - failedNetwork, - trackNetworkBannerEvent, - dispatch, - clearUnavailableTimer, - ]); - - const startDegradedTimer = useCallback(() => { - clearDegradedTimer(); - - timersRef.current.degradedTimer = setTimeout(() => { - if (failedNetwork) { - trackNetworkBannerEvent({ - bannerType: 'degraded', - eventName: MetaMetricsEventName.NetworkConnectionBannerShown, - networkClientId: failedNetwork.networkClientId, - }); - dispatch( - updateNetworkConnectionBanner({ - status: 'degraded', - networkName: failedNetwork.networkName, - networkClientId: failedNetwork.networkClientId, - chainId: failedNetwork.chainId, - isInfuraEndpoint: failedNetwork.isInfuraEndpoint, - infuraEndpointIndex: failedNetwork.infuraEndpointIndex, - }), - ); - - startUnavailableTimer(); - } - }, DEGRADED_BANNER_TIMEOUT); - }, [ - failedNetwork, - trackNetworkBannerEvent, - dispatch, - startUnavailableTimer, - clearDegradedTimer, - ]); - - // If the failed network does not change but the banner status changes, start the degraded or unavailable timer - // If the failed network changes, reset all timers and change the status - // If the device is offline, don't show network banners - the issue is device connectivity, not the network + // Fire the banner-shown analytics when the banner transitions to a + // visible status. The 5s / 30s escalation lives inside the controller; + // here we just translate state changes to analytics. + const lastReportedKeyRef = useRef(null); useEffect(() => { - // When device is offline, clear timers and reset banner state - // We don't want to show network degraded/unavailable banners when the real issue - // is the device's internet connectivity - if (isOffline) { - clearTimers(); - if (networkConnectionBannerState.status !== 'available') { - dispatch(updateNetworkConnectionBanner({ status: 'available' })); - } + if ( + networkConnectionBannerState.status !== 'degraded' && + networkConnectionBannerState.status !== 'unavailable' + ) { + lastReportedKeyRef.current = null; return; } - - if (failedNetwork) { - if (networkConnectionBannerState.status === 'degraded') { - startUnavailableTimer(); - } else if ( - networkConnectionBannerState.status === 'unknown' || - networkConnectionBannerState.status === 'available' - ) { - startDegradedTimer(); - } - } else if (networkConnectionBannerState.status !== 'available') { - dispatch(updateNetworkConnectionBanner({ status: 'available' })); + const key = `${networkConnectionBannerState.status}:${networkConnectionBannerState.networkClientId}`; + if (lastReportedKeyRef.current === key) { + return; } - - return () => { - clearTimers(); - }; - }, [ - isOffline, - failedNetwork, - clearTimers, - dispatch, - networkConnectionBannerState.status, - startDegradedTimer, - startUnavailableTimer, - ]); + lastReportedKeyRef.current = key; + trackNetworkBannerEvent({ + bannerType: networkConnectionBannerState.status, + eventName: MetaMetricsEventName.NetworkConnectionBannerShown, + networkClientId: networkConnectionBannerState.networkClientId, + }); + }, [networkConnectionBannerState, trackNetworkBannerEvent]); const switchToInfura = useCallback(async () => { if ( @@ -256,8 +143,6 @@ export const useNetworkConnectionBanner = return; } - // Update the network configuration to use the Infura endpoint as default - // Only show success toast if the update completes without error try { await dispatch( updateNetwork( @@ -285,26 +170,6 @@ export const useNetworkConnectionBanner = dispatch, ]); - // When in degraded/unavailable status, use fresh selector data for network details - // to prevent stale "Switch to MetaMask default RPC" button after switching endpoints - if ( - (networkConnectionBannerState.status === 'degraded' || - networkConnectionBannerState.status === 'unavailable') && - failedNetwork - ) { - return { - ...networkConnectionBannerState, - // Override with fresh data from selector - networkClientId: failedNetwork.networkClientId, - networkName: failedNetwork.networkName, - chainId: failedNetwork.chainId, - isInfuraEndpoint: failedNetwork.isInfuraEndpoint, - infuraEndpointIndex: failedNetwork.infuraEndpointIndex, - trackNetworkBannerEvent, - switchToInfura, - }; - } - return { ...networkConnectionBannerState, trackNetworkBannerEvent, diff --git a/ui/selectors/multichain/networks.test.ts b/ui/selectors/multichain/networks.test.ts index d35e60b21b38..786c6b7aecca 100644 --- a/ui/selectors/multichain/networks.test.ts +++ b/ui/selectors/multichain/networks.test.ts @@ -26,7 +26,6 @@ import { getSelectedMultichainNetworkChainId, getSelectedMultichainNetworkConfiguration, getIsEvmMultichainNetworkSelected, - selectFirstFailedNetworkForNetworkConnectionBanner, getEvmMultichainNetworkConfigurations, getAllMultichainNetworkConfigurations, } from './networks'; @@ -594,779 +593,4 @@ describe('Multichain network selectors', () => { ).toStrictEqual(mockEvmNetworksWithNewConfig['eip155:1']); }); }); - - describe('selectFirstFailedNetworkForNetworkConnectionBanner', () => { - it('returns the first failed network when every enabled network has failed (all-down escape hatch)', () => { - const mockStateWithMultipleUnavailableNetworks = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0xaa36a7': true, - }, - }, - networksMetadata: { - mainnet: { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - sepolia: { - EIPS: {}, - status: NetworkStatus.Blocked, - }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithMultipleUnavailableNetworks, - ), - ).toStrictEqual({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - }); - - it('returns the failed custom network when both a custom and Infura network are down', () => { - const mockStateWithMultipleUnavailableNetworks = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1000': true, - '0xaa36a7': true, - }, - }, - networksMetadata: { - 'AAAA-BBBB-CCCC-DDDD': { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - sepolia: { - EIPS: {}, - status: NetworkStatus.Blocked, - }, - }, - networkConfigurationsByChainId: { - '0x1000': { - chainId: '0x1000' as const, - name: 'Custom Network', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Custom as const, - url: 'https://custom.network', - networkClientId: 'AAAA-BBBB-CCCC-DDDD' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithMultipleUnavailableNetworks, - ), - ).toStrictEqual({ - networkName: 'Custom Network', - networkClientId: 'AAAA-BBBB-CCCC-DDDD', - chainId: '0x1000', - isInfuraEndpoint: false, - infuraEndpointIndex: undefined, - }); - }); - - it('returns null when all enabled EVM networks are available', () => { - const mockStateWithAvailableEvmNetworks = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0xaa36a7': true, - }, - }, - networksMetadata: { - mainnet: { - EIPS: {}, - status: NetworkStatus.Available, - }, - sepolia: { - EIPS: {}, - status: NetworkStatus.Available, - }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithAvailableEvmNetworks, - ), - ).toBeNull(); - }); - - it('returns null when no EVM networks are enabled', () => { - const mockStateWithNoEnabledEvmNetworks = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: {}, - }, - networksMetadata: {}, - networkConfigurationsByChainId: {}, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithNoEnabledEvmNetworks, - ), - ).toBeNull(); - }); - - it('skips networks with missing metadata', () => { - const mockStateWithMissingMetadata = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - }, - }, - networksMetadata: {}, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithMissingMetadata, - ), - ).toBeNull(); - }); - - it('skips networks that do not have network configurations', () => { - const mockStateWithMissingNetworkConfig = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - }, - }, - networksMetadata: { - mainnet: { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - }, - networkConfigurationsByChainId: {}, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithMissingNetworkConfig, - ), - ).toBeNull(); - }); - - it('returns infuraEndpointIndex when custom network has an Infura endpoint available', () => { - const mockStateWithCustomAndInfuraEndpoints = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0xa4b1': true, - }, - }, - networksMetadata: { - 'custom-arbitrum': { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - }, - networkConfigurationsByChainId: { - '0xa4b1': { - chainId: '0xa4b1' as const, - name: 'Arbitrum One', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Custom as const, - url: 'https://custom.arbitrum.rpc', - networkClientId: 'custom-arbitrum' as const, - }, - { - type: RpcEndpointType.Infura as const, - url: 'https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'arbitrum-mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'custom-arbitrum', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithCustomAndInfuraEndpoints as Parameters< - typeof selectFirstFailedNetworkForNetworkConnectionBanner - >[0], - ), - ).toStrictEqual({ - networkName: 'Arbitrum One', - networkClientId: 'custom-arbitrum', - chainId: '0xa4b1', - isInfuraEndpoint: false, - infuraEndpointIndex: 1, - }); - }); - - it('returns undefined infuraEndpointIndex when custom network has no Infura endpoint', () => { - const mockStateWithOnlyCustomEndpoint = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1000': true, - }, - }, - networksMetadata: { - 'custom-network': { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - }, - networkConfigurationsByChainId: { - '0x1000': { - chainId: '0x1000' as const, - name: 'Custom Network', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Custom as const, - url: 'https://custom.network.rpc', - networkClientId: 'custom-network' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'custom-network', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithOnlyCustomEndpoint, - ), - ).toStrictEqual({ - networkName: 'Custom Network', - networkClientId: 'custom-network', - chainId: '0x1000', - isInfuraEndpoint: false, - infuraEndpointIndex: undefined, - }); - }); - - it('returns the network when only one network is enabled and it has failed (all-down escape hatch)', () => { - const mockStateWithInfuraAsDefault = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - }, - }, - networksMetadata: { - mainnet: { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - const result = selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithInfuraAsDefault, - ); - expect(result).toStrictEqual({ - networkName: 'Ethereum Mainnet', - networkClientId: 'mainnet', - chainId: '0x1', - isInfuraEndpoint: true, - infuraEndpointIndex: undefined, - }); - }); - - it('returns null when only one Infura network out of many enabled has failed', () => { - // Single Infura blip in an otherwise-healthy set: 1 distinct domain, - // not all-down. Suppress to avoid the noisy banner. See WPC-1014. - const mockStateWithSingleInfuraDown = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0xaa36a7': true, - }, - }, - networksMetadata: { - mainnet: { - EIPS: {}, - status: NetworkStatus.Available, - }, - sepolia: { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithSingleInfuraDown, - ), - ).toBeNull(); - }); - - it('returns null when multiple Infura networks fail together but stay on one domain and others remain available', () => { - // Infura-wide partial outage: three *.infura.io networks down, but two - // popular non-Infura RPCs are still healthy. Only 1 distinct domain in - // the failed set, not all-down -> suppress the banner. - const mockStateWithInfuraPartialOutage = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0xaa36a7': true, - '0xe708': true, - '0xa4b1': true, - '0xa': true, - }, - }, - networksMetadata: { - mainnet: { EIPS: {}, status: NetworkStatus.Unavailable }, - sepolia: { EIPS: {}, status: NetworkStatus.Unavailable }, - linea: { EIPS: {}, status: NetworkStatus.Unavailable }, - 'arbitrum-alchemy': { EIPS: {}, status: NetworkStatus.Available }, - 'optimism-alchemy': { EIPS: {}, status: NetworkStatus.Available }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xe708': { - chainId: '0xe708' as const, - name: 'Linea', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'linea' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xa4b1': { - chainId: '0xa4b1' as const, - name: 'Arbitrum One', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'arbitrum-alchemy' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xa': { - chainId: '0xa' as const, - name: 'Optimism', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://optimism-mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'optimism-alchemy' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithInfuraPartialOutage as unknown as Parameters< - typeof selectFirstFailedNetworkForNetworkConnectionBanner - >[0], - ), - ).toBeNull(); - }); - - it('returns the first failed network when failures span 2+ domains', () => { - const mockStateWithTwoDomainsDown = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0xa4b1': true, - '0xaa36a7': true, - }, - }, - networksMetadata: { - mainnet: { EIPS: {}, status: NetworkStatus.Unavailable }, - 'arbitrum-alchemy': { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - sepolia: { EIPS: {}, status: NetworkStatus.Available }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xa4b1': { - chainId: '0xa4b1' as const, - name: 'Arbitrum One', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Custom as const, - url: 'https://arb-mainnet.g.alchemy.com/v2/abc', - networkClientId: 'arbitrum-alchemy' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - // Both Mainnet (Infura) and the Alchemy-backed Arbitrum have failed - // -> 2 distinct domains -> banner. The Alchemy RPC is custom so the - // override surfaces it for the CTA. - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithTwoDomainsDown, - ), - ).toStrictEqual({ - networkName: 'Arbitrum One', - networkClientId: 'arbitrum-alchemy', - chainId: '0xa4b1', - isInfuraEndpoint: false, - infuraEndpointIndex: undefined, - }); - }); - - it('returns the failed custom network even when other networks are available (custom override)', () => { - const mockStateWithCustomDownAmongAvailable = { - metamask: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0xaa36a7': true, - '0x1000': true, - }, - }, - networksMetadata: { - mainnet: { EIPS: {}, status: NetworkStatus.Available }, - sepolia: { EIPS: {}, status: NetworkStatus.Available }, - 'custom-network': { - EIPS: {}, - status: NetworkStatus.Unavailable, - }, - }, - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1' as const, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'mainnet' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0xaa36a7': { - chainId: '0xaa36a7' as const, - name: 'Sepolia', - nativeCurrency: 'SepoliaETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Infura as const, - url: 'https://sepolia.infura.io/v3/{infuraProjectId}' as const, - networkClientId: 'sepolia' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - '0x1000': { - chainId: '0x1000' as const, - name: 'Custom Network', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - type: RpcEndpointType.Custom as const, - url: 'https://custom.network', - networkClientId: 'custom-network' as const, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: [], - defaultBlockExplorerUrlIndex: 0, - }, - }, - selectedNetworkClientId: 'mainnet', - }, - }; - - expect( - selectFirstFailedNetworkForNetworkConnectionBanner( - mockStateWithCustomDownAmongAvailable, - ), - ).toStrictEqual({ - networkName: 'Custom Network', - networkClientId: 'custom-network', - chainId: '0x1000', - isInfuraEndpoint: false, - infuraEndpointIndex: undefined, - }); - }); - }); }); diff --git a/ui/selectors/multichain/networks.ts b/ui/selectors/multichain/networks.ts index b56ef315b148..fda671d752dc 100644 --- a/ui/selectors/multichain/networks.ts +++ b/ui/selectors/multichain/networks.ts @@ -452,202 +452,6 @@ export const selectAnyEnabledNetworksAreAvailable = createSelector( ); }, ); - -/** - * Network configurations and their RPC endpoints annotated with information - * that the network connection banner logic cares about: whether the endpoint is - * an Infura URL, whether its current metadata status is anything other than - * Available ("failed"), and its registrable domain. The network itself also - * gets an `infuraEndpointIndex` pointing at the first Infura endpoint (used by - * the "Switch to Infura" CTA). - */ -const selectEnhancedNetworkConfigurationsByChainId = createSelector( - getNetworkConfigurationsByChainId, - getNetworksMetadata, - (networkConfigurationsByChainId, networksMetadata) => { - const enhancedNetworkConfigurationsByChainId: Record< - Hex, - Omit<(typeof networkConfigurationsByChainId)[Hex], 'rpcEndpoints'> & { - rpcEndpoints: { - networkClientId: string; - url: string; - isInfuraEndpoint: boolean; - isFailed: boolean; - domain: string | null; - }[]; - infuraEndpointIndex: number | undefined; - } - > = {}; - - for (const [chainId, networkConfiguration] of Object.entries( - networkConfigurationsByChainId, - )) { - const enhancedRpcEndpoints = networkConfiguration.rpcEndpoints.map( - (rpcEndpoint) => { - const metadata = networksMetadata[rpcEndpoint.networkClientId]; - // We have to use this function to check whether the endpoint is - // an Infura endpoint because some Infura endpoint URLs use the - // wrong type. - const isInfuraEndpoint = getIsMetaMaskInfuraEndpointUrl( - rpcEndpoint.url, - infuraProjectId ?? '', - ); - const isFailed = - metadata !== undefined && - metadata.status !== NetworkStatus.Available; - - return { - networkClientId: rpcEndpoint.networkClientId, - url: rpcEndpoint.url, - isInfuraEndpoint, - isFailed, - domain: getDomain(rpcEndpoint.url), - }; - }, - ); - - const firstInfuraIndex = enhancedRpcEndpoints.findIndex( - (rpcEndpoint) => rpcEndpoint.isInfuraEndpoint, - ); - - enhancedNetworkConfigurationsByChainId[chainId as Hex] = { - ...networkConfiguration, - rpcEndpoints: enhancedRpcEndpoints, - infuraEndpointIndex: - firstInfuraIndex === -1 ? undefined : firstInfuraIndex, - }; - } - - return enhancedNetworkConfigurationsByChainId; - }, -); - -/** - * The list of enabled EVM networks whose default RPC endpoint is currently - * failing, plus a flag for whether every enabled network is failing. Used as - * the input to the network connection banner show/hide rule. - */ -const selectEnabledFailedNetworksResult = createSelector( - getEnabledNetworks, - selectEnhancedNetworkConfigurationsByChainId, - (enabledNetworks, enhancedNetworkConfigurationsByChainId) => { - const enabledEvmNetworks = enabledNetworks[KnownCaipNamespace.Eip155] ?? {}; - const enabledEvmChainIds = Object.entries(enabledEvmNetworks) - .filter(([, isEnabled]) => isEnabled) - .map(([chainId]) => chainId as Hex); - - const failedNetworks: { - networkClientId: string; - chainId: Hex; - networkName: string; - isInfuraEndpoint: boolean; - infuraEndpointIndex: number | undefined; - domain: string | null; - }[] = []; - let totalEnabled = 0; - - for (const chainId of enabledEvmChainIds) { - const networkConfiguration = - enhancedNetworkConfigurationsByChainId[chainId]; - if (!networkConfiguration) { - continue; - } - - const { - rpcEndpoints, - defaultRpcEndpointIndex, - name, - infuraEndpointIndex, - } = networkConfiguration; - const defaultRpcEndpoint = rpcEndpoints[defaultRpcEndpointIndex]; - if (!defaultRpcEndpoint) { - continue; - } - - totalEnabled += 1; - - if (!defaultRpcEndpoint.isFailed) { - continue; - } - - failedNetworks.push({ - networkClientId: defaultRpcEndpoint.networkClientId, - chainId, - networkName: name, - isInfuraEndpoint: defaultRpcEndpoint.isInfuraEndpoint, - // Only useful when the default is non-Infura — otherwise the CTA to - // switch to Infura is hidden anyway. - infuraEndpointIndex: defaultRpcEndpoint.isInfuraEndpoint - ? undefined - : infuraEndpointIndex, - domain: defaultRpcEndpoint.domain, - }); - } - - return { - failedNetworks, - areAllEnabledNetworksFailed: - failedNetworks.length > 0 && failedNetworks.length === totalEnabled, - }; - }, -); - -/** - * Returns the first failed EVM network that should drive the network connection - * banner, or null when no banner should be shown. - * - * A network is "failed" here when its default RPC endpoint's status is anything - * other than `NetworkStatus.Available`. - * - * The banner always shows for custom networks because users always have the - * option to switch to a built-in network, and we surface that custom network - * first so the "Switch to MetaMask default RPC" CTA points at it. For all - * other networks the banner is intentionally noisy-averse: a single provider's - * wide outage (e.g. an Infura-wide hiccup that takes down many *.infura.io - * networks at once) is suppressed because it looks like many failed networks - * but is really one provider. The banner shows only when failed RPCs span - * 2+ distinct domains (likely client-side), or every enabled EVM network has - * failed (covers single-network setups), or any failed network's active RPC - * is a non-Infura (custom) endpoint — these have no automatic failover so the - * user must be told. - */ -export const selectFirstFailedNetworkForNetworkConnectionBanner = - createSelector( - selectEnabledFailedNetworksResult, - ({ failedNetworks, areAllEnabledNetworksFailed }) => { - const firstCustomFailed = failedNetworks.find((n) => !n.isInfuraEndpoint); - const distinctDomains = new Set( - failedNetworks - .map((n) => n.domain) - .filter((domain): domain is string => domain !== null), - ).size; - - // Show the banner if: - // - The first failing network is a custom network (we assume users always - // want to be informed about errors with RPC endpoints they've chosen) - // - There are failures across more than one domain (likely client-side - // issue) - // - All enabled networks are failing (likely client-side issue) - if ( - firstCustomFailed || - distinctDomains > 1 || - areAllEnabledNetworksFailed - ) { - const selected = firstCustomFailed ?? failedNetworks[0]; - - return { - networkClientId: selected.networkClientId, - chainId: selected.chainId, - networkName: selected.networkName, - isInfuraEndpoint: selected.isInfuraEndpoint, - infuraEndpointIndex: selected.infuraEndpointIndex, - }; - } - - return null; - }, - ); - // TODO: Remove after updating to @metamask/network-controller 20.0.0 type ProviderConfigWithImageUrlAndExplorerUrl = { rpcUrl?: string; diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 99b4a577f08b..c8e21a8e836f 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -3913,11 +3913,51 @@ export const selectNonZeroUnusedApprovalsAllowList = createSelector( ); /** + * Returns the banner state in the legacy shape consumed by the UI, derived + * from NetworkConnectionBannerController. The controller stores the failing + * network's Infura switch target as `infuraNetworkClientId`; we look up the + * matching index in `networkConfigurationsByChainId` so the existing + * "Switch to MetaMask default RPC" call site (which uses + * `infuraEndpointIndex`) needs no changes. + * * @param {MetaMaskReduxState} state - The Redux state * @returns {import('../../shared/constants/app-state').NetworkConnectionBanner} */ export function getNetworkConnectionBanner(state) { - return state.metamask.networkConnectionBanner; + const { status, network } = state.metamask; + if (!status || status === 'available') { + return { status: status || 'available' }; + } + if (status !== 'degraded' && status !== 'unavailable') { + return { status: 'available' }; + } + if (!network) { + return { status: 'available' }; + } + + let infuraEndpointIndex; + if (network.infuraNetworkClientId) { + const config = + state.metamask.networkConfigurationsByChainId?.[network.chainId]; + if (config) { + const idx = config.rpcEndpoints.findIndex( + (endpoint) => + endpoint.networkClientId === network.infuraNetworkClientId, + ); + if (idx !== -1) { + infuraEndpointIndex = idx; + } + } + } + + return { + status, + networkName: network.networkName, + networkClientId: network.networkClientId, + chainId: network.chainId, + isInfuraEndpoint: network.isInfuraEndpoint, + infuraEndpointIndex, + }; } /** diff --git a/ui/store/actions.ts b/ui/store/actions.ts index e4e1c54174cd..f6773f6f9d03 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -196,10 +196,7 @@ import { SortCriteria } from '../components/app/assets/util/sort'; import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notifications'; import { getDismissSmartAccountSuggestionEnabled } from '../pages/confirmations/selectors/preferences'; import { stripWalletTypePrefixFromWalletId } from '../hooks/multichain-accounts/utils'; -import { - ClaimSubmitToastType, - type NetworkConnectionBanner, -} from '../../shared/constants/app-state'; +import { ClaimSubmitToastType } from '../../shared/constants/app-state'; import { SeasonDtoState, SeasonStatusState, @@ -6817,16 +6814,6 @@ export function fetchSmartTransactionsLiveness({ } }; } -export function updateNetworkConnectionBanner( - networkConnectionBanner: NetworkConnectionBanner, -): ThunkAction { - return async () => { - await submitRequestToBackground('updateNetworkConnectionBanner', [ - networkConnectionBanner, - ]); - }; -} - /** * Sends the background state the networkClientId and domain upon network switch * From c1903dcdeac66cccf84d1d21640c2cdb1be7ac81 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 9 Jun 2026 12:09:12 +0200 Subject: [PATCH 3/4] chore: drop psl direct dep and stale getDomain helper The banner-rule logic that needed eTLD+1 grouping now lives in the NetworkConnectionBannerController package; nothing in the extension imports `getDomain` anymore. --- package.json | 1 - shared/lib/url-utils.test.ts | 42 +---------------------------- shared/lib/url-utils.ts | 29 -------------------- types/psl.d.ts | 17 ------------ ui/selectors/multichain/networks.ts | 2 -- yarn.lock | 1 - 6 files changed, 1 insertion(+), 91 deletions(-) delete mode 100644 types/psl.d.ts diff --git a/package.json b/package.json index 80f2f157eb73..cd1d00e4c1b4 100644 --- a/package.json +++ b/package.json @@ -505,7 +505,6 @@ "nanoid": "^3.3.8", "pify": "^5.0.0", "prop-types": "^15.6.1", - "psl": "^1.15.0", "punycode": "^2.1.1", "qrcode-generator": "^2.0.4", "qrcode.react": "^4.2.0", diff --git a/shared/lib/url-utils.test.ts b/shared/lib/url-utils.test.ts index 8ce9b790bd14..0833d0587452 100644 --- a/shared/lib/url-utils.test.ts +++ b/shared/lib/url-utils.test.ts @@ -1,4 +1,4 @@ -import { getDomain, isLocalhostOrIPAddress } from './url-utils'; +import { isLocalhostOrIPAddress } from './url-utils'; describe('isLocalhostOrIPAddress', () => { it('returns true for "localhost" (any case)', () => { @@ -23,43 +23,3 @@ describe('isLocalhostOrIPAddress', () => { expect(isLocalhostOrIPAddress('my-custom-rpc')).toBe(false); }); }); - -describe('getDomain', () => { - it('returns the last two labels for a multi-label hostname', () => { - expect(getDomain('https://mainnet.infura.io/v3/abc')).toBe('infura.io'); - }); - - it('groups subdomain-heavy hostnames under the same registrable domain', () => { - expect(getDomain('https://linea-mainnet.infura.io/v3/abc')).toBe( - 'infura.io', - ); - expect(getDomain('https://polygon-mainnet.g.alchemy.com/v2/abc')).toBe( - 'alchemy.com', - ); - }); - - it('returns the hostname as-is when it has exactly two labels', () => { - expect(getDomain('https://alchemy.com/')).toBe('alchemy.com'); - }); - - it('handles multi-part public suffixes like .co.uk', () => { - expect(getDomain('https://api.example.co.uk/v1')).toBe('example.co.uk'); - expect(getDomain('https://example.co.uk/')).toBe('example.co.uk'); - }); - - it('returns single-label hosts (e.g., localhost) verbatim', () => { - expect(getDomain('http://localhost:8545')).toBe('localhost'); - }); - - it('returns IPv4 addresses verbatim', () => { - expect(getDomain('http://127.0.0.1:8545')).toBe('127.0.0.1'); - }); - - it('returns IPv6 addresses verbatim, including brackets', () => { - expect(getDomain('http://[::1]:8545')).toBe('[::1]'); - }); - - it('returns null for an invalid URL', () => { - expect(getDomain('not a url')).toBeNull(); - }); -}); diff --git a/shared/lib/url-utils.ts b/shared/lib/url-utils.ts index 545a7910af16..9d8ad013bb74 100644 --- a/shared/lib/url-utils.ts +++ b/shared/lib/url-utils.ts @@ -1,6 +1,5 @@ import urlLib from 'url'; import ipRegex from 'ip-regex'; -import psl from 'psl'; export function addUrlProtocolPrefix(urlString: string) { let trimmed = urlString.trim(); @@ -80,31 +79,3 @@ export function isLocalhostOrIPAddress(hostname: string): boolean { return ipRegex({ exact: true }).test(hostnameWithoutBrackets); } - -/** - * Registrable domain (eTLD+1) for a URL, computed via the Public Suffix List - * so multi-part suffixes like ".co.uk" resolve correctly. Used to group RPC - * endpoints by provider so a single provider's wide outage (e.g. *.infura.io) - * is treated as one failure rather than many. - * - * Localhost, IP literals, and single-label hosts are returned verbatim rather - * than reduced to a domain (psl returns null or garbage for those, and callers - * grouping by domain still need to distinguish them). - * - * @param urlString - The URL to extract a domain from. - * @returns The domain, or null if the URL is invalid. - */ -export function getDomain(urlString: string): string | null { - const url = getValidUrl(urlString); - if (url === null) { - return null; - } - - const { hostname } = url; - - if (!hostname.includes('.') || isLocalhostOrIPAddress(hostname)) { - return hostname; - } - - return psl.get(hostname) ?? hostname; -} diff --git a/types/psl.d.ts b/types/psl.d.ts deleted file mode 100644 index 74eede54f35d..000000000000 --- a/types/psl.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// psl ships its own types at psl/types/index.d.ts, but its package.json -// `exports` map omits a `types` condition so TypeScript's moduleResolution:node16 -// can't find them. Mirror the small surface we use. -declare module 'psl' { - export function get(domain: string | null): string | null; - export function isValid(domain: string): boolean; - export function parse(domain: string): - | { - tld: string | null; - sld: string | null; - domain: string | null; - subdomain: string | null; - listed: boolean; - input: string; - } - | { input: string; error: { message: string; code: string } }; -} diff --git a/ui/selectors/multichain/networks.ts b/ui/selectors/multichain/networks.ts index fda671d752dc..4c7a46df086e 100644 --- a/ui/selectors/multichain/networks.ts +++ b/ui/selectors/multichain/networks.ts @@ -44,8 +44,6 @@ import { } from '../../../shared/lib/selectors/networks'; import { createDeepEqualSelector } from '../../../shared/lib/selectors/selector-creators'; import { getEnabledNetworks } from '../../../shared/lib/selectors/multichain'; -import { getIsMetaMaskInfuraEndpointUrl } from '../../../shared/lib/network-utils'; -import { getDomain } from '../../../shared/lib/url-utils'; import type { RemoteFeatureFlagsState } from '../../../shared/lib/selectors/remote-feature-flags'; import { type AccountsState, diff --git a/yarn.lock b/yarn.lock index b80008be81fe..fc3e9b5431cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -33852,7 +33852,6 @@ __metadata: prettier-plugin-sort-json: "npm:^4.1.1" process: "npm:^0.11.10" prop-types: "npm:^15.6.1" - psl: "npm:^1.15.0" pumpify: "npm:^2.0.1" punycode: "npm:^2.1.1" qrcode-generator: "npm:^2.0.4" From 26d319270f8261d64a4d3009ae802815fce99f74 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 9 Jun 2026 17:26:13 +0200 Subject: [PATCH 4/4] test: drop stale networkConnectionBanner key from app-state snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field was removed from the controller in the previous refactor — update the inline snapshot to match. --- app/scripts/controllers/app-state-controller.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index a8924535331b..6a622f48418c 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -1080,9 +1080,6 @@ describe('AppStateController', () => { "lastVisitedRoute": null, "musdConversionDismissedCtaKeys": [], "musdConversionEducationSeen": false, - "networkConnectionBanner": { - "status": "unknown", - }, "newPrivacyPolicyToastClickedOrClosed": null, "newPrivacyPolicyToastShownDate": null, "nftsDropdownState": {},