From 4d59b80c74de12786ac7a56d32043285577a6d71 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 20:05:11 +0200 Subject: [PATCH 1/7] feat: register NetworkConnectionBannerController in Engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new core package into mobile via the standard messenger-client init pattern. The controller is alongside the existing useNetworkConnectionBanner hook — clients can now subscribe to its state-change events, and a follow-up PR will migrate the hook to read from the controller's state instead of the local Redux reducer. Mobile's installed controller versions are older than the preview package's resolved peer ranges (network-controller@31 vs ^32, connectivity-controller@0.1 vs ^0.2). Runtime shapes are compatible — all event names and state field names align — so the type-level mismatch at the messenger boundary is bridged with a single cast in the messenger factory. The cast can be dropped once mobile bumps its controller versions to match. --- app/core/Engine/Engine.ts | 9 ++++++ ...twork-connection-banner-controller-init.ts | 31 +++++++++++++++++++ app/core/Engine/messengers/index.ts | 5 +++ ...-connection-banner-controller-messenger.ts | 19 ++++++++++++ app/core/Engine/types.ts | 7 +++++ package.json | 2 ++ yarn.lock | 19 +++++++++++- 7 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/core/Engine/controllers/network-connection-banner-controller/network-connection-banner-controller-init.ts create mode 100644 app/core/Engine/messengers/network-connection-banner-controller-messenger.ts diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 6fac2725d91..87eb62f61e5 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -183,6 +183,7 @@ import { phishingControllerInit } from './controllers/phishing-controller-init'; import { addressBookControllerInit } from './controllers/address-book-controller-init'; import { analyticsControllerInit } from './controllers/analytics-controller/analytics-controller-init'; import { connectivityControllerInit } from './controllers/connectivity/connectivity-controller-init'; +import { networkConnectionBannerControllerInit } from './controllers/network-connection-banner-controller/network-connection-banner-controller-init'; import { multichainRoutingServiceInit } from './controllers/multichain-routing-service-init.ts'; import { profileMetricsControllerInit } from './controllers/profile-metrics-controller-init'; import { profileMetricsServiceInit } from './controllers/profile-metrics-service-init'; @@ -398,6 +399,8 @@ export class Engine { RewardsDataService: rewardsDataServiceInit, DelegationController: DelegationControllerInit, ConnectivityController: connectivityControllerInit, + NetworkConnectionBannerController: + networkConnectionBannerControllerInit, ProfileMetricsController: profileMetricsControllerInit, ProfileMetricsService: profileMetricsServiceInit, AnalyticsController: analyticsControllerInit, @@ -451,6 +454,8 @@ export class Engine { const addressBookController = messengerClientsByName.AddressBookController; const connectivityController = messengerClientsByName.ConnectivityController; + const networkConnectionBannerController = + messengerClientsByName.NetworkConnectionBannerController; const profileMetricsController = messengerClientsByName.ProfileMetricsController; const profileMetricsService = messengerClientsByName.ProfileMetricsService; @@ -567,6 +572,7 @@ export class Engine { AddressBookController: addressBookController, AppMetadataController: messengerClientsByName.AppMetadataController, ConnectivityController: connectivityController, + NetworkConnectionBannerController: networkConnectionBannerController, AssetsContractController: assetsContractController, AssetsController: messengerClientsByName.AssetsController, NftController: nftController, @@ -1428,6 +1434,7 @@ export default { BridgeStatusController, CardController, ConnectivityController, + NetworkConnectionBannerController, CurrencyRateController, DeFiPositionsController, DelegationController, @@ -1500,6 +1507,8 @@ export default { BridgeController: BridgeController.state, BridgeStatusController: BridgeStatusController.state, ConnectivityController: ConnectivityController.state, + NetworkConnectionBannerController: + NetworkConnectionBannerController.state, CurrencyRateController: CurrencyRateController.state, DeFiPositionsController: DeFiPositionsController.state, DelegationController: DelegationController.state, diff --git a/app/core/Engine/controllers/network-connection-banner-controller/network-connection-banner-controller-init.ts b/app/core/Engine/controllers/network-connection-banner-controller/network-connection-banner-controller-init.ts new file mode 100644 index 00000000000..032d46ee7ab --- /dev/null +++ b/app/core/Engine/controllers/network-connection-banner-controller/network-connection-banner-controller-init.ts @@ -0,0 +1,31 @@ +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 +> = ({ controllerMessenger }) => { + const controller = new NetworkConnectionBannerController({ + messenger: controllerMessenger, + }); + + return { + controller, + }; +}; diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 74f8882a132..f2ed77c063e 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -128,6 +128,7 @@ import { getTransakServiceMessenger } from './transak-service-messenger/transak- import { getPhishingControllerMessenger } from './phishing-controller-messenger'; import { getAddressBookControllerMessenger } from './address-book-controller-messenger'; import { getConnectivityControllerMessenger } from './connectivity-controller-messenger'; +import { getNetworkConnectionBannerControllerMessenger } from './network-connection-banner-controller-messenger'; import { getMultichainRoutingServiceInitMessenger, getMultichainRoutingServiceMessenger, @@ -186,6 +187,10 @@ export const MESSENGER_FACTORIES = { getMessenger: getConnectivityControllerMessenger, getInitMessenger: noop, }, + NetworkConnectionBannerController: { + getMessenger: getNetworkConnectionBannerControllerMessenger, + getInitMessenger: noop, + }, ApprovalController: { getMessenger: getApprovalControllerMessenger, getInitMessenger: noop, diff --git a/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts b/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts new file mode 100644 index 00000000000..35bcd59e136 --- /dev/null +++ b/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts @@ -0,0 +1,19 @@ +import { Messenger } from '@metamask/messenger'; +import type { NetworkConnectionBannerControllerMessenger } from '@metamask/network-connection-banner-controller'; +import { RootMessenger } from '../types'; + +/** + * Get the NetworkConnectionBannerControllerMessenger for the + * NetworkConnectionBannerController. + * + * @param rootMessenger - The root messenger. + * @returns The NetworkConnectionBannerControllerMessenger. + */ +export function getNetworkConnectionBannerControllerMessenger( + rootMessenger: RootMessenger, +): NetworkConnectionBannerControllerMessenger { + return new Messenger({ + namespace: 'NetworkConnectionBannerController', + parent: rootMessenger, + }); +} diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 93ea56f0814..3d1213c604b 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -87,6 +87,10 @@ import { AddressBookControllerEvents, AddressBookControllerState, } from '@metamask/address-book-controller'; +import type { + NetworkConnectionBannerController, + NetworkConnectionBannerControllerState, +} from '@metamask/network-connection-banner-controller'; import { ConnectivityController, ConnectivityControllerActions, @@ -745,6 +749,7 @@ export type MessengerClients = { AddressBookController: AddressBookController; AppMetadataController: AppMetadataController; ConnectivityController: ConnectivityController; + NetworkConnectionBannerController: NetworkConnectionBannerController; ApprovalController: ApprovalController; AssetsContractController: AssetsContractController; AssetsController: AssetsController; @@ -850,6 +855,7 @@ export type EngineState = { AssetsController: AssetsControllerState; AppMetadataController: AppMetadataControllerState; ConnectivityController: ConnectivityControllerState; + NetworkConnectionBannerController: NetworkConnectionBannerControllerState; NftController: NftControllerState; CurrencyRateController: CurrencyRateState; KeyringController: KeyringControllerState; @@ -951,6 +957,7 @@ export type MessengerClientsToInitialize = | 'AssetsContractController' | 'AssetsController' | 'ConnectivityController' + | 'NetworkConnectionBannerController' ///: BEGIN:ONLY_INCLUDE_IF(snaps) | 'AuthenticationController' | 'CronjobController' diff --git a/package.json b/package.json index 90931a91f71..7026b62d184 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ ] }, "resolutions": { + "@metamask/network-connection-banner-controller": "npm:@metamask-previews/network-connection-banner-controller@0.1.0-preview-7507a11", "@metamask/network-controller": "32.0.0", "@react-native-community/viewpager": "patch:@react-native-community/viewpager@npm%3A3.3.0#~/.yarn/patches/@react-native-community-viewpager-npm-3.3.0.patch", "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.5", @@ -302,6 +303,7 @@ "@metamask/multichain-network-controller": "^3.1.0", "@metamask/multichain-transactions-controller": "^7.1.0", "@metamask/native-utils": "^0.8.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": "24.1.1", diff --git a/yarn.lock b/yarn.lock index da7f08b4839..dd54840ead0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9296,6 +9296,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" @@ -31969,7 +31985,7 @@ __metadata: languageName: node linkType: hard -"ip-regex@npm:^4.1.0": +"ip-regex@npm:^4.1.0, ip-regex@npm:^4.3.0": version: 4.3.0 resolution: "ip-regex@npm:4.3.0" checksum: 10/7ff904b891221b1847f3fdf3dbb3e6a8660dc39bc283f79eb7ed88f5338e1a3d1104b779bc83759159be266249c59c2160e779ee39446d79d4ed0890dfd06f08 @@ -35275,6 +35291,7 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.1.0" "@metamask/multichain-transactions-controller": "npm:^7.1.0" "@metamask/native-utils": "npm:^0.8.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:24.1.1" From 4ac3c93e471b38ddf52446450d826d53cba84ad7 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 20:28:53 +0200 Subject: [PATCH 2/7] refactor: drop in-app banner rule and consume NetworkConnectionBannerController The new controller registered in the previous commit owns the show/hide rule, 5s/30s timer escalation, and the switch-to-Infura action. With it in place, the in-app duplicates are dead code: - delete app/reducers/networkConnectionBanner and app/actions/networkConnectionBanner - selector now reads from engine.backgroundState.NetworkConnectionBannerController and adapts to the existing UI shape (visible: boolean + flat fields) - useNetworkConnectionBanner hook drops the inline checkNetworkStatus function, the two timers, the rpcEndpointChainAvailable subscription, and the dispatch calls; switchToInfura now calls the controller via NetworkConnectionBannerController:switchToDefaultInfuraRpc - UI component and analytics events are unchanged --- .../networkConnectionBanner/index.test.ts | 135 -- app/actions/networkConnectionBanner/index.ts | 84 - .../NetworkConnectionBanner.tsx | 2 +- .../UI/NetworkConnectionBanner/types.ts | 26 + app/components/Views/Wallet/index.test.tsx | 2 - .../useNetworkConnectionBanner.test.tsx | 1739 ++--------------- .../useNetworkConnectionBanner.ts | 350 +--- app/reducers/index.ts | 6 - .../networkConnectionBanner/index.test.ts | 221 --- app/reducers/networkConnectionBanner/index.ts | 68 - .../networkConnectionBanner/index.test.ts | 117 -- .../networkConnectionBanner/index.ts | 46 +- app/util/test/initial-root-state.ts | 2 - 13 files changed, 244 insertions(+), 2554 deletions(-) delete mode 100644 app/actions/networkConnectionBanner/index.test.ts delete mode 100644 app/actions/networkConnectionBanner/index.ts delete mode 100644 app/reducers/networkConnectionBanner/index.test.ts delete mode 100644 app/reducers/networkConnectionBanner/index.ts delete mode 100644 app/selectors/networkConnectionBanner/index.test.ts diff --git a/app/actions/networkConnectionBanner/index.test.ts b/app/actions/networkConnectionBanner/index.test.ts deleted file mode 100644 index 9cda29f9359..00000000000 --- a/app/actions/networkConnectionBanner/index.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - showNetworkConnectionBanner, - hideNetworkConnectionBanner, - NetworkConnectionBannerActionType, -} from '.'; -import { NetworkConnectionBannerStatus } from '../../components/UI/NetworkConnectionBanner/types'; - -describe('networkConnectionBanner actions', () => { - describe('NetworkConnectionBannerActionType', () => { - it('should have correct action type values', () => { - expect( - NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER, - ).toBe('SHOW_NETWORK_CONNECTION_BANNER'); - expect( - NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER, - ).toBe('HIDE_NETWORK_CONNECTION_BANNER'); - }); - }); - - describe('showNetworkConnectionBanner', () => { - it.each([ - { - chainId: '0x1', - status: 'degraded' as const, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - isInfuraEndpoint: true, - }, - { - chainId: '0x89', - status: 'unavailable' as const, - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }, - ] as const)( - 'should create an action to show the network connection banner with valid chainId, status, networkName and rpcUrl for $status status', - ({ chainId, status, networkName, rpcUrl, isInfuraEndpoint }) => { - expect( - showNetworkConnectionBanner({ - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - }), - ).toStrictEqual({ - type: NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER, - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - infuraNetworkClientId: undefined, - }); - }, - ); - - it('should require chainId, status, networkName, rpcUrl, and isInfuraEndpoint parameters', () => { - const chainId = '0x1'; - const status: NetworkConnectionBannerStatus = 'degraded'; - const networkName = 'Ethereum Mainnet'; - const rpcUrl = 'https://mainnet.infura.io/v3/123'; - const isInfuraEndpoint = true; - - const action = showNetworkConnectionBanner({ - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - }); - - expect(action.chainId).toBe(chainId); - expect(action.status).toBe(status); - expect(action.networkName).toBe(networkName); - expect(action.rpcUrl).toBe(rpcUrl); - expect(Object.keys(action)).toEqual([ - 'type', - 'chainId', - 'status', - 'networkName', - 'rpcUrl', - 'isInfuraEndpoint', - 'infuraNetworkClientId', - ]); - }); - - it('includes infuraNetworkClientId when provided', () => { - const chainId = '0x89'; - const status: NetworkConnectionBannerStatus = 'degraded'; - const networkName = 'Polygon Mainnet'; - const rpcUrl = 'https://polygon-rpc.com'; - const isInfuraEndpoint = false; - const infuraNetworkClientId = 'polygon-mainnet'; - - const action = showNetworkConnectionBanner({ - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - infuraNetworkClientId, - }); - - expect(action.infuraNetworkClientId).toBe(infuraNetworkClientId); - expect(action).toStrictEqual({ - type: NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER, - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - infuraNetworkClientId, - }); - }); - }); - - describe('hideNetworkConnectionBanner', () => { - it('should create an action to hide the network connection banner', () => { - expect(hideNetworkConnectionBanner()).toStrictEqual({ - type: NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER, - }); - }); - - it('should not include chainId property in hide action', () => { - const action = hideNetworkConnectionBanner(); - - expect(action).toStrictEqual({ - type: NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER, - }); - expect(Object.keys(action)).toStrictEqual(['type']); - }); - }); -}); diff --git a/app/actions/networkConnectionBanner/index.ts b/app/actions/networkConnectionBanner/index.ts deleted file mode 100644 index 7f721ca6607..00000000000 --- a/app/actions/networkConnectionBanner/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Hex } from '@metamask/utils'; -import { Action } from 'redux'; -import { NetworkConnectionBannerStatus } from '../../components/UI/NetworkConnectionBanner/types'; -/** - * Different action types available for different RPC event flow - */ -export enum NetworkConnectionBannerActionType { - SHOW_NETWORK_CONNECTION_BANNER = 'SHOW_NETWORK_CONNECTION_BANNER', - HIDE_NETWORK_CONNECTION_BANNER = 'HIDE_NETWORK_CONNECTION_BANNER', -} - -/** - * Action to show the network connection banner - * chainId is required to identify which network is having the issue - * status is required to identify the status of the network connection banner - */ -export interface ShowNetworkConnectionBannerAction extends Action { - type: NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER; - chainId: Hex; - status: NetworkConnectionBannerStatus; - networkName: string; - rpcUrl: string; - isInfuraEndpoint: boolean; - /** - * Network client ID of an available Infura endpoint (for custom networks that have one) - * that can be used to switch to Infura. Undefined if no Infura endpoint is available. - */ - infuraNetworkClientId?: string; -} - -/** - * Action to hide the network connection banner - * No parameters needed - just hides the currently visible banner - */ -export interface HideNetworkConnectionBannerAction extends Action { - type: NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER; -} - -export type NetworkConnectionBannerAction = - | ShowNetworkConnectionBannerAction - | HideNetworkConnectionBannerAction; - -/** - * showNetworkConnectionBanner action creator - * @param {Hex} chainId: the chain id of the network that is having the issue - * @param {NetworkConnectionBannerStatus} status: the status of the network connection banner - * @param {string} [infuraNetworkClientId]: optional network client ID of an Infura endpoint that can be switched to - * @returns {ShowNetworkConnectionBannerAction} - the action object to show the network connection banner - */ -export function showNetworkConnectionBanner({ - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - infuraNetworkClientId, -}: { - chainId: Hex; - status: NetworkConnectionBannerStatus; - networkName: string; - rpcUrl: string; - isInfuraEndpoint: boolean; - infuraNetworkClientId?: string; -}): ShowNetworkConnectionBannerAction { - return { - type: NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER, - chainId, - status, - networkName, - rpcUrl, - isInfuraEndpoint, - infuraNetworkClientId, - }; -} - -/** - * hideNetworkConnectionBanner action creator - * @returns {HideNetworkConnectionBannerAction} - the action object to hide the network connection banner - */ -export function hideNetworkConnectionBanner(): HideNetworkConnectionBannerAction { - return { - type: NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER, - }; -} diff --git a/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx b/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx index e3319248b59..f31d270173d 100644 --- a/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx +++ b/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx @@ -3,7 +3,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useAppTheme } from '../../../util/theme'; import { useNetworkConnectionBanner } from '../../hooks/useNetworkConnectionBanner'; import { strings } from '../../../../locales/i18n'; -import { NetworkConnectionBannerState } from '../../../reducers/networkConnectionBanner'; +import type { NetworkConnectionBannerState } from './types'; import BannerBase from '../../../component-library/components/Banners/Banner/foundation/BannerBase'; import { Theme } from '../../../util/theme/models'; import Animated, { diff --git a/app/components/UI/NetworkConnectionBanner/types.ts b/app/components/UI/NetworkConnectionBanner/types.ts index dedd9e6cfe2..36951b8eb7c 100644 --- a/app/components/UI/NetworkConnectionBanner/types.ts +++ b/app/components/UI/NetworkConnectionBanner/types.ts @@ -1,3 +1,5 @@ +import type { Hex } from '@metamask/utils'; + /** * Network Connection Banner Status * @@ -5,3 +7,27 @@ * Unavailable: The network is not available. */ export type NetworkConnectionBannerStatus = 'degraded' | 'unavailable'; + +/** + * Shape consumed by the banner UI. Derived from + * `NetworkConnectionBannerController` state via the selector — the + * `visible: false` case maps to the controller's `'available'` status. + */ +export type NetworkConnectionBannerState = + | { + visible: false; + } + | { + visible: true; + chainId: Hex; + status: NetworkConnectionBannerStatus; + networkName: string; + rpcUrl: string; + isInfuraEndpoint: boolean; + /** + * Network client ID of an available Infura endpoint (for custom networks + * that have one) that can be used to switch to Infura. Undefined if no + * Infura endpoint is available. + */ + infuraNetworkClientId?: string; + }; diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index aad6fa43369..aa60540af70 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -230,7 +230,6 @@ import Engine from '../../../core/Engine'; import { useSelector } from 'react-redux'; import { mockedPerpsFeatureFlagsEnabledState } from '../../UI/Perps/mocks/remoteFeatureFlagMocks'; import { initialState as cardInitialState } from '../../../core/redux/slices/card'; -import { initialState as networkConnectionBannerInitialState } from '../../../reducers/networkConnectionBanner'; import { NavigationProp, ParamListBase, @@ -477,7 +476,6 @@ const mockInitialState = { newPrivacyPolicyToastShownDate: null, newPrivacyPolicyToastClickedOrClosed: false, }, - networkConnectionBanner: networkConnectionBannerInitialState, engine: { backgroundState: { ...backgroundState, diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx index 8890ca959ad..5f68b056a3b 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx @@ -1,22 +1,14 @@ import React from 'react'; -import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { renderHook, act } from '@testing-library/react-native'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; -import { Hex } from '@metamask/utils'; -import { - NetworkConfiguration, - NetworkStatus, - RpcEndpointType, -} from '@metamask/network-controller'; import useNetworkConnectionBanner from './useNetworkConnectionBanner'; import Engine from '../../../core/Engine'; import { useAnalytics } from '../useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { selectNetworkConnectionBannerState } from '../../../selectors/networkConnectionBanner'; -import { selectIsDeviceOffline } from '../../../selectors/connectivityController'; -import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController'; import Routes from '../../../constants/navigation/Routes'; import { isPublicEndpointUrl } from '../../../core/Engine/controllers/network-controller/utils'; import { @@ -25,24 +17,10 @@ import { } from '../../../component-library/components/Toast'; import { IconName } from '../../../component-library/components/Icons/Icon'; -// Mock dependencies jest.mock('@react-navigation/native'); jest.mock('../../../core/Engine'); -jest.mock('../../../selectors/networkEnablementController'); jest.mock('../useAnalytics/useAnalytics'); jest.mock('../../../selectors/networkConnectionBanner'); -jest.mock('../../../selectors/connectivityController'); -jest.mock('react-redux', () => { - const actual = jest.requireActual('react-redux'); - return { - ...actual, - useSelector: jest.fn( - // Call the selector function directly to get the mocked return value - // This ensures selectors are called on every render, not just when store changes - (selector) => selector({} as unknown), - ), - }; -}); jest.mock('../../../core/Engine/controllers/network-controller/utils', () => ({ ...jest.requireActual( '../../../core/Engine/controllers/network-controller/utils', @@ -55,494 +33,167 @@ jest.mock('../../../constants/network', () => ({ })); const mockStore = configureMockStore(); -const mockNavigation = { - navigate: jest.fn(), -}; - +const mockNavigate = jest.fn(); const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); const mockToastRef = { - current: { - showToast: mockShowToast, - closeToast: mockCloseToast, - }, -}; - -const mockNetworkConfiguration: NetworkConfiguration = { - chainId: '0x1', - name: 'Ethereum Mainnet', - rpcEndpoints: [ - { - url: 'https://mainnet.infura.io/v3/test-infura-project-id', - networkClientId: '0x1', - type: RpcEndpointType.Custom, - }, - { - url: 'https://eth-mainnet.alchemyapi.io/v2/test', - networkClientId: '0x1', - type: RpcEndpointType.Custom, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://etherscan.io'], - nativeCurrency: 'ETH', -}; - -// Network configuration with both custom and Infura endpoints -const mockNetworkConfigurationWithInfura: NetworkConfiguration = { - chainId: '0x89', - name: 'Polygon Mainnet', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - networkClientId: '0x89-custom', - type: RpcEndpointType.Custom, - }, - { - url: 'https://polygon-mainnet.infura.io/v3/{infuraProjectId}', - networkClientId: 'polygon-mainnet', - type: RpcEndpointType.Infura, - name: 'Polygon Mainnet', - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://polygonscan.com'], - nativeCurrency: 'MATIC', -}; - -const mockNetworkConfigurationByChainId: Record = { - '0x1': mockNetworkConfiguration, - '0x89': { - ...mockNetworkConfiguration, - chainId: '0x89', - name: 'Polygon Mainnet', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - networkClientId: '0x89', - type: RpcEndpointType.Custom, - }, - ], - }, + current: { showToast: mockShowToast, closeToast: jest.fn() }, }; -const NETWORK_CLIENT_ID_1 = 'network-client-1'; -const NETWORK_CLIENT_ID_89 = 'network-client-89'; - -const mockNetworkMetadata = { - [NETWORK_CLIENT_ID_1]: { status: NetworkStatus.Available }, - [NETWORK_CLIENT_ID_89]: { status: NetworkStatus.Unavailable }, -}; - -const mockNetworkController = { - state: { - networksMetadata: mockNetworkMetadata, - }, - findNetworkClientIdByChainId: jest.fn((chainId: Hex) => { - const clientIdMap: Record = { - '0x1': NETWORK_CLIENT_ID_1, - '0x89': NETWORK_CLIENT_ID_89, - }; - return clientIdMap[chainId]; - }), - getNetworkConfigurationByNetworkClientId: jest.fn( - (networkClientId: string) => { - const configMap: Record = { - [NETWORK_CLIENT_ID_1]: mockNetworkConfigurationByChainId['0x1'], - [NETWORK_CLIENT_ID_89]: mockNetworkConfigurationByChainId['0x89'], - }; - return configMap[networkClientId]; - }, - ), - getNetworkConfigurationByChainId: jest.fn< - NetworkConfiguration | undefined, - [Hex] - >((chainId: Hex) => mockNetworkConfigurationByChainId[chainId]), - updateNetwork: jest.fn().mockResolvedValue(undefined), -}; +const switchToDefaultInfuraRpcMock = jest.fn(async () => undefined); -const mockEngine = { - lookupEnabledNetworks: jest.fn(), - controllerMessenger: { - subscribe: jest.fn(), - unsubscribe: jest.fn(), - }, - context: { - NetworkController: mockNetworkController, - }, -}; +const selectorMock = jest.mocked(selectNetworkConnectionBannerState); describe('useNetworkConnectionBanner', () => { - let store: ReturnType; - let stableTrackEvent: jest.Mock; - let stableCreateEventBuilder: jest.Mock; - let mockAddProperties: jest.Mock; + let mockTrackEvent: jest.Mock; let mockBuild: jest.Mock; + let mockAddProperties: jest.Mock; + let mockCreateEventBuilder: jest.Mock; - const setupMocks = () => { + beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); - - // Setup mocks - (useNavigation as jest.Mock).mockReturnValue(mockNavigation); - jest.mocked(selectEVMEnabledNetworks).mockReturnValue(['0x1', '0x89']); - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - // Default to online - jest.mocked(selectIsDeviceOffline).mockReturnValue(false); - // Mock Engine methods directly (safer than spyOn after jest.mock) - Engine.lookupEnabledNetworks = mockEngine.lookupEnabledNetworks; - // @ts-expect-error - Mocking Engine for testing - Engine.controllerMessenger = mockEngine.controllerMessenger; - // @ts-expect-error - Mocking Engine for testing - Engine.context = mockEngine.context; + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + jest.mocked(isPublicEndpointUrl).mockReturnValue(true); - // Mock the useAnalytics hook to return stable functions - stableTrackEvent = jest.fn(); - mockAddProperties = jest.fn().mockReturnThis(); + mockTrackEvent = jest.fn(); mockBuild = jest.fn(() => ({ event: 'test-event', properties: {} })); - stableCreateEventBuilder = jest.fn(() => ({ + mockAddProperties = jest.fn().mockReturnThis(); + mockCreateEventBuilder = jest.fn(() => ({ addProperties: mockAddProperties, build: mockBuild, })); - (useAnalytics as jest.Mock).mockReturnValue({ - trackEvent: stableTrackEvent, - createEventBuilder: stableCreateEventBuilder, + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }); - jest.mocked(isPublicEndpointUrl).mockReturnValue(true); - - mockShowToast.mockClear(); - - store = mockStore({ - networkConnectionBanner: { - visible: false, - chainId: undefined, + // @ts-expect-error - mocking Engine context + Engine.context = { + NetworkConnectionBannerController: { + switchToDefaultInfuraRpc: switchToDefaultInfuraRpcMock, }, - engine: { - backgroundState: { - ConnectivityController: { - connectivityStatus: 'online', - }, - }, - }, - }); - }; - - const cleanupMocks = () => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }; - - const renderHookWithProvider = () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); + }; + }); + const renderHookWithProviders = () => { + const store = mockStore({}); return renderHook(() => useNetworkConnectionBanner(), { - wrapper, - initialProps: {}, + wrapper: ({ children }) => ( + + + } + > + {children} + + + ), }); }; - beforeEach(() => { - setupMocks(); - }); - - afterEach(() => { - cleanupMocks(); - }); - - describe('initial state', () => { - it('should return initial values', () => { - const { result } = renderHookWithProvider(); - - expect(result.current.networkConnectionBannerState.visible).toBe(false); - expect( - // @ts-expect-error - chainId is not defined in the initial state - result.current.networkConnectionBannerState.chainId, - ).toBeUndefined(); - expect(typeof result.current.updateRpc).toBe('function'); - expect(typeof result.current.switchToInfura).toBe('function'); - }); - - it('should call Engine.lookupEnabledNetworks on mount', () => { - renderHookWithProvider(); - - expect(mockEngine.lookupEnabledNetworks).toHaveBeenCalledTimes(1); + it('returns the banner state from the selector', () => { + selectorMock.mockReturnValue({ visible: false }); + const { result } = renderHookWithProviders(); + expect(result.current.networkConnectionBannerState).toStrictEqual({ + visible: false, }); }); - describe('updateRpc function', () => { - it('should navigate to edit network screen with provided rpcUrl', () => { - const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; - const chainId = '0x1'; - (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ - visible: true, - chainId, - status, - }); - - const { result } = renderHookWithProvider(); - - act(() => { - result.current.updateRpc(rpcUrl, status, chainId); - }); - - expect(mockNavigation.navigate).toHaveBeenCalledWith( - Routes.EDIT_NETWORK, - { - network: rpcUrl, - shouldNetworkSwitchPopToWallet: false, - shouldShowPopularNetworks: false, - trackRpcUpdateFromBanner: true, - }, - ); + it('fires the banner-shown analytics event when the banner becomes visible', () => { + selectorMock.mockReturnValue({ + visible: true, + chainId: '0x1', + status: 'degraded', + networkName: 'Ethereum Mainnet', + rpcUrl: 'https://mainnet.infura.io/v3/test-infura-project-id', + isInfuraEndpoint: true, }); - it('should track degraded RPC update event', () => { - const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; - const chainId = '0x1'; - (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ - visible: true, - chainId, - status, - }); - - const { result } = renderHookWithProvider(); - - act(() => { - result.current.updateRpc(rpcUrl, status, chainId); - }); + renderHookWithProviders(); - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, - ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', - ); - expect(mockAddProperties).toHaveBeenCalledWith({ + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ banner_type: 'degraded', chain_id_caip: 'eip155:1', - rpc_endpoint_url: 'mainnet.infura.io', - rpc_domain: 'mainnet.infura.io', - }); - }); + }), + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); - it('should track unavailable RPC update event', () => { - const status = 'unavailable'; - const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; - const chainId = '0x1'; - (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ - visible: true, - chainId, - status, - }); + it('does not fire analytics when the banner is hidden', () => { + selectorMock.mockReturnValue({ visible: false }); + renderHookWithProviders(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); - const { result } = renderHookWithProvider(); + describe('updateRpc', () => { + it('navigates to the edit-network screen and tracks the click event', () => { + selectorMock.mockReturnValue({ visible: false }); + const { result } = renderHookWithProviders(); act(() => { - result.current.updateRpc(rpcUrl, status, chainId); - }); - - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, - ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', - ); - expect(mockAddProperties).toHaveBeenCalledWith({ - banner_type: 'unavailable', - chain_id_caip: 'eip155:1', - rpc_endpoint_url: 'mainnet.infura.io', - rpc_domain: 'mainnet.infura.io', - }); - }); - - it('should track RPC update event with custom endpoint for non-public URLs', () => { - const status = 'degraded'; - const rpcUrl = 'https://custom-rpc.example.com'; - const chainId = '0x1'; - - jest.mocked(isPublicEndpointUrl).mockReturnValue(false); - - (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ - visible: true, - chainId, - status, + result.current.updateRpc('https://polygon-rpc.com', 'degraded', '0x89'); }); - const { result } = renderHookWithProvider(); - - act(() => { - result.current.updateRpc(rpcUrl, status, chainId); + expect(mockNavigate).toHaveBeenCalledWith(Routes.EDIT_NETWORK, { + network: 'https://polygon-rpc.com', + shouldNetworkSwitchPopToWallet: false, + shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }); - - expect(stableCreateEventBuilder).toHaveBeenCalledWith( + expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + banner_type: 'degraded', + chain_id_caip: 'eip155:137', + }), ); - expect(mockAddProperties).toHaveBeenCalledWith({ - banner_type: 'degraded', - chain_id_caip: 'eip155:1', - rpc_endpoint_url: 'custom', - rpc_domain: 'custom', - }); }); - it('should use mocked Infura project ID from constants', () => { - const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; - const chainId = '0x1'; - - (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ - visible: true, - chainId, - status, - }); - - const { result } = renderHookWithProvider(); + it('records the RPC as "custom" when the URL is not public', () => { + jest.mocked(isPublicEndpointUrl).mockReturnValue(false); + selectorMock.mockReturnValue({ visible: false }); + const { result } = renderHookWithProviders(); act(() => { - result.current.updateRpc(rpcUrl, status, chainId); + result.current.updateRpc( + 'https://my-private-rpc.example', + 'unavailable', + '0x1', + ); }); - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, - ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + rpc_domain: 'custom', + rpc_endpoint_url: 'custom', + }), ); }); }); - describe('useEffect', () => { - it('should show network connection banner after timeout', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); - - it('does not show the banner when only one Infura-backed network is failing while others are available', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - // 0x1 (Mainnet, Infura) is failing; 0x89 (custom) is fine. Single - // registrable domain, no custom in the failed set, not all enabled - // networks are failing -> suppress. - const singleInfuraDownMetadata = { - [NETWORK_CLIENT_ID_1]: { status: NetworkStatus.Unavailable }, - [NETWORK_CLIENT_ID_89]: { status: NetworkStatus.Available }, - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = { - NetworkController: { - ...mockNetworkController, - state: { networksMetadata: singleInfuraDownMetadata }, - }, - }; - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - expect(store.getActions()).toHaveLength(0); - }); - - it('should not show banner if already visible for the same network', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', // Same as the unavailable network in our mock - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(0); - }); - - it('should update banner if already visible for a different network', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x1', // Different from the unavailable network (0x89) - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test-infura-project-id', - isInfuraEndpoint: true, - }); - - renderHookWithProvider(); + describe('switchToInfura', () => { + it('is a no-op when the banner is not visible', async () => { + selectorMock.mockReturnValue({ visible: false }); + const { result } = renderHookWithProviders(); - act(() => { - jest.advanceTimersByTime(5000); + await act(async () => { + await result.current.switchToInfura(); }); - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); + expect(switchToDefaultInfuraRpcMock).not.toHaveBeenCalled(); }); - it('hides the banner when the previously failing custom network recovers and only one Infura network is now failing', () => { - // Start with banner visible for 0x89 (custom RPC) — the previous code - // path that fired the custom-override rule. - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ + it('is a no-op when no Infura endpoint is available', async () => { + selectorMock.mockReturnValue({ visible: true, chainId: '0x89', status: 'degraded', @@ -550,1193 +201,63 @@ describe('useNetworkConnectionBanner', () => { rpcUrl: 'https://polygon-rpc.com', isInfuraEndpoint: false, }); + const { result } = renderHookWithProviders(); - // 0x89 recovers, only 0x1 (Infura) is now failing. This is a single- - // provider blip (one registrable domain, not all enabled networks down, - // no custom) so the banner should be suppressed. - const updatedNetworkMetadata = { - [NETWORK_CLIENT_ID_1]: { status: NetworkStatus.Unavailable }, - [NETWORK_CLIENT_ID_89]: { status: NetworkStatus.Available }, - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = { - NetworkController: { - ...mockNetworkController, - state: { - networksMetadata: updatedNetworkMetadata, - }, - }, - }; - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); + await act(async () => { + await result.current.switchToInfura(); }); - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); + expect(switchToDefaultInfuraRpcMock).not.toHaveBeenCalled(); }); - it('should hide banner when all networks become available', () => { - // Start with banner visible for 0x89 - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ + it('delegates to the controller, fires analytics, and shows a success toast', async () => { + selectorMock.mockReturnValue({ visible: true, chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - const allAvailableNetworkMetadata = { - [NETWORK_CLIENT_ID_1]: { status: NetworkStatus.Available }, - [NETWORK_CLIENT_ID_89]: { status: NetworkStatus.Available }, - }; - - const mockEngineWithAllAvailable = { - ...mockEngine, - context: { - NetworkController: { - ...mockNetworkController, - state: { - networksMetadata: allAvailableNetworkMetadata, - }, - }, - }, - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = mockEngineWithAllAvailable.context; - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - }); - - it('should show degraded banner after 5 seconds for degraded networks', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - // Fast-forward to 5 seconds (degraded banner timeout) - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); - - it('should show unavailable banner after 30 seconds for unavailable networks', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - // Fast-forward to 30 seconds (unavailable banner timeout) - act(() => { - jest.advanceTimersByTime(30000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(2); // Both degraded and unavailable banners - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - - expect(actions[1]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); - - it('should track correct events for degraded banner', () => { - const rpcUrl = 'https://polygon-rpc.com'; - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl, - isInfuraEndpoint: false, + infuraNetworkClientId: 'polygon-mainnet-infura', }); + const { result } = renderHookWithProviders(); - renderHookWithProvider(); - - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN, - ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', - ); - expect(mockAddProperties).toHaveBeenCalledWith({ - banner_type: 'degraded', - chain_id_caip: 'eip155:137', - rpc_endpoint_url: 'polygon-rpc.com', - rpc_domain: 'polygon-rpc.com', + await act(async () => { + await result.current.switchToInfura(); }); - }); - it('should track correct events for unavailable banner', () => { - const rpcUrl = 'https://polygon-rpc.com'; - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, + expect(switchToDefaultInfuraRpcMock).toHaveBeenCalledWith({ chainId: '0x89', - status: 'unavailable', - networkName: 'Polygon Mainnet', - rpcUrl, - isInfuraEndpoint: false, }); - - renderHookWithProvider(); - - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN, + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SWITCH_TO_METAMASK_DEFAULT_RPC_CLICKED, ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + }), ); - expect(mockAddProperties).toHaveBeenCalledWith({ - banner_type: 'unavailable', - chain_id_caip: 'eip155:137', - rpc_endpoint_url: 'polygon-rpc.com', - rpc_domain: 'polygon-rpc.com', - }); - }); - - it('should not track events when banner is not visible', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - expect(stableCreateEventBuilder).not.toHaveBeenCalled(); - expect(stableTrackEvent).not.toHaveBeenCalled(); }); - it('should update from degraded to unavailable status when network becomes truly unavailable', () => { - // Start with degraded banner visible - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ + it('does not show the toast when the controller rejects', async () => { + switchToDefaultInfuraRpcMock.mockRejectedValueOnce(new Error('boom')); + selectorMock.mockReturnValue({ visible: true, chainId: '0x89', - status: 'degraded', + status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', isInfuraEndpoint: false, + infuraNetworkClientId: 'polygon-mainnet-infura', }); + const { result } = renderHookWithProviders(); - renderHookWithProvider(); - - // Fast-forward to 30 seconds to trigger unavailable banner - act(() => { - jest.advanceTimersByTime(30000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'unavailable', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, + await act(async () => { + await result.current.switchToInfura(); }); - }); - it('should update from unavailable to degraded status when network improves', () => { - // Start with unavailable banner visible - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'unavailable', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - // Fast-forward to 5 seconds (degraded banner timeout) - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); - - it('should show unavailable banner for networks that are truly unavailable', () => { - const unavailableNetworkMetadata = { - [NETWORK_CLIENT_ID_1]: { status: NetworkStatus.Available }, - [NETWORK_CLIENT_ID_89]: { status: NetworkStatus.Unavailable }, - }; - - const mockEngineWithUnavailableNetwork = { - ...mockEngine, - context: { - NetworkController: { - ...mockNetworkController, - state: { - networksMetadata: unavailableNetworkMetadata, - }, - }, - }, - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = mockEngineWithUnavailableNetwork.context; - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - // Fast-forward time to trigger the unavailable banner timeout (30 seconds) - act(() => { - jest.advanceTimersByTime(30000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(2); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - expect(actions[1]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'unavailable', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - expect(actions[1].status).toBe('unavailable'); - expect(actions[1].networkName).toBe('Polygon Mainnet'); - expect(actions[1].rpcUrl).toBe('https://polygon-rpc.com'); - }); - - it('should track banner shown event when showing banner', () => { - const rpcUrl = 'https://polygon-rpc.com'; - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl, - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN, - ); - expect(stableTrackEvent).toHaveBeenCalled(); - expect(isPublicEndpointUrl).toHaveBeenCalledWith( - rpcUrl, - 'test-infura-project-id', - ); - expect(mockAddProperties).toHaveBeenCalledWith({ - banner_type: 'degraded', - chain_id_caip: 'eip155:137', - rpc_endpoint_url: 'polygon-rpc.com', - rpc_domain: 'polygon-rpc.com', - }); - }); - - it('should not show banner for available networks', () => { - const availableNetworkMetadata = { - [NETWORK_CLIENT_ID_1]: { status: NetworkStatus.Available }, - [NETWORK_CLIENT_ID_89]: { status: NetworkStatus.Available }, - }; - - const mockEngineWithAvailableNetworks = { - ...mockEngine, - context: { - NetworkController: { - ...mockNetworkController, - state: { - networksMetadata: availableNetworkMetadata, - }, - }, - }, - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = mockEngineWithAvailableNetworks.context; - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(0); - }); - - it('should skip networks without configuration', () => { - const mockNetworkControllerWithoutConfig = { - ...mockNetworkController, - findNetworkClientIdByChainId: jest.fn((chainId: Hex) => { - const clientIdMap: Record = { - '0x1': NETWORK_CLIENT_ID_1, - '0x89': NETWORK_CLIENT_ID_89, - }; - return clientIdMap[chainId]; - }), - getNetworkConfigurationByNetworkClientId: jest.fn(() => undefined), - }; - - const mockEngineWithoutConfig = { - ...mockEngine, - context: { - NetworkController: mockNetworkControllerWithoutConfig, - }, - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = mockEngineWithoutConfig.context; - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(0); - }); - - it('should clean up both timeouts on unmount', () => { - const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); - - const { unmount } = renderHookWithProvider(); - - unmount(); - - // Should be called twice - once for degraded timeout, once for unavailable timeout - expect(clearTimeoutSpy).toHaveBeenCalledTimes(2); - }); - }); - - describe('enabled networks computation', () => { - it('should compute enabled EVM networks correctly', () => { - renderHookWithProvider(); - - // The hook should compute enabled networks internally - // We can't directly test the internal computation, but we can verify - // that the selector is called - expect(selectEVMEnabledNetworks).toHaveBeenCalled(); - }); - - it('should handle empty enabled networks', () => { - jest.mocked(selectEVMEnabledNetworks).mockReturnValue([]); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(0); - }); - }); - - describe('NetworkController:rpcEndpointChainAvailable event subscription', () => { - it('subscribes to rpcEndpointChainAvailable event on mount', () => { - renderHookWithProvider(); - - expect(mockEngine.controllerMessenger.subscribe).toHaveBeenCalledWith( - 'NetworkController:rpcEndpointChainAvailable', - expect.any(Function), - ); - }); - - it('unsubscribes from rpcEndpointChainAvailable event on unmount', () => { - const { unmount } = renderHookWithProvider(); - - const subscribeCall = ( - mockEngine.controllerMessenger.subscribe as jest.Mock - ).mock.calls.find( - (call) => call[0] === 'NetworkController:rpcEndpointChainAvailable', - ); - const subscribedHandler = subscribeCall?.[1]; - - unmount(); - - expect(mockEngine.controllerMessenger.unsubscribe).toHaveBeenCalledWith( - 'NetworkController:rpcEndpointChainAvailable', - subscribedHandler, - ); - }); - - it('hides banner when rpcEndpointChainAvailable event fires for matching chain', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - const subscribeCall = ( - mockEngine.controllerMessenger.subscribe as jest.Mock - ).mock.calls.find( - (call) => call[0] === 'NetworkController:rpcEndpointChainAvailable', - ); - const subscribedHandler = subscribeCall?.[1]; - - act(() => { - subscribedHandler({ chainId: '0x89' }); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - }); - - it('does not hide banner when rpcEndpointChainAvailable event fires for different chain', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - const subscribeCall = ( - mockEngine.controllerMessenger.subscribe as jest.Mock - ).mock.calls.find( - (call) => call[0] === 'NetworkController:rpcEndpointChainAvailable', - ); - const subscribedHandler = subscribeCall?.[1]; - - act(() => { - subscribedHandler({ chainId: '0x1' }); // Different chain - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(0); - }); - - it('does not hide banner when banner is not visible', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - const subscribeCall = ( - mockEngine.controllerMessenger.subscribe as jest.Mock - ).mock.calls.find( - (call) => call[0] === 'NetworkController:rpcEndpointChainAvailable', - ); - const subscribedHandler = subscribeCall?.[1]; - - act(() => { - subscribedHandler({ chainId: '0x89' }); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(0); - }); - }); - - describe('when device is offline', () => { - beforeEach(() => { - // Mock selector to return offline - jest.mocked(selectIsDeviceOffline).mockReturnValue(true); - }); - - it('hides banner when device is offline even if network is unavailable', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - }); - - it('does not show degraded banner when device is offline', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - // Should not show degraded banner when offline - expect(actions).not.toContainEqual( - expect.objectContaining({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - status: 'degraded', - }), - ); - }); - - it('does not show unavailable banner when device is offline', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(30000); - }); - - const actions = store.getActions(); - // Should not show unavailable banner when offline - expect(actions).not.toContainEqual( - expect.objectContaining({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - status: 'unavailable', - }), - ); - }); - - it('does not progress from degraded to unavailable when device goes offline', () => { - // Device is offline with degraded banner showing - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - renderHookWithProvider(); - - // Clear any calls from initial render (hiding banner) - store.clearActions(); - - // Wait for what would have been the unavailable timeout - act(() => { - jest.advanceTimersByTime(30000); - }); - - const actions = store.getActions(); - // Should NOT progress to unavailable since device is offline - expect(actions).not.toContainEqual( - expect.objectContaining({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - status: 'unavailable', - }), - ); - }); - - it('does not update banner if already hidden when offline', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - const actions = store.getActions(); - // Should not dispatch any actions when banner is already hidden - expect(actions).toHaveLength(0); - }); - - it('resumes normal behavior when device comes back online', async () => { - // Start offline - jest.mocked(selectIsDeviceOffline).mockReturnValue(true); - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - const { rerender } = renderHookWithProvider(); - - // Clear any calls from initial render - store.clearActions(); - - // Device comes back online - update selector mock - jest.mocked(selectIsDeviceOffline).mockReturnValue(false); - - await act(async () => { - rerender({}); - }); - - // Advance timer to trigger degraded - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - // Should now show degraded banner - expect(actions).toContainEqual( - expect.objectContaining({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - status: 'degraded', - chainId: '0x89', - }), - ); - }); - - it('hides banner immediately when device goes offline while showing degraded', async () => { - // Start online with degraded banner - jest.mocked(selectIsDeviceOffline).mockReturnValue(false); - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - const { rerender } = renderHookWithProvider(); - - // Clear any calls from initial render - store.clearActions(); - - // Device goes offline - update selector mock - jest.mocked(selectIsDeviceOffline).mockReturnValue(true); - - await act(async () => { - rerender({}); - }); - - await waitFor(() => { - const actions = store.getActions(); - expect(actions).toContainEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - }); - }); - - it('hides banner immediately when device goes offline while showing unavailable', async () => { - // Start online with unavailable banner - jest.mocked(selectIsDeviceOffline).mockReturnValue(false); - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'unavailable', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - const { rerender } = renderHookWithProvider(); - - // Clear any calls from initial render - store.clearActions(); - - // Device goes offline - update selector mock - jest.mocked(selectIsDeviceOffline).mockReturnValue(true); - - await act(async () => { - rerender({}); - }); - - await waitFor(() => { - const actions = store.getActions(); - expect(actions).toContainEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - }); - }); - }); - - describe('switchToInfura function', () => { - it('does nothing when banner is not visible', async () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect(mockNetworkController.updateNetwork).not.toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does nothing when infuraNetworkClientId is undefined', async () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect(mockNetworkController.updateNetwork).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); }); - - it('updates network, hides banner, and shows toast when Infura endpoint is available', async () => { - const mockConfigWithInfura = mockNetworkConfigurationWithInfura; - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValue( - mockConfigWithInfura, - ); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - // Re-calculates Infura endpoint index from current config (index 1) - expect(mockNetworkController.updateNetwork).toHaveBeenCalledWith( - '0x89', - { - ...mockConfigWithInfura, - defaultRpcEndpointIndex: 1, - }, - { - replacementSelectedRpcEndpointIndex: 1, - }, - ); - - // Hides banner to prevent stale state - const actions = store.getActions(); - expect(actions).toContainEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - - expect(mockShowToast).toHaveBeenCalledWith({ - variant: ToastVariants.Icon, - labelOptions: [ - { - label: 'Updated to MetaMask default', - }, - ], - iconName: IconName.Confirmation, - hasNoTimeout: false, - }); - }); - - it('tracks switch to MetaMask default RPC event', async () => { - const mockConfigWithInfura = mockNetworkConfigurationWithInfura; - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValue( - mockConfigWithInfura, - ); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect(stableCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SWITCH_TO_METAMASK_DEFAULT_RPC_CLICKED, - ); - expect(stableTrackEvent).toHaveBeenCalled(); - }); - - it('does not show toast when updateNetwork fails', async () => { - const mockConfigWithInfura = mockNetworkConfigurationWithInfura; - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValue( - mockConfigWithInfura, - ); - mockNetworkController.updateNetwork.mockRejectedValueOnce( - new Error('Update failed'), - ); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect(mockNetworkController.updateNetwork).toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does nothing when network configuration is not found', async () => { - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValueOnce( - undefined, - ); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect( - mockNetworkController.getNetworkConfigurationByChainId, - ).toHaveBeenCalledWith('0x89'); - expect(mockNetworkController.updateNetwork).not.toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('finds correct endpoint index by networkClientId even when endpoints are reordered', async () => { - // Config where Infura endpoint moved to index 0 (was originally at index 1) - const reorderedConfig: NetworkConfiguration = { - chainId: '0x89', - name: 'Polygon Mainnet', - rpcEndpoints: [ - { - url: 'https://polygon-mainnet.infura.io/v3/test-infura-project-id', - networkClientId: 'polygon-mainnet', // Same networkClientId, different index - type: RpcEndpointType.Custom, - }, - { - url: 'https://polygon-rpc.com', - networkClientId: '0x89-custom', - type: RpcEndpointType.Custom, - }, - ], - defaultRpcEndpointIndex: 1, - blockExplorerUrls: ['https://polygonscan.com'], - nativeCurrency: 'MATIC', - }; - - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValue( - reorderedConfig, - ); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - // Finds endpoint by networkClientId and uses its current index (0) - expect(mockNetworkController.updateNetwork).toHaveBeenCalledWith( - '0x89', - { - ...reorderedConfig, - defaultRpcEndpointIndex: 0, - }, - { - replacementSelectedRpcEndpointIndex: 0, - }, - ); - }); - - it('does nothing when Infura endpoint no longer exists in config', async () => { - // Config without any Infura endpoint - const configWithoutInfura: NetworkConfiguration = { - chainId: '0x89', - name: 'Polygon Mainnet', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - networkClientId: '0x89-custom', - type: RpcEndpointType.Custom, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://polygonscan.com'], - nativeCurrency: 'MATIC', - }; - - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValue( - configWithoutInfura, - ); - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', // The Infura endpoint was removed - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - expect(mockNetworkController.updateNetwork).not.toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does nothing when Infura endpoint is already the default', async () => { - // Config where Infura is already the default endpoint - const configWithInfuraAsDefault: NetworkConfiguration = { - chainId: '0x89', - name: 'Polygon Mainnet', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - networkClientId: '0x89-custom', - type: RpcEndpointType.Custom, - }, - { - url: 'https://polygon-mainnet.infura.io/v3/test-infura-project-id', - networkClientId: 'polygon-mainnet', - type: RpcEndpointType.Custom, - }, - ], - defaultRpcEndpointIndex: 1, // Infura is already the default - blockExplorerUrls: ['https://polygonscan.com'], - nativeCurrency: 'MATIC', - }; - - mockNetworkController.getNetworkConfigurationByChainId.mockReturnValue( - configWithInfuraAsDefault, - ); - - // Banner state may be stale - still shows switch button - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - - const { result } = renderHookWithProvider(); - - await act(async () => { - await result.current.switchToInfura(); - }); - - // Skips update since Infura is already the default - expect(mockNetworkController.updateNetwork).not.toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - }); - - describe('infuraNetworkClientId detection', () => { - it('includes infuraNetworkClientId in banner action when Infura endpoint is available', () => { - // Setup network with custom endpoint as default but Infura endpoint available - const networkConfigWithInfuraEndpoint: NetworkConfiguration = { - chainId: '0x89', - name: 'Polygon Mainnet', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - networkClientId: '0x89-custom', - type: RpcEndpointType.Custom, - }, - { - url: 'https://polygon-mainnet.infura.io/v3/test-infura-project-id', - networkClientId: 'polygon-mainnet', - type: RpcEndpointType.Custom, - }, - ], - defaultRpcEndpointIndex: 0, - blockExplorerUrls: ['https://polygonscan.com'], - nativeCurrency: 'MATIC', - }; - - const mockNetworkControllerWithInfura = { - ...mockNetworkController, - getNetworkConfigurationByNetworkClientId: jest.fn( - (networkClientId: string) => { - if (networkClientId === NETWORK_CLIENT_ID_89) { - return networkConfigWithInfuraEndpoint; - } - return mockNetworkConfigurationByChainId['0x1']; - }, - ), - }; - - // @ts-expect-error - Mocking Engine for testing - Engine.context = { - NetworkController: mockNetworkControllerWithInfura, - }; - - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: 'polygon-mainnet', - }); - }); - - it('does not include infuraNetworkClientId when no Infura endpoint is available', () => { - jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({ - visible: false, - }); - - renderHookWithProvider(); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'SHOW_NETWORK_CONNECTION_BANNER', - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); }); }); diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts index 4ccc3b48ed8..f9e8d0a90f8 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts @@ -1,27 +1,18 @@ -import { useCallback, useContext, useEffect, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { Hex, hexToNumber } from '@metamask/utils'; -import { NetworkStatus } from '@metamask/network-controller'; +import { useCallback, useContext, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { hexToNumber } from '@metamask/utils'; import { useNavigation } from '@react-navigation/native'; import { selectNetworkConnectionBannerState } from '../../../selectors/networkConnectionBanner'; -import { selectIsDeviceOffline } from '../../../selectors/connectivityController'; import Engine from '../../../core/Engine'; import Routes from '../../../constants/navigation/Routes'; import { useAnalytics } from '../useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import { - hideNetworkConnectionBanner, - showNetworkConnectionBanner, -} from '../../../actions/networkConnectionBanner'; -import { NetworkConnectionBannerStatus } from '../../UI/NetworkConnectionBanner/types'; -import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController'; -import { NetworkConnectionBannerState } from '../../../reducers/networkConnectionBanner'; -import { - isPublicEndpointUrl, - getIsMetaMaskInfuraEndpointUrl, -} from '../../../core/Engine/controllers/network-controller/utils'; +import type { + NetworkConnectionBannerState, + NetworkConnectionBannerStatus, +} from '../../UI/NetworkConnectionBanner/types'; +import { isPublicEndpointUrl } from '../../../core/Engine/controllers/network-controller/utils'; import onlyKeepHost from '../../../util/onlyKeepHost'; -import { getDomain } from '../../../util/url-utils'; import { INFURA_PROJECT_ID } from '../../../constants/network'; import { ToastContext, @@ -32,9 +23,6 @@ import { IconName } from '../../../component-library/components/Icons/Icon'; const infuraProjectId = INFURA_PROJECT_ID ?? ''; -const DEGRADED_BANNER_TIMEOUT = 5 * 1000; // 5 seconds -const UNAVAILABLE_BANNER_TIMEOUT = 30 * 1000; // 30 seconds - function sanitizeRpcUrl(rpcUrl: string) { return isPublicEndpointUrl(rpcUrl, infuraProjectId) ? onlyKeepHost(rpcUrl) @@ -55,245 +43,15 @@ const useNetworkConnectionBanner = (): { */ switchToInfura: () => Promise; } => { - const dispatch = useDispatch(); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const { toastRef } = useContext(ToastContext); const networkConnectionBannerState = useSelector( selectNetworkConnectionBannerState, ); - const isOffline = useSelector(selectIsDeviceOffline); - - // Use ref to access current banner state without causing timer effect to re-run - const bannerStateRef = useRef(networkConnectionBannerState); - - const evmEnabledNetworksChainIds = useSelector(selectEVMEnabledNetworks); - - useEffect(() => { - Engine.lookupEnabledNetworks(); - }, []); - - function updateRpc( - rpcUrl: string, - status: NetworkConnectionBannerStatus, - chainId: string, - ) { - navigation.navigate(Routes.EDIT_NETWORK, { - network: rpcUrl, - shouldNetworkSwitchPopToWallet: false, - shouldShowPopularNetworks: false, - trackRpcUpdateFromBanner: true, - }); - - const sanitizedUrl = sanitizeRpcUrl(rpcUrl); - trackEvent( - createEventBuilder( - MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, - ) - .addProperties({ - banner_type: status, - chain_id_caip: `eip155:${hexToNumber(chainId)}`, - // @deprecated: will be removed in a future release - rpc_endpoint_url: sanitizedUrl, - rpc_domain: sanitizedUrl, - }) - .build(), - ); - } - - 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) { - const currentBannerState = bannerStateRef.current; - if (currentBannerState.visible) { - dispatch(hideNetworkConnectionBanner()); - } - return; - } - - const checkNetworkStatus = (timeoutType: NetworkConnectionBannerStatus) => { - const currentBannerState = bannerStateRef.current; - const networksMetadata = - Engine.context.NetworkController.state.networksMetadata; - - // Collect every enabled EVM network whose default RPC endpoint is - // currently failing (status !== Available). We need the full list to - // decide whether to show the banner per the rules below. - const failedNetworks: { - chainId: Hex; - networkName: string; - rpcUrl: string; - isInfuraEndpoint: boolean; - infuraNetworkClientId?: string; - domain: string | null; - }[] = []; - let totalEnabled = 0; - - for (const evmEnabledNetworkChainId of evmEnabledNetworksChainIds) { - try { - const networkClientId = - Engine.context.NetworkController.findNetworkClientIdByChainId( - evmEnabledNetworkChainId, - ); - const networkMetadata = networksMetadata[networkClientId]; - if (!networkMetadata) { - continue; - } - - const networkConfig = - Engine.context.NetworkController.getNetworkConfigurationByNetworkClientId( - networkClientId, - ); - if (!networkConfig) { - continue; - } - - totalEnabled += 1; - - if (networkMetadata.status === NetworkStatus.Available) { - continue; - } - - const defaultRpcEndpointIndex = - networkConfig.defaultRpcEndpointIndex || 0; - const rpcUrl = - networkConfig.rpcEndpoints[defaultRpcEndpointIndex]?.url || - networkConfig.rpcEndpoints[0]?.url; - - const isInfuraEndpoint = getIsMetaMaskInfuraEndpointUrl( - rpcUrl, - infuraProjectId, - ); - - // For custom endpoints (non-Infura), check if there's an Infura - // endpoint available for this network that we can switch to - let infuraNetworkClientId: string | undefined; - if (!isInfuraEndpoint) { - const infuraEndpoint = networkConfig.rpcEndpoints.find( - (endpoint, index) => - index !== defaultRpcEndpointIndex && - getIsMetaMaskInfuraEndpointUrl(endpoint.url, infuraProjectId), - ); - infuraNetworkClientId = infuraEndpoint?.networkClientId; - } - - failedNetworks.push({ - chainId: evmEnabledNetworkChainId, - networkName: networkConfig.name, - rpcUrl, - isInfuraEndpoint, - infuraNetworkClientId, - domain: getDomain(rpcUrl), - }); - } catch { - // TODO: remove this once we update the fixtures on e2e tests - // findNetworkClientIdByChainId and getNetworkConfigurationByNetworkClientId can throw - continue; - } - } - - const firstCustomFailed = failedNetworks.find((n) => !n.isInfuraEndpoint); - const distinctDomains = new Set( - failedNetworks - .map((n) => n.domain) - .filter((domain): domain is string => domain !== null), - ).size; - const areAllEnabledNetworksFailed = - failedNetworks.length > 0 && failedNetworks.length === totalEnabled; - - // Show the banner if: - // - The first failing network is a custom network (users always want to - // be informed about errors with RPC endpoints they've chosen), or - // - There are failures across more than one registrable domain (likely - // client-side issue), or - // - All enabled networks are failing (escape hatch for single-network - // users so they still get a signal). - // A wide single-provider outage on a multi-network setup (e.g. every - // *.infura.io network goes down at once) collapses to one domain and - // is suppressed unless that user's entire enabled set is on it. - const shouldShowBanner = Boolean( - firstCustomFailed || distinctDomains > 1 || areAllEnabledNetworksFailed, - ); - - if (shouldShowBanner) { - const selected = firstCustomFailed ?? failedNetworks[0]; - // Show/update banner if: - // 1. No banner is currently visible, OR - // 2. Banner is visible but for a different network, OR - // 3. Banner is visible for the same network but with a different status - const shouldDispatch = - !currentBannerState.visible || - currentBannerState.chainId !== selected.chainId || - currentBannerState.status !== timeoutType; - - if (shouldDispatch) { - dispatch( - showNetworkConnectionBanner({ - chainId: selected.chainId, - status: timeoutType, - networkName: selected.networkName, - rpcUrl: selected.rpcUrl, - isInfuraEndpoint: selected.isInfuraEndpoint, - infuraNetworkClientId: selected.infuraNetworkClientId, - }), - ); - } - } else if (currentBannerState.visible) { - // Hide banner: either no networks are failing, or failures are confined - // to a single provider and don't warrant the banner. - dispatch(hideNetworkConnectionBanner()); - } - }; - - // Set up degraded banner timeout (5 seconds) - const degradedTimeout = setTimeout(() => { - checkNetworkStatus('degraded'); - }, DEGRADED_BANNER_TIMEOUT); - - // Set up unavailable banner timeout (30 seconds) - const unavailableTimeout = setTimeout(() => { - checkNetworkStatus('unavailable'); - }, UNAVAILABLE_BANNER_TIMEOUT); - - return () => { - clearTimeout(degradedTimeout); - clearTimeout(unavailableTimeout); - }; - }, [isOffline, evmEnabledNetworksChainIds, dispatch]); - - useEffect(() => { - bannerStateRef.current = networkConnectionBannerState; - }, [networkConnectionBannerState]); - - // Subscribe to NetworkController:rpcEndpointChainAvailable event - // to hide the banner when a network becomes available again - useEffect(() => { - const handleChainAvailable = ({ chainId }: { chainId: Hex }) => { - const currentBannerState = bannerStateRef.current; - // Only hide the banner if it's visible and matches the chain that became available - if ( - currentBannerState.visible && - currentBannerState.chainId === chainId - ) { - dispatch(hideNetworkConnectionBanner()); - } - }; - - Engine.controllerMessenger.subscribe( - 'NetworkController:rpcEndpointChainAvailable', - handleChainAvailable, - ); - - return () => { - Engine.controllerMessenger.unsubscribe( - 'NetworkController:rpcEndpointChainAvailable', - handleChainAvailable, - ); - }; - }, [dispatch]); + // Fire analytics whenever the banner is visible. The banner's show/hide + // and 5s/30s escalation are driven by NetworkConnectionBannerController. useEffect(() => { if (networkConnectionBannerState.visible) { const sanitizedUrl = sanitizeRpcUrl(networkConnectionBannerState.rpcUrl); @@ -312,42 +70,48 @@ const useNetworkConnectionBanner = (): { } }, [networkConnectionBannerState, trackEvent, createEventBuilder]); - /** - * Switch the default RPC endpoint to Infura for the current unavailable network. - */ - const switchToInfura = useCallback(async () => { - if (!networkConnectionBannerState.visible) { - return; - } - - const { chainId, status } = networkConnectionBannerState; + const updateRpc = useCallback( + ( + rpcUrl: string, + status: NetworkConnectionBannerStatus, + chainId: string, + ) => { + navigation.navigate(Routes.EDIT_NETWORK, { + network: rpcUrl, + shouldNetworkSwitchPopToWallet: false, + shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, + }); - const networkConfiguration = - Engine.context.NetworkController.getNetworkConfigurationByChainId( - chainId, + const sanitizedUrl = sanitizeRpcUrl(rpcUrl); + trackEvent( + createEventBuilder( + MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, + ) + .addProperties({ + banner_type: status, + chain_id_caip: `eip155:${hexToNumber(chainId)}`, + // @deprecated: will be removed in a future release + rpc_endpoint_url: sanitizedUrl, + rpc_domain: sanitizedUrl, + }) + .build(), ); - if (!networkConfiguration) { - return; - } + }, + [navigation, trackEvent, createEventBuilder], + ); - const { infuraNetworkClientId } = networkConnectionBannerState; - if (!infuraNetworkClientId) { + const switchToInfura = useCallback(async () => { + if (!networkConnectionBannerState.visible) { return; } - // Find the endpoint index by networkClientId - const infuraEndpointIndex = networkConfiguration.rpcEndpoints.findIndex( - (endpoint) => endpoint.networkClientId === infuraNetworkClientId, - ); - // Skip if endpoint not found or it's already the default - if ( - infuraEndpointIndex === -1 || - infuraEndpointIndex === networkConfiguration.defaultRpcEndpointIndex - ) { + const { chainId, status, infuraNetworkClientId } = + networkConnectionBannerState; + if (!infuraNetworkClientId) { return; } - // Track the switch to MetaMask default RPC event const sanitizedUrl = sanitizeRpcUrl(networkConnectionBannerState.rpcUrl); trackEvent( createEventBuilder( @@ -363,23 +127,10 @@ const useNetworkConnectionBanner = (): { ); try { - // Update the network configuration to use the Infura endpoint as default - await Engine.context.NetworkController.updateNetwork( - chainId, - { - ...networkConfiguration, - defaultRpcEndpointIndex: infuraEndpointIndex, - }, - { - replacementSelectedRpcEndpointIndex: infuraEndpointIndex, - }, + await Engine.context.NetworkConnectionBannerController.switchToDefaultInfuraRpc( + { chainId }, ); - // Hide banner immediately to prevent stale "Switch to MetaMask default RPC" button - // The normal status check logic will re-show it with fresh data if network is still unavailable - dispatch(hideNetworkConnectionBanner()); - - // Show success toast toastRef?.current?.showToast({ variant: ToastVariants.Icon, labelOptions: [ @@ -393,16 +144,9 @@ const useNetworkConnectionBanner = (): { hasNoTimeout: false, }); } catch { - // Error is already handled by updateNetwork which shows a warning - // Do not show success toast on failure + // updateNetwork already surfaces a warning toast on failure. } - }, [ - networkConnectionBannerState, - trackEvent, - createEventBuilder, - toastRef, - dispatch, - ]); + }, [networkConnectionBannerState, trackEvent, createEventBuilder, toastRef]); return { networkConnectionBannerState, diff --git a/app/reducers/index.ts b/app/reducers/index.ts index cfbcbce8b0e..f6e599994a4 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -29,10 +29,6 @@ import confirmationMetricsReducer from '../core/redux/slices/confirmationMetrics import originThrottlingReducer from '../core/redux/slices/originThrottling'; import notificationsAccountsProvider from '../core/redux/slices/notifications'; import cronjobControllerReducer from '../core/redux/slices/cronjobController'; -import networkConnectionBannerReducer, { - NetworkConnectionBannerState, -} from './networkConnectionBanner'; - import bannersReducer, { BannersState } from './banners'; import bridgeReducer from '../core/redux/slices/bridge'; import performanceReducer, { @@ -132,7 +128,6 @@ export interface RootState { ///: END:ONLY_INCLUDE_IF cronjobController: StateFromReducer; rewards: RewardsState; - networkConnectionBanner: NetworkConnectionBannerState; attribution: StateFromReducer; } @@ -175,7 +170,6 @@ const baseReducers = { qrKeyringScanner: qrKeyringScannerReducer, cronjobController: cronjobControllerReducer, rewards: rewardsReducer, - networkConnectionBanner: networkConnectionBannerReducer, }; if (isTestEnvironment) { diff --git a/app/reducers/networkConnectionBanner/index.test.ts b/app/reducers/networkConnectionBanner/index.test.ts deleted file mode 100644 index 01b57b317c8..00000000000 --- a/app/reducers/networkConnectionBanner/index.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - showNetworkConnectionBanner, - hideNetworkConnectionBanner, -} from '../../actions/networkConnectionBanner'; - -import reducer, { NetworkConnectionBannerState } from '.'; - -const initialState: Readonly = { - visible: false, -}; - -describe('networkConnectionBanner reducer', () => { - describe('default case', () => { - it('should return the initial state', () => { - // @ts-expect-error - null is not a valid action type - expect(reducer(undefined, { type: null })).toStrictEqual(initialState); - }); - - it('should return current state for unknown action types', () => { - const existingState = { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - isInfuraEndpoint: true, - } as const; - const unknownAction = { - type: 'UNKNOWN_ACTION_TYPE', - } as const; - - // @ts-expect-error - unknownAction is not a valid action - const result = reducer(existingState, unknownAction); - - expect(result).toStrictEqual(existingState); - }); - - it('should return initial state for unknown action when state is undefined', () => { - const unknownAction = { - type: 'UNKNOWN_ACTION_TYPE', - } as const; - - // @ts-expect-error - unknownAction is not a valid action - const result = reducer(undefined, unknownAction); - - expect(result).toStrictEqual(initialState); - }); - }); - - describe('SHOW_DEGRADED_RPC_CONNECTION_BANNER', () => { - it('should show banner with chainId, status, networkName and rpcUrl when action is dispatched', () => { - const chainId = '0x1'; - const networkName = 'Ethereum Mainnet'; - const rpcUrl = 'https://mainnet.infura.io/v3/123'; - const action = showNetworkConnectionBanner({ - chainId, - status: 'degraded', - networkName, - rpcUrl, - isInfuraEndpoint: true, - }); - - const result = reducer(initialState, action); - - expect(result).toStrictEqual({ - visible: true, - chainId, - status: 'degraded', - networkName, - rpcUrl, - isInfuraEndpoint: true, - infuraNetworkClientId: undefined, - }); - }); - - it('should update existing state when banner is already visible with chainId, status, networkName and rpcUrl', () => { - const existingState = { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - isInfuraEndpoint: true, - } as const; - - const newChainId = '0x89'; - const newNetworkName = 'Polygon Mainnet'; - const newNetworkRpcUrl = 'https://polygon-rpc.com'; - const action = showNetworkConnectionBanner({ - chainId: newChainId, - status: 'degraded', - networkName: newNetworkName, - rpcUrl: newNetworkRpcUrl, - isInfuraEndpoint: false, - }); - - const result = reducer(existingState, action); - - expect(result).toStrictEqual({ - visible: true, - chainId: newChainId, - status: 'degraded', - networkName: newNetworkName, - rpcUrl: newNetworkRpcUrl, - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); - }); - - describe('HIDE_DEGRADED_RPC_CONNECTION_BANNER', () => { - it('should hide banner and clear chainId, status, networkName and rpcUrl when action is dispatched', () => { - const existingState = { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - isInfuraEndpoint: true, - } as const; - const action = hideNetworkConnectionBanner(); - - const result = reducer(existingState, action); - - expect(result).toStrictEqual({ - visible: false, - }); - }); - - it('should maintain hidden state when banner is already hidden', () => { - const action = hideNetworkConnectionBanner(); - - const result = reducer(initialState, action); - - expect(result).toStrictEqual(initialState); - }); - }); - - describe('state transitions', () => { - it('should handle complete show-hide cycle', () => { - const chainId = '0x89'; - const networkName = 'Polygon Mainnet'; - const rpcUrl = 'https://polygon-rpc.com'; - const showAction = showNetworkConnectionBanner({ - chainId, - status: 'degraded', - networkName, - rpcUrl, - isInfuraEndpoint: false, - }); - const hideAction = hideNetworkConnectionBanner(); - - const afterShow = reducer(initialState, showAction); - - expect(afterShow).toStrictEqual({ - visible: true, - chainId, - status: 'degraded', - networkName, - rpcUrl, - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - - const afterHide = reducer(afterShow, hideAction); - - expect(afterHide).toStrictEqual({ - visible: false, - }); - }); - }); - - describe('immutability', () => { - it('should not mutate the original state', () => { - const originalState = { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - isInfuraEndpoint: true, - } as const; - const action = hideNetworkConnectionBanner(); - - const result = reducer(originalState, action); - - expect(result).not.toBe(originalState); - expect(originalState).toStrictEqual({ - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - isInfuraEndpoint: true, - }); - }); - - it('should return new state object for show action', () => { - const action = showNetworkConnectionBanner({ - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - }); - - const result = reducer(initialState, action); - - expect(result).not.toBe(initialState); - expect(result).toStrictEqual({ - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - isInfuraEndpoint: false, - infuraNetworkClientId: undefined, - }); - }); - }); -}); diff --git a/app/reducers/networkConnectionBanner/index.ts b/app/reducers/networkConnectionBanner/index.ts deleted file mode 100644 index 428fb60f677..00000000000 --- a/app/reducers/networkConnectionBanner/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Hex } from '@metamask/utils'; -import { - NetworkConnectionBannerActionType, - NetworkConnectionBannerAction, -} from '../../actions/networkConnectionBanner'; -import { NetworkConnectionBannerStatus } from '../../components/UI/NetworkConnectionBanner/types'; - -/** - * Type for defining what properties will be defined in store - */ -export type NetworkConnectionBannerState = - | { - visible: false; - } - | { - visible: true; - chainId: Hex; - status: NetworkConnectionBannerStatus; - networkName: string; - rpcUrl: string; - isInfuraEndpoint: boolean; - /** - * Network client ID of an available Infura endpoint (for custom networks that have one) - * that can be used to switch to Infura. Undefined if no Infura endpoint is available. - */ - infuraNetworkClientId?: string; - }; - -/** - * Initial state of the Network Connection Banners event flow - */ -export const initialState: NetworkConnectionBannerState = { - visible: false, -}; - -/** - * Reducer to Network Connection Banner relative event - * @param {NetworkConnectionBannerState} state: the state of the Network Connection Banners event flow, default to initialState - * @param {NetworkConnectionBannerAction} action: the action object contain type and payload to change state. - * @returns {NetworkConnectionBannerState}: the new state of the Network Connection Banners event flow - */ -const networkConnectionBannerReducer = ( - state: NetworkConnectionBannerState = initialState, - action: NetworkConnectionBannerAction = { - type: NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER, - }, -): NetworkConnectionBannerState => { - switch (action.type) { - case NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER: - return { - visible: true, - chainId: action.chainId, - status: action.status, - networkName: action.networkName, - rpcUrl: action.rpcUrl, - isInfuraEndpoint: action.isInfuraEndpoint, - infuraNetworkClientId: action.infuraNetworkClientId, - }; - case NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER: - return { - visible: false, - }; - default: - return state; - } -}; - -export default networkConnectionBannerReducer; diff --git a/app/selectors/networkConnectionBanner/index.test.ts b/app/selectors/networkConnectionBanner/index.test.ts deleted file mode 100644 index 07de46f027e..00000000000 --- a/app/selectors/networkConnectionBanner/index.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { selectNetworkConnectionBannerState } from './index'; -import { RootState } from '../../reducers'; -import { NetworkConnectionBannerState } from '../../reducers/networkConnectionBanner'; - -describe('networkConnectionBanner selectors', () => { - const mockNetworkConnectionBannersState: NetworkConnectionBannerState = { - visible: false, - }; - - const mockState = { - networkConnectionBanner: mockNetworkConnectionBannersState, - } as unknown as RootState; - - describe('selectNetworkConnectionBannerState', () => { - it('should return the network connection banners state', () => { - const result = selectNetworkConnectionBannerState(mockState); - - expect(result).toBe(mockState.networkConnectionBanner); - }); - - it.each([ - { - status: 'slow', - chainId: '0x1', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - description: 'slow status', - }, - { - status: 'unavailable', - chainId: '0x89', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - description: 'unavailable status', - }, - ])( - 'should return state with visible true and $description when banner is shown', - ({ status, chainId, networkName, rpcUrl }) => { - const stateWithVisibleBanner = { - networkConnectionBanner: { - visible: true, - chainId, - status, - networkName, - rpcUrl, - }, - } as unknown as RootState; - - const result = selectNetworkConnectionBannerState( - stateWithVisibleBanner, - ); - - expect(result).toStrictEqual({ - visible: true, - chainId, - status, - networkName, - rpcUrl, - }); - }, - ); - - it('should return state with visible false when banner is hidden', () => { - const stateWithHiddenBanner = { - networkConnectionBanner: { - visible: false, - }, - } as unknown as RootState; - - const result = selectNetworkConnectionBannerState(stateWithHiddenBanner); - - expect(result).toStrictEqual({ - visible: false, - }); - }); - - it('should return the same reference when called multiple times with same state', () => { - const result1 = selectNetworkConnectionBannerState(mockState); - const result2 = selectNetworkConnectionBannerState(mockState); - - expect(result1).toBe(result2); - }); - - it('should return different references when state changes', () => { - const state1 = { - networkConnectionBanner: { - visible: false, - }, - } as unknown as RootState; - - const state2 = { - networkConnectionBanner: { - visible: true, - chainId: '0x1', - status: 'slow', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - }, - } as unknown as RootState; - - const result1 = selectNetworkConnectionBannerState(state1); - const result2 = selectNetworkConnectionBannerState(state2); - - expect(result1).not.toBe(result2); - expect(result1).toStrictEqual({ - visible: false, - }); - expect(result2).toStrictEqual({ - visible: true, - chainId: '0x1', - status: 'slow', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/123', - }); - }); - }); -}); diff --git a/app/selectors/networkConnectionBanner/index.ts b/app/selectors/networkConnectionBanner/index.ts index bf57db243ff..b709391eb7a 100644 --- a/app/selectors/networkConnectionBanner/index.ts +++ b/app/selectors/networkConnectionBanner/index.ts @@ -1,10 +1,44 @@ -import { RootState } from '../../reducers'; +import { createSelector } from 'reselect'; +import type { NetworkConnectionBannerControllerState } from '@metamask/network-connection-banner-controller'; +import type { + NetworkConnectionBannerState, + NetworkConnectionBannerStatus, +} from '../../components/UI/NetworkConnectionBanner/types'; +import type { RootState } from '../../reducers'; + +const selectNetworkConnectionBannerControllerState = (state: RootState) => + state?.engine?.backgroundState?.NetworkConnectionBannerController as + | NetworkConnectionBannerControllerState + | undefined; /** - * Selector to get the NetworkConnectionBannerState + * Selector that exposes the banner state to the UI. Reads from the + * `NetworkConnectionBannerController` state in `engine.backgroundState` and + * maps it to the legacy `{ visible: boolean }` shape the banner component + * consumes. * - * @param state - Root redux state - * @returns - NetworkConnectionBannerState state + * @param state - The root redux state. + * @returns The banner state for the UI to render. */ -export const selectNetworkConnectionBannerState = (state: RootState) => - state.networkConnectionBanner; +export const selectNetworkConnectionBannerState = createSelector( + [selectNetworkConnectionBannerControllerState], + (controllerState): NetworkConnectionBannerState => { + if ( + !controllerState || + controllerState.status === 'available' || + !controllerState.network + ) { + return { visible: false }; + } + return { + visible: true, + chainId: controllerState.network.chainId, + status: controllerState.status as NetworkConnectionBannerStatus, + networkName: controllerState.network.networkName, + rpcUrl: controllerState.network.rpcUrl, + isInfuraEndpoint: controllerState.network.isInfuraEndpoint, + infuraNetworkClientId: + controllerState.network.infuraNetworkClientId ?? undefined, + }; + }, +); diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index d6cec23d2c5..cdfb55711fd 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -16,7 +16,6 @@ import { initialState as initialPerformanceState } from '../../core/redux/slices import { initialState as initialSampleCounterState } from '../../features/SampleFeature/reducers/sample-counter'; import { isTestEnvironment } from './utils'; import { initialState as initialRewardsState } from '../../reducers/rewards'; -import { initialState as initialNetworkConnectionBannerState } from '../../reducers/networkConnectionBanner'; // A cast is needed here because we use enums in some controllers, and TypeScript doesn't consider // the string value of an enum as satisfying an enum type. export const backgroundState: EngineState = @@ -75,7 +74,6 @@ const initialRootState: RootState = { sampleCounter: initialSampleCounterState, card: initialCardState, rewards: initialRewardsState, - networkConnectionBanner: initialNetworkConnectionBannerState, attribution: { attribution: null, }, From c4c904c884f0d9f65715fe81ae7f7f9b067a5d1c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 21:17:21 +0200 Subject: [PATCH 3/7] fix(test): add NetworkConnectionBannerController to initial-background-state fixture --- app/util/test/initial-background-state.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 26b17517592..e2f7ab49c59 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -646,6 +646,10 @@ "MultichainTransactionsController": { "nonEvmTransactions": {} }, + "NetworkConnectionBannerController": { + "status": "available", + "network": null + }, "MultichainAssetsController": { "accountsAssets": {}, "assetsMetadata": {}, From 94cb4611949d50a3c111d6098d11ee73ec27b2b8 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 23:11:15 +0200 Subject: [PATCH 4/7] chore: drop psl, ip-regex, and url-utils now that the controller owns domain grouping --- app/util/url-utils.test.ts | 70 -------------------------------------- app/util/url-utils.ts | 53 ----------------------------- jest.config.js | 2 +- package.json | 2 -- yarn.lock | 9 ----- 5 files changed, 1 insertion(+), 135 deletions(-) delete mode 100644 app/util/url-utils.test.ts delete mode 100644 app/util/url-utils.ts diff --git a/app/util/url-utils.test.ts b/app/util/url-utils.test.ts deleted file mode 100644 index e617b66dd58..00000000000 --- a/app/util/url-utils.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { getDomain, isLocalhostOrIPAddress } from './url-utils'; - -describe('isLocalhostOrIPAddress', () => { - it('returns true for "localhost" (any case)', () => { - expect(isLocalhostOrIPAddress('localhost')).toBe(true); - expect(isLocalhostOrIPAddress('LOCALHOST')).toBe(true); - }); - - it('returns true for IPv4 addresses', () => { - expect(isLocalhostOrIPAddress('127.0.0.1')).toBe(true); - expect(isLocalhostOrIPAddress('10.0.0.1')).toBe(true); - expect(isLocalhostOrIPAddress('8.8.8.8')).toBe(true); - }); - - it('returns true for IPv6 addresses (with or without brackets)', () => { - expect(isLocalhostOrIPAddress('::1')).toBe(true); - expect(isLocalhostOrIPAddress('[::1]')).toBe(true); - }); - - it('returns false for regular hostnames', () => { - expect(isLocalhostOrIPAddress('infura.io')).toBe(false); - expect(isLocalhostOrIPAddress('mainnet.infura.io')).toBe(false); - expect(isLocalhostOrIPAddress('my-custom-rpc')).toBe(false); - }); - - it('returns false for malformed IPv4-looking strings', () => { - expect(isLocalhostOrIPAddress('999.999.999.999')).toBe(false); - expect(isLocalhostOrIPAddress('1.2.3')).toBe(false); - }); -}); - -describe('getDomain', () => { - it('returns the registrable domain 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/app/util/url-utils.ts b/app/util/url-utils.ts deleted file mode 100644 index aeec5481849..00000000000 --- a/app/util/url-utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import ipRegex from 'ip-regex'; -import psl from 'psl'; - -/** - * Check if a hostname is localhost or an IP address (v4 or v6). Public RPC - * providers use domain names, not raw IP addresses, so a `true` result means - * the host should not be reduced to an eTLD+1. - * - * @param hostname - The hostname to check. - * @returns True if the hostname is localhost or an IP address. - */ -export function isLocalhostOrIPAddress(hostname: string): boolean { - const lowerHostname = hostname.toLowerCase(); - - if (lowerHostname === 'localhost') { - return true; - } - - // Remove brackets from IPv6 addresses for testing (e.g., [::1] -> ::1) - const hostnameWithoutBrackets = lowerHostname.replace(/^\[|\]$/gu, ''); - - 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 { - let url: URL; - try { - url = new URL(urlString); - } catch { - return null; - } - - const { hostname } = url; - - if (!hostname.includes('.') || isLocalhostOrIPAddress(hostname)) { - return hostname; - } - - return psl.get(hostname) ?? hostname; -} diff --git a/jest.config.js b/jest.config.js index a6f43f58ad6..d46ad74bc84 100644 --- a/jest.config.js +++ b/jest.config.js @@ -33,7 +33,7 @@ const config = { setupFilesAfterEnv: ['/app/util/test/testSetup.js'], testEnvironment: 'jest-environment-node', transformIgnorePatterns: [ - 'node_modules/(?!((@metamask/)?(@react-native|react-native|redux-persist-filesystem|@react-navigation|@react-native-community|@react-native-masked-view|react-navigation|react-navigation-redux-helpers|@sentry|d3-color|d3-shape|d3-path|d3-scale|d3-array|d3-time|d3-format|d3-interpolate|d3-selection|d3-axis|d3-transition|internmap|react-native-wagmi-charts|react-native-nitro-modules|@notifee|expo-file-system|expo-modules-core|expo(nent)?|@expo(nent)?/.*)|@noble/.*|@nktkas/hyperliquid|@metamask/design-system-twrnc-preset|@metamask/design-system-react-native|@metamask/native-utils|@metamask/smart-transactions-controller|@tommasini/react-native-scrollable-tab-view|@veriff/react-native-sdk|@braze/react-native-sdk|uuid|ip-regex))', + 'node_modules/(?!((@metamask/)?(@react-native|react-native|redux-persist-filesystem|@react-navigation|@react-native-community|@react-native-masked-view|react-navigation|react-navigation-redux-helpers|@sentry|d3-color|d3-shape|d3-path|d3-scale|d3-array|d3-time|d3-format|d3-interpolate|d3-selection|d3-axis|d3-transition|internmap|react-native-wagmi-charts|react-native-nitro-modules|@notifee|expo-file-system|expo-modules-core|expo(nent)?|@expo(nent)?/.*)|@noble/.*|@nktkas/hyperliquid|@metamask/design-system-twrnc-preset|@metamask/design-system-react-native|@metamask/native-utils|@metamask/smart-transactions-controller|@tommasini/react-native-scrollable-tab-view|@veriff/react-native-sdk|@braze/react-native-sdk|uuid))', ], transform: { '^.+\\.[jt]sx?$': ['babel-jest', { configFile: './babel.config.tests.js' }], diff --git a/package.json b/package.json index 7026b62d184..757486d4ce5 100644 --- a/package.json +++ b/package.json @@ -446,7 +446,6 @@ "he": "^1.2.0", "https-browserify": "0.0.1", "humanize-duration": "^3.27.2", - "ip-regex": "^5.0.0", "is-url": "^1.2.4", "js-sha3": "0.9.3", "lodash": "4.18.1", @@ -460,7 +459,6 @@ "pbkdf2": "3.1.3", "pify": "6.1.0", "prop-types": "15.7.2", - "psl": "^1.15.0", "pump": "3.0.0", "punycode": "^2.1.1", "qs": "6.15.2", diff --git a/yarn.lock b/yarn.lock index dd54840ead0..ebd51864e17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31992,13 +31992,6 @@ __metadata: languageName: node linkType: hard -"ip-regex@npm:^5.0.0": - version: 5.0.0 - resolution: "ip-regex@npm:5.0.0" - checksum: 10/4098b2df89c015f1484a5946e733ec126af8c1828719d90e09f04af23ce487e1a852670e4d3f51b0dc6dfbaf7d8bfab23fd7893ca60e69833da99b7b1ee3623b - languageName: node - linkType: hard - "ipaddr.js@npm:1.9.1": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -35525,7 +35518,6 @@ __metadata: https-browserify: "npm:0.0.1" humanize-duration: "npm:^3.27.2" husky: "npm:^9.1.7" - ip-regex: "npm:^5.0.0" is-url: "npm:^1.2.4" jest: "npm:^29.7.0" jest-junit: "npm:^15.0.0" @@ -35556,7 +35548,6 @@ __metadata: prettier-2: "npm:prettier@^2.8.8" prompts: "npm:^2.4.2" prop-types: "npm:15.7.2" - psl: "npm:^1.15.0" pump: "npm:3.0.0" punycode: "npm:^2.1.1" qs: "npm:6.15.2" From d1df6dde3e3d794e42c54a25a112df3667d839d6 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 10 Jun 2026 12:28:48 +0200 Subject: [PATCH 5/7] chore: keep narrower cast for state-changed event-name skew After the connectivity-controller bump to ^0.2.0 the version-skew at the messenger boundary narrows to event-name aliases: the preview package's messenger expects each peer's `:stateChanged` event while mobile's GlobalEvents union still registers the legacy `:stateChange` flavor. BaseController publishes both at runtime, so wiring works. --- .../network-connection-banner-controller-messenger.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts b/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts index 35bcd59e136..5b490fc246b 100644 --- a/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts +++ b/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts @@ -12,8 +12,15 @@ import { RootMessenger } from '../types'; export function getNetworkConnectionBannerControllerMessenger( rootMessenger: RootMessenger, ): NetworkConnectionBannerControllerMessenger { + // The preview package's messenger type is built against the new + // `:stateChanged` event-name flavor (`ControllerStateChangedEvent`), while + // mobile's `GlobalEvents` union still aggregates each peer controller's + // legacy `:stateChange` flavor. `BaseController` publishes both names at + // runtime, so the wiring works — only the type union is skewed. The cast + // can be dropped once `GlobalEvents` is updated to include the + // `:stateChanged` aliases for the relevant peer controllers. return new Messenger({ namespace: 'NetworkConnectionBannerController', - parent: rootMessenger, - }); + parent: rootMessenger as unknown as undefined, + }) as unknown as NetworkConnectionBannerControllerMessenger; } From 8f62d2b6a7758fc95715328bfc0ab9f05482967f Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 10 Jun 2026 23:56:01 +0200 Subject: [PATCH 6/7] fix: use 69c25f142 preview --- ...-connection-banner-controller-messenger.ts | 42 ++++++++++++++----- app/core/Engine/types.ts | 4 ++ package.json | 2 +- yarn.lock | 8 ++-- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts b/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts index 5b490fc246b..c22bba6a3fa 100644 --- a/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts +++ b/app/core/Engine/messengers/network-connection-banner-controller-messenger.ts @@ -1,4 +1,8 @@ -import { Messenger } from '@metamask/messenger'; +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; import type { NetworkConnectionBannerControllerMessenger } from '@metamask/network-connection-banner-controller'; import { RootMessenger } from '../types'; @@ -12,15 +16,31 @@ import { RootMessenger } from '../types'; export function getNetworkConnectionBannerControllerMessenger( rootMessenger: RootMessenger, ): NetworkConnectionBannerControllerMessenger { - // The preview package's messenger type is built against the new - // `:stateChanged` event-name flavor (`ControllerStateChangedEvent`), while - // mobile's `GlobalEvents` union still aggregates each peer controller's - // legacy `:stateChange` flavor. `BaseController` publishes both names at - // runtime, so the wiring works — only the type union is skewed. The cast - // can be dropped once `GlobalEvents` is updated to include the - // `:stateChanged` aliases for the relevant peer controllers. - return new Messenger({ + const messenger = new Messenger< + 'NetworkConnectionBannerController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ namespace: 'NetworkConnectionBannerController', - parent: rootMessenger as unknown as undefined, - }) as unknown as NetworkConnectionBannerControllerMessenger; + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:updateNetwork', + 'NetworkEnablementController:getState', + 'ConnectivityController:getState', + ], + events: [ + 'NetworkController:stateChange', + 'NetworkEnablementController:stateChange', + 'ConnectivityController:stateChange', + ], + messenger, + }); + + return messenger; } diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 3d1213c604b..dc77d61ae0f 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -90,6 +90,8 @@ import { import type { NetworkConnectionBannerController, NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerActions, + NetworkConnectionBannerControllerEvents, } from '@metamask/network-connection-banner-controller'; import { ConnectivityController, @@ -542,6 +544,7 @@ export type GlobalActions = | KeyringControllerActions | NetworkControllerActions | NetworkEnablementControllerActions + | NetworkConnectionBannerControllerActions | PermissionControllerActions | SignatureControllerActions | LoggingControllerActions @@ -633,6 +636,7 @@ export type GlobalEvents = | KeyringControllerEvents | NetworkControllerEvents | NetworkEnablementControllerEvents + | NetworkConnectionBannerControllerEvents | PermissionControllerEvents ///: BEGIN:ONLY_INCLUDE_IF(snaps) | SnapsGlobalEvents diff --git a/package.json b/package.json index 757486d4ce5..e60025e5882 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ ] }, "resolutions": { - "@metamask/network-connection-banner-controller": "npm:@metamask-previews/network-connection-banner-controller@0.1.0-preview-7507a11", + "@metamask/network-connection-banner-controller": "npm:@metamask-previews/network-connection-banner-controller@0.1.0-preview-69c25f142", "@metamask/network-controller": "32.0.0", "@react-native-community/viewpager": "patch:@react-native-community/viewpager@npm%3A3.3.0#~/.yarn/patches/@react-native-community-viewpager-npm-3.3.0.patch", "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.5", diff --git a/yarn.lock b/yarn.lock index ebd51864e17..873d4583f53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9296,9 +9296,9 @@ __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" +"@metamask/network-connection-banner-controller@npm:@metamask-previews/network-connection-banner-controller@0.1.0-preview-69c25f142": + version: 0.1.0-preview-69c25f142 + resolution: "@metamask-previews/network-connection-banner-controller@npm:0.1.0-preview-69c25f142" dependencies: "@metamask/base-controller": "npm:^9.1.0" "@metamask/connectivity-controller": "npm:^0.2.0" @@ -9308,7 +9308,7 @@ __metadata: "@metamask/utils": "npm:^11.9.0" ip-regex: "npm:^4.3.0" psl: "npm:^1.15.0" - checksum: 10/a3a8bb5f0e14aa503c72ed4d0b16ebd1763817bbdf3fb04f5dc4c6156ca8c0f02284fe73989676043b48fc198c366b6171f369fbafd1ceb906b397ccc22dcd11 + checksum: 10/261ed4f78094d6ee631b189fbb2d2449eb9ede52bf90af4129a275fde3e7b8a24e4e5dac5eac3d664c264ff97643029ab714ba41844fd5e6f1d6ccba62f72737 languageName: node linkType: hard From aecbf224addacf6749dae77e65e6a79d1fbc6262 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 00:01:50 +0200 Subject: [PATCH 7/7] test: add unit test for NetworkConnectionBannerControllerMessenger --- ...ection-banner-controller-messenger.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/core/Engine/messengers/network-connection-banner-controller-messenger.test.ts diff --git a/app/core/Engine/messengers/network-connection-banner-controller-messenger.test.ts b/app/core/Engine/messengers/network-connection-banner-controller-messenger.test.ts new file mode 100644 index 00000000000..85c29ad0d7c --- /dev/null +++ b/app/core/Engine/messengers/network-connection-banner-controller-messenger.test.ts @@ -0,0 +1,33 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, + MOCK_ANY_NAMESPACE, + type MockAnyNamespace, +} from '@metamask/messenger'; +import { NetworkConnectionBannerControllerMessenger } from '@metamask/network-connection-banner-controller'; +import { getNetworkConnectionBannerControllerMessenger } from './network-connection-banner-controller-messenger'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +describe('getNetworkConnectionBannerControllerMessenger', () => { + it('returns a restricted messenger', () => { + const rootMessenger: RootMessenger = getRootMessenger(); + const networkConnectionBannerControllerMessenger = + getNetworkConnectionBannerControllerMessenger(rootMessenger); + + expect(networkConnectionBannerControllerMessenger).toBeInstanceOf( + Messenger, + ); + }); +});