From 66abd50a03164c20a46ea353fdea7b528aac0261 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 16:09:43 +0200 Subject: [PATCH 01/48] feat: add @metamask/network-connection-banner-controller --- .github/CODEOWNERS | 1 + README.md | 7 + .../CHANGELOG.md | 12 + .../LICENSE | 20 + .../README.md | 18 + .../jest.config.js | 29 + .../package.json | 80 ++ ...ionBannerController-method-action-types.ts | 37 + .../NetworkConnectionBannerController.test.ts | 1126 +++++++++++++++++ .../src/NetworkConnectionBannerController.ts | 506 ++++++++ .../src/index.ts | 20 + .../src/psl.d.ts | 17 + .../src/url-utils.test.ts | 76 ++ .../src/url-utils.ts | 53 + .../tsconfig.build.json | 16 + .../tsconfig.json | 14 + .../typedoc.json | 7 + tsconfig.build.json | 3 + tsconfig.json | 3 + yarn.lock | 53 +- 20 files changed, 2094 insertions(+), 4 deletions(-) create mode 100644 packages/network-connection-banner-controller/CHANGELOG.md create mode 100644 packages/network-connection-banner-controller/LICENSE create mode 100644 packages/network-connection-banner-controller/README.md create mode 100644 packages/network-connection-banner-controller/jest.config.js create mode 100644 packages/network-connection-banner-controller/package.json create mode 100644 packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts create mode 100644 packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts create mode 100644 packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts create mode 100644 packages/network-connection-banner-controller/src/index.ts create mode 100644 packages/network-connection-banner-controller/src/psl.d.ts create mode 100644 packages/network-connection-banner-controller/src/url-utils.test.ts create mode 100644 packages/network-connection-banner-controller/src/url-utils.ts create mode 100644 packages/network-connection-banner-controller/tsconfig.build.json create mode 100644 packages/network-connection-banner-controller/tsconfig.json create mode 100644 packages/network-connection-banner-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7625784448..efc8210058 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -91,6 +91,7 @@ /packages/composable-controller @MetaMask/core-platform /packages/connectivity-controller @MetaMask/core-platform /packages/controller-utils @MetaMask/core-platform +/packages/network-connection-banner-controller @MetaMask/core-platform /packages/eth-json-rpc-middleware @MetaMask/core-platform /packages/messenger @MetaMask/core-platform /packages/messenger-cli @MetaMask/core-platform diff --git a/README.md b/README.md index f4a317c926..da738a84ee 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/multichain-network-controller`](packages/multichain-network-controller) - [`@metamask/multichain-transactions-controller`](packages/multichain-transactions-controller) - [`@metamask/name-controller`](packages/name-controller) +- [`@metamask/network-connection-banner-controller`](packages/network-connection-banner-controller) - [`@metamask/network-controller`](packages/network-controller) - [`@metamask/network-enablement-controller`](packages/network-enablement-controller) - [`@metamask/notification-services-controller`](packages/notification-services-controller) @@ -189,6 +190,7 @@ linkStyle default opacity:0.5 multichain_network_controller(["@metamask/multichain-network-controller"]); multichain_transactions_controller(["@metamask/multichain-transactions-controller"]); name_controller(["@metamask/name-controller"]); + network_connection_banner_controller(["@metamask/network-connection-banner-controller"]); network_controller(["@metamask/network-controller"]); network_enablement_controller(["@metamask/network-enablement-controller"]); notification_services_controller(["@metamask/notification-services-controller"]); @@ -436,6 +438,11 @@ linkStyle default opacity:0.5 name_controller --> base_controller; name_controller --> controller_utils; name_controller --> messenger; + network_connection_banner_controller --> base_controller; + network_connection_banner_controller --> connectivity_controller; + network_connection_banner_controller --> messenger; + network_connection_banner_controller --> network_controller; + network_connection_banner_controller --> network_enablement_controller; network_controller --> base_controller; network_controller --> connectivity_controller; network_controller --> controller_utils; diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md new file mode 100644 index 0000000000..b82868f70c --- /dev/null +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release. diff --git a/packages/network-connection-banner-controller/LICENSE b/packages/network-connection-banner-controller/LICENSE new file mode 100644 index 0000000000..c8a0ff6be3 --- /dev/null +++ b/packages/network-connection-banner-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/network-connection-banner-controller/README.md b/packages/network-connection-banner-controller/README.md new file mode 100644 index 0000000000..7db31b8d81 --- /dev/null +++ b/packages/network-connection-banner-controller/README.md @@ -0,0 +1,18 @@ +# `@metamask/network-connection-banner-controller` + +NetworkConnectionBannerController decides when and how to surface the network +connection banner based on RPC endpoint health. It encapsulates the shared +rule, the 5s/30s timer state machine, and the eTLD+1 grouping previously +duplicated across MetaMask clients. + +## Installation + +`yarn add @metamask/network-connection-banner-controller` + +or + +`npm install @metamask/network-connection-banner-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/network-connection-banner-controller/jest.config.js b/packages/network-connection-banner-controller/jest.config.js new file mode 100644 index 0000000000..f6a03b6880 --- /dev/null +++ b/packages/network-connection-banner-controller/jest.config.js @@ -0,0 +1,29 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // Skip the ambient psl.d.ts shim from coverage — it's a type-only file. + coveragePathIgnorePatterns: ['.*\\.d\\.ts$'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json new file mode 100644 index 0000000000..6bc6129928 --- /dev/null +++ b/packages/network-connection-banner-controller/package.json @@ -0,0 +1,80 @@ +{ + "name": "@metamask/network-connection-banner-controller", + "version": "0.1.0", + "description": "Decides when and how to surface the network connection banner based on RPC endpoint health", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/network-connection-banner-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/network-connection-banner-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/network-connection-banner-controller", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.1.0", + "@metamask/connectivity-controller": "^0.2.0", + "@metamask/messenger": "^1.2.0", + "@metamask/network-controller": "^32.0.0", + "@metamask/network-enablement-controller": "^5.3.0", + "@metamask/utils": "^11.11.0", + "ip-regex": "^4.3.0", + "psl": "^1.15.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts new file mode 100644 index 0000000000..0e3b76ca1c --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -0,0 +1,37 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; + +/** + * Clears the banner state regardless of the current rule outcome. The next + * subscription-driven evaluation will re-show the banner if the conditions + * still hold. + */ +export type NetworkConnectionBannerControllerDismissBannerAction = { + type: `NetworkConnectionBannerController:dismissBanner`; + handler: NetworkConnectionBannerController['dismissBanner']; +}; + +/** + * Switches the chain's default RPC endpoint to its first Infura endpoint, + * causing the banner to clear once the network becomes available again. + * + * @param args - The arguments to this action. + * @param args.chainId - The chain whose default RPC should be switched. + * @throws If the chain configuration cannot be found, or if it has no + * Infura endpoint to switch to, or if the default is already Infura. + */ +export type NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction = { + type: `NetworkConnectionBannerController:switchToDefaultInfuraRpc`; + handler: NetworkConnectionBannerController['switchToDefaultInfuraRpc']; +}; + +/** + * Union of all NetworkConnectionBannerController action types. + */ +export type NetworkConnectionBannerControllerMethodActions = + | NetworkConnectionBannerControllerDismissBannerAction + | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction; diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts new file mode 100644 index 0000000000..a3b167ee78 --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -0,0 +1,1126 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import type { ConnectivityControllerState } from '@metamask/connectivity-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { + NetworkConfiguration, + NetworkState, +} from '@metamask/network-controller'; +import { + NetworkStatus, + RpcEndpointType, +} from '@metamask/network-controller'; +import type { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { NetworkConnectionBannerControllerMessenger } from './NetworkConnectionBannerController'; +import { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; + +const INFURA_PROJECT_ID = 'test-infura-project-id'; + +const MAINNET_CLIENT_ID = 'mainnet'; +const SEPOLIA_CLIENT_ID = 'sepolia'; +const POLYGON_CUSTOM_CLIENT_ID = 'polygon-custom'; +const ALCHEMY_CLIENT_ID = 'eth-alchemy'; + +function buildInfuraEndpoint( + networkClientId: string, + subdomain: string, +): NetworkConfiguration['rpcEndpoints'][number] { + return { + networkClientId, + type: RpcEndpointType.Infura, + url: `https://${subdomain}.infura.io/v3/${INFURA_PROJECT_ID}`, + }; +} + +function buildCustomEndpoint( + networkClientId: string, + url: string, +): NetworkConfiguration['rpcEndpoints'][number] { + return { + networkClientId, + type: RpcEndpointType.Custom, + url, + }; +} + +function buildConfiguration( + overrides: Partial & Pick, +): NetworkConfiguration { + return { + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet')], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + ...overrides, + }; +} + +describe('NetworkConnectionBannerController', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('metadata', () => { + it('keeps banner state ephemeral and surfaces it to debug snapshots and the UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "network": null, + "status": "available", + } + `); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "network": null, + "status": "available", + } + `); + }); + }); + }); + + describe('default state', () => { + it('starts with status "available" and no network selected', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }); + }); + }); + + describe('rule evaluation on NetworkController:stateChanged', () => { + it('does not show the banner when only one Infura network is failing alongside healthy peers (single-provider blip)', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + '0xaa36a7': buildConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), + ], + }), + }, + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + [SEPOLIA_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }); + }); + + it('does not show the banner when many Infura networks are failing simultaneously alongside a healthy peer on another domain', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + '0xaa36a7': buildConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), + ], + }), + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + [SEPOLIA_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + + expect(controller.state.status).toBe('available'); + }); + }); + + it('shows the banner when failures span two different registrable domains', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + '0xa4b1': buildConfiguration({ + chainId: '0xa4b1', + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + buildCustomEndpoint( + ALCHEMY_CLIENT_ID, + 'https://arb-mainnet.g.alchemy.com/v2/abc', + ), + ], + }), + }, + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + // Below the degraded threshold — banner still hidden. + jest.advanceTimersByTime(4_999); + expect(controller.state.status).toBe('available'); + + // Cross the 5s mark — degraded banner appears. Custom override surfaces + // the Alchemy network so the "Switch to Infura" CTA targets it. + jest.advanceTimersByTime(1); + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + chainId: '0xa4b1', + isInfuraEndpoint: false, + rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/abc', + }); + + // Cross the 30s mark — escalates to unavailable. + jest.advanceTimersByTime(25_000); + expect(controller.state.status).toBe('unavailable'); + }); + }); + + it('shows the banner when a single custom RPC fails amid healthy Infura peers (custom override)', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + chainId: '0x89', + isInfuraEndpoint: false, + }); + }); + }); + + it('shows the banner when every enabled network is failing on a single domain (all-down escape hatch)', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + chainId: '0x1', + isInfuraEndpoint: true, + }); + }); + }); + + it('prefers a custom failure over an Infura one when surfacing the banner network', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.network).toMatchObject({ chainId: '0x89' }); + }); + }); + + it('only updates the failed-network detail (not the timers) when the same chain keeps failing across re-evaluations', async () => { + await withController(({ controller, setNetworkState }) => { + const config = buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }); + setNetworkState( + buildNetworkState({ + configurations: { '0x1': config }, + enabledChainIds: ['0x1'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + // Same chain still failing — should be a no-op update (no timer reset). + setNetworkState( + buildNetworkState({ + configurations: { '0x1': config }, + enabledChainIds: ['0x1'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Blocked), + }, + }), + ); + + // 25s after the original degraded fire — the unavailable escalation + // should still happen on schedule (timers were not restarted). + jest.advanceTimersByTime(25_000); + expect(controller.state.status).toBe('unavailable'); + }); + }); + + it('cancels the banner if the network recovers between the degraded-timer scheduling and its firing', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // Advance 4s — degraded timer is scheduled but not yet fired. + jest.advanceTimersByTime(4_000); + + // Network recovers in the meantime. The next state-change clears + // the timer. + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }); + }); + + it('bails out at the degraded timer if the underlying state has silently recovered', async () => { + await withController( + ({ controller, setNetworkState, setNetworkStateSilently }) => { + const config = buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }); + setNetworkState( + buildNetworkState({ + configurations: { '0x89': config }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // Underlying state recovers but no event fires; the scheduled + // degraded timer must re-check and skip the update. + setNetworkStateSilently( + buildNetworkState({ + configurations: { '0x89': config }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('available'); + }, + ); + }); + + it('bails out at the unavailable timer if the underlying state has silently recovered', async () => { + await withController( + ({ controller, setNetworkState, setNetworkStateSilently }) => { + const config = buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }); + setNetworkState( + buildNetworkState({ + configurations: { '0x89': config }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + // Underlying state recovers but no event fires; the scheduled + // unavailable timer must re-check and skip the escalation. + setNetworkStateSilently( + buildNetworkState({ + configurations: { '0x89': config }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + jest.advanceTimersByTime(25_000); + expect(controller.state.status).toBe('degraded'); + }, + ); + }); + + it('skips enabled chains that have no network configuration', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState({ + network: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + enablement: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, + }, + } as NetworkEnablementControllerState['enabledNetworkMap'], + }, + connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, + }); + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }); + }); + + it('clears banner state when all enabled networks recover', async () => { + await withController(({ controller, setNetworkState }) => { + const failingConfig = buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }); + setNetworkState( + buildNetworkState({ + configurations: { '0x89': failingConfig }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + setNetworkState( + buildNetworkState({ + configurations: { '0x89': failingConfig }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + }, + }), + ); + + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }); + }); + + it('treats an unparseable RPC URL as non-Infura when classifying failures', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint(MAINNET_CLIENT_ID, 'not a valid url'), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + isInfuraEndpoint: false, + }); + }); + }); + + it('skips configurations whose default RPC endpoint is missing', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': { + chainId: '0x1', + name: 'Broken', + nativeCurrency: 'ETH', + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + }, + }, + enabledChainIds: ['0x1'], + }), + ); + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }); + }); + + it('reports the Infura endpoint to switch to when the failing network has one', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + ALCHEMY_CLIENT_ID, + 'https://eth-mainnet.alchemyapi.io/v2/abc', + ), + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.network).toMatchObject({ + chainId: '0x1', + isInfuraEndpoint: false, + infuraNetworkClientId: MAINNET_CLIENT_ID, + // Sanity-check: not null when there's an Infura endpoint to offer. + }); + }); + }); + }); + + describe('ConnectivityController integration', () => { + it('does not touch banner state when going offline while no banner is shown', async () => { + await withController(({ controller, setConnectivityStatus }) => { + const before = controller.state; + setConnectivityStatus(CONNECTIVITY_STATUSES.Offline); + expect(controller.state).toStrictEqual(before); + }); + }); + + it('suppresses the banner while the device is offline and reinstates it when back online', async () => { + await withController( + ({ controller, setNetworkState, setConnectivityStatus }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + setConnectivityStatus(CONNECTIVITY_STATUSES.Offline); + expect(controller.state.status).toBe('available'); + expect(controller.state.network).toBeNull(); + + setConnectivityStatus(CONNECTIVITY_STATUSES.Online); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, + ); + }); + }); + + describe('dismissBanner', () => { + it('is a no-op when no banner is currently shown', async () => { + await withController(({ controller }) => { + const before = controller.state; + controller.dismissBanner(); + expect(controller.state).toStrictEqual(before); + }); + }); + + it('clears banner state via direct call', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + controller.dismissBanner(); + expect(controller.state.status).toBe('available'); + expect(controller.state.network).toBeNull(); + }); + }); + + it('clears banner state via messenger action', async () => { + await withController(({ controller, rootMessenger, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(5_000); + + rootMessenger.call('NetworkConnectionBannerController:dismissBanner'); + expect(controller.state.status).toBe('available'); + }); + }); + }); + + describe('switchToDefaultInfuraRpc', () => { + it('invokes NetworkController:updateNetwork with the Infura endpoint as the new default', async () => { + await withController( + async ({ rootMessenger, setNetworkState, updateNetwork }) => { + const config = buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + ALCHEMY_CLIENT_ID, + 'https://eth-mainnet.alchemyapi.io/v2/abc', + ), + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }); + setNetworkState( + buildNetworkState({ + configurations: { '0x1': config }, + enabledChainIds: ['0x1'], + metadata: { + [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', + { chainId: '0x1' }, + ); + + expect(updateNetwork).toHaveBeenCalledTimes(1); + expect(updateNetwork).toHaveBeenCalledWith( + '0x1', + expect.objectContaining({ defaultRpcEndpointIndex: 1 }), + { replacementSelectedRpcEndpointIndex: 1 }, + ); + }, + ); + }); + + it('is a no-op when the default is already Infura', async () => { + await withController( + async ({ rootMessenger, setNetworkState, updateNetwork }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + }, + enabledChainIds: ['0x1'], + }), + ); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', + { chainId: '0x1' }, + ); + + expect(updateNetwork).not.toHaveBeenCalled(); + }, + ); + }); + + it('throws when no network configuration exists for the chain', async () => { + await withController(async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', + { chainId: '0xdeadbeef' }, + ), + ).rejects.toThrow(/No network configuration found/u); + }); + }); + + it('throws when the chain has no Infura endpoint to switch to', async () => { + await withController(async ({ rootMessenger, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + ALCHEMY_CLIENT_ID, + 'https://eth-mainnet.alchemyapi.io/v2/abc', + ), + ], + }), + }, + enabledChainIds: ['0x1'], + }), + ); + + await expect( + rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', + { chainId: '0x1' }, + ), + ).rejects.toThrow(/No Infura endpoint available/u); + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeMetadata(status: NetworkStatus): { + // eslint-disable-next-line @typescript-eslint/naming-convention + EIPS: Record; + status: NetworkStatus; +} { + return { EIPS: {}, status }; +} + +type BuildNetworkStateArgs = { + configurations: Record; + metadata?: Record< + string, + { + // eslint-disable-next-line @typescript-eslint/naming-convention + EIPS: Record; + status: NetworkStatus; + } + >; + enabledChainIds?: Hex[]; +}; + +type StubbedState = { + network: Partial; + enablement: NetworkEnablementControllerState; + connectivity: ConnectivityControllerState; +}; + +function buildNetworkState({ + configurations, + metadata = {}, + enabledChainIds, +}: BuildNetworkStateArgs): StubbedState { + const allChainIds = (enabledChainIds ?? (Object.keys(configurations) as Hex[])); + return { + network: { + networkConfigurationsByChainId: configurations, + networksMetadata: metadata as NetworkState['networksMetadata'], + }, + enablement: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: Object.fromEntries( + allChainIds.map((chainId) => [chainId, true]), + ), + } as NetworkEnablementControllerState['enabledNetworkMap'], + }, + connectivity: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + }; +} + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type WithControllerCallback = (payload: { + controller: NetworkConnectionBannerController; + rootMessenger: RootMessenger; + controllerMessenger: NetworkConnectionBannerControllerMessenger; + setNetworkState: (state: StubbedState) => void; + setNetworkStateSilently: (state: StubbedState) => void; + setConnectivityStatus: (status: ConnectivityControllerState['connectivityStatus']) => void; + updateNetwork: jest.Mock; +}) => Promise | ReturnValue; + +async function withController( + testFunction: WithControllerCallback, +): Promise { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + let currentState: StubbedState = { + network: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + enablement: { + enabledNetworkMap: {}, + } as NetworkEnablementControllerState, + connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, + }; + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => currentState.network as NetworkState, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + (chainId) => + currentState.network.networkConfigurationsByChainId?.[chainId], + ); + const updateNetwork = jest.fn(async () => undefined); + rootMessenger.registerActionHandler( + 'NetworkController:updateNetwork', + updateNetwork, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => currentState.enablement, + ); + + rootMessenger.registerActionHandler( + 'ConnectivityController:getState', + () => currentState.connectivity, + ); + + const controllerMessenger = new Messenger({ + namespace: 'NetworkConnectionBannerController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: controllerMessenger, + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:updateNetwork', + 'NetworkEnablementController:getState', + 'ConnectivityController:getState', + ], + events: [ + 'NetworkController:stateChanged', + 'NetworkEnablementController:stateChanged', + 'ConnectivityController:stateChanged', + ], + }); + + const controller = new NetworkConnectionBannerController({ + messenger: controllerMessenger, + }); + + const setNetworkState = (state: StubbedState): void => { + currentState = state; + rootMessenger.publish( + 'NetworkController:stateChanged', + currentState.network as NetworkState, + [], + ); + rootMessenger.publish( + 'NetworkEnablementController:stateChanged', + currentState.enablement, + [], + ); + }; + + // Update the upstream state visible to the controller WITHOUT publishing a + // stateChange event. Used to exercise the defensive re-evaluation inside + // the degraded / unavailable timer callbacks. + const setNetworkStateSilently = (state: StubbedState): void => { + currentState = state; + }; + + const setConnectivityStatus = ( + status: ConnectivityControllerState['connectivityStatus'], + ): void => { + currentState = { + ...currentState, + connectivity: { connectivityStatus: status }, + }; + rootMessenger.publish( + 'ConnectivityController:stateChanged', + currentState.connectivity, + [], + ); + }; + + return await testFunction({ + controller, + rootMessenger, + controllerMessenger, + setNetworkState, + setNetworkStateSilently, + setConnectivityStatus, + updateNetwork, + }); +} diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts new file mode 100644 index 0000000000..a861ccdcab --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -0,0 +1,506 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { connectivityControllerSelectors } from '@metamask/connectivity-controller'; +import type { + ConnectivityControllerGetStateAction, + ConnectivityControllerState, +} from '@metamask/connectivity-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkConfigurationByChainIdAction, + NetworkControllerGetStateAction, + NetworkControllerUpdateNetworkAction, + NetworkState, +} from '@metamask/network-controller'; +import { NetworkStatus } from '@metamask/network-controller'; +import type { + NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerState, +} from '@metamask/network-enablement-controller'; +import { selectEnabledEvmNetworks } from '@metamask/network-enablement-controller'; +import type { Hex } from '@metamask/utils'; + +import type { NetworkConnectionBannerControllerMethodActions } from './NetworkConnectionBannerController-method-action-types'; +import { getDomain } from './url-utils'; + +/** + * The name of the {@link NetworkConnectionBannerController}, used to namespace + * the controller's actions and events and to namespace the controller's state + * data when composed with other controllers. + */ +export const controllerName = 'NetworkConnectionBannerController'; + +/** + * Status the banner can be in. `available` means no banner is shown; the + * `degraded` and `unavailable` values mirror the two-tier escalation that the + * UI renders. + */ +export type NetworkConnectionBannerStatus = + | 'available' + | 'degraded' + | 'unavailable'; + +/** + * Details of the failing network the banner should describe. Populated when + * {@link NetworkConnectionBannerControllerState.status} is `degraded` or + * `unavailable`, `null` otherwise. + * + * `infuraNetworkClientId` is the networkClientId of an Infura endpoint on the + * same chain that the user can switch to. `null` when the failing endpoint is + * already Infura, or when no Infura alternative exists. + */ +export type NetworkConnectionBannerFailedNetwork = { + chainId: Hex; + networkClientId: string; + networkName: string; + rpcUrl: string; + isInfuraEndpoint: boolean; + infuraNetworkClientId: string | null; +}; + +/** + * State for the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerState = { + status: NetworkConnectionBannerStatus; + network: NetworkConnectionBannerFailedNetwork | null; +}; + +const networkConnectionBannerControllerMetadata = { + status: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + network: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link NetworkConnectionBannerController} state. + * + * @returns The default state. + */ +export function getDefaultNetworkConnectionBannerControllerState(): NetworkConnectionBannerControllerState { + return { + status: 'available', + network: null, + }; +} + +const DEGRADED_BANNER_TIMEOUT_MS = 5_000; +const UNAVAILABLE_BANNER_TIMEOUT_MS = 30_000; + +const MESSENGER_EXPOSED_METHODS = [ + 'dismissBanner', + 'switchToDefaultInfuraRpc', +] as const; + +/** + * Retrieves the state of the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + NetworkConnectionBannerControllerState + >; + +/** + * Actions that {@link NetworkConnectionBannerControllerMessenger} exposes to + * other consumers. + */ +export type NetworkConnectionBannerControllerActions = + | NetworkConnectionBannerControllerGetStateAction + | NetworkConnectionBannerControllerMethodActions; + +/** + * Actions from other messengers that + * {@link NetworkConnectionBannerControllerMessenger} calls. + */ +type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkConfigurationByChainIdAction + | NetworkControllerUpdateNetworkAction + | NetworkEnablementControllerGetStateAction + | ConnectivityControllerGetStateAction; + +/** + * Published when the state of {@link NetworkConnectionBannerController} + * changes. + */ +export type NetworkConnectionBannerControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + NetworkConnectionBannerControllerState + >; + +/** + * Events that {@link NetworkConnectionBannerControllerMessenger} exposes to + * other consumers. + */ +export type NetworkConnectionBannerControllerEvents = + NetworkConnectionBannerControllerStateChangedEvent; + +/** + * Events from other messengers that + * {@link NetworkConnectionBannerControllerMessenger} subscribes to. + */ +type AllowedEvents = + | ControllerStateChangedEvent<'NetworkController', NetworkState> + | ControllerStateChangedEvent< + 'NetworkEnablementController', + NetworkEnablementControllerState + > + | ControllerStateChangedEvent< + 'ConnectivityController', + ConnectivityControllerState + >; + +/** + * The messenger restricted to actions and events accessed by + * {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerMessenger = Messenger< + typeof controllerName, + NetworkConnectionBannerControllerActions | AllowedActions, + NetworkConnectionBannerControllerEvents | AllowedEvents +>; + +/** + * Options for constructing the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerOptions = { + /** + * The messenger for inter-controller communication. + */ + messenger: NetworkConnectionBannerControllerMessenger; +}; + +/** + * NetworkConnectionBannerController decides whether the network connection + * banner should be shown for the user, and which failing network it should + * describe. It encapsulates the rule, the 5s / 30s timer escalation, and the + * eTLD+1 grouping that was previously duplicated across MetaMask clients. + * + * The banner shows when: + * + * - The first failing network's default RPC is a custom (non-Infura) endpoint + * — users always want to be informed about errors with RPCs they've chosen. + * - Failed RPCs span more than one registrable domain (likely client-side). + * - Every enabled EVM network is failing (escape hatch for single-network + * setups so they still get a signal). + * + * A wide single-provider outage (e.g. every `*.infura.io` network goes down at + * once) collapses to one domain and is suppressed, except in the all-down + * single-network case. When a custom failure is present, it's surfaced first + * so the "Switch to MetaMask default RPC" CTA targets the network the user + * can act on. + * + * Clients only need to render the banner from the controller's state and wire + * click handlers to {@link dismissBanner} or {@link switchToDefaultInfuraRpc}. + */ +export class NetworkConnectionBannerController extends BaseController< + typeof controllerName, + NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerMessenger +> { + #degradedTimer: ReturnType | undefined; + + #unavailableTimer: ReturnType | undefined; + + /** + * Constructs a new {@link NetworkConnectionBannerController}. + * + * @param args - The arguments to this controller. + * @param args.messenger - The messenger suited for this controller. + */ + constructor({ messenger }: NetworkConnectionBannerControllerOptions) { + super({ + messenger, + metadata: networkConnectionBannerControllerMetadata, + name: controllerName, + state: getDefaultNetworkConnectionBannerControllerState(), + }); + + const onStateChange = (): void => this.#evaluate(); + this.messenger.subscribe('NetworkController:stateChanged', onStateChange); + this.messenger.subscribe( + 'NetworkEnablementController:stateChanged', + onStateChange, + ); + this.messenger.subscribe( + 'ConnectivityController:stateChanged', + onStateChange, + ); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Clears the banner state regardless of the current rule outcome. The next + * subscription-driven evaluation will re-show the banner if the conditions + * still hold. + */ + dismissBanner(): void { + this.#clearTimers(); + if (this.state.status !== 'available' || this.state.network !== null) { + this.update((draft) => { + draft.status = 'available'; + draft.network = null; + }); + } + } + + /** + * Switches the chain's default RPC endpoint to its first Infura endpoint, + * causing the banner to clear once the network becomes available again. + * + * @param args - The arguments to this action. + * @param args.chainId - The chain whose default RPC should be switched. + * @throws If the chain configuration cannot be found, or if it has no + * Infura endpoint to switch to, or if the default is already Infura. + */ + async switchToDefaultInfuraRpc({ + chainId, + }: { + chainId: Hex; + }): Promise { + const networkConfiguration = this.messenger.call( + 'NetworkController:getNetworkConfigurationByChainId', + chainId, + ); + if (!networkConfiguration) { + throw new Error( + `No network configuration found for chain ID "${chainId}".`, + ); + } + + const infuraEndpointIndex = networkConfiguration.rpcEndpoints.findIndex( + (endpoint) => isInfuraEndpoint(endpoint.url), + ); + if (infuraEndpointIndex === -1) { + throw new Error( + `No Infura endpoint available for chain ID "${chainId}".`, + ); + } + if (infuraEndpointIndex === networkConfiguration.defaultRpcEndpointIndex) { + // The default is already Infura; nothing to do. + return; + } + + await this.messenger.call( + 'NetworkController:updateNetwork', + chainId, + { + ...networkConfiguration, + defaultRpcEndpointIndex: infuraEndpointIndex, + }, + { replacementSelectedRpcEndpointIndex: infuraEndpointIndex }, + ); + } + + #evaluate(): void { + const isOffline = connectivityControllerSelectors.selectIsOffline( + this.messenger.call('ConnectivityController:getState'), + ); + if (isOffline) { + this.#clearTimers(); + if (this.state.status !== 'available' || this.state.network !== null) { + this.update((draft) => { + draft.status = 'available'; + draft.network = null; + }); + } + return; + } + + const failed = this.#findFailedNetworkForBanner(); + + if (!failed) { + this.#clearTimers(); + if (this.state.status !== 'available' || this.state.network !== null) { + this.update((draft) => { + draft.status = 'available'; + draft.network = null; + }); + } + return; + } + + // If the banner is currently showing for a different network or status, + // restart the escalation timeline for the new one. Otherwise let the + // existing timers continue. + if ( + this.state.status !== 'available' && + this.state.network?.networkClientId === failed.networkClientId + ) { + this.update((draft) => { + draft.network = failed; + }); + return; + } + + this.#clearTimers(); + this.update((draft) => { + draft.status = 'available'; + draft.network = null; + }); + + this.#degradedTimer = setTimeout(() => { + this.#degradedTimer = undefined; + const stillFailed = this.#findFailedNetworkForBanner(); + if (!stillFailed) { + return; + } + this.update((draft) => { + draft.status = 'degraded'; + draft.network = stillFailed; + }); + this.#unavailableTimer = setTimeout(() => { + this.#unavailableTimer = undefined; + const stillFailedAtEscalation = this.#findFailedNetworkForBanner(); + if (!stillFailedAtEscalation) { + return; + } + this.update((draft) => { + draft.status = 'unavailable'; + draft.network = stillFailedAtEscalation; + }); + }, UNAVAILABLE_BANNER_TIMEOUT_MS - DEGRADED_BANNER_TIMEOUT_MS); + }, DEGRADED_BANNER_TIMEOUT_MS); + } + + #clearTimers(): void { + if (this.#degradedTimer !== undefined) { + clearTimeout(this.#degradedTimer); + this.#degradedTimer = undefined; + } + if (this.#unavailableTimer !== undefined) { + clearTimeout(this.#unavailableTimer); + this.#unavailableTimer = undefined; + } + } + + #findFailedNetworkForBanner(): NetworkConnectionBannerFailedNetwork | null { + const enabledEvmChainIds = selectEnabledEvmNetworks( + this.messenger.call('NetworkEnablementController:getState'), + ) as Hex[]; + const { networkConfigurationsByChainId, networksMetadata } = + this.messenger.call('NetworkController:getState'); + + type EnrichedFailedNetwork = NetworkConnectionBannerFailedNetwork & { + domain: string | null; + }; + const failedNetworks: EnrichedFailedNetwork[] = []; + let totalEnabled = 0; + + for (const chainId of enabledEvmChainIds) { + const networkConfiguration = networkConfigurationsByChainId[chainId]; + if (!networkConfiguration) { + continue; + } + + const { rpcEndpoints, defaultRpcEndpointIndex, name } = + networkConfiguration; + const defaultRpcEndpoint = rpcEndpoints[defaultRpcEndpointIndex]; + if (!defaultRpcEndpoint) { + continue; + } + + totalEnabled += 1; + + const metadata = networksMetadata[defaultRpcEndpoint.networkClientId]; + if ( + metadata === undefined || + metadata.status === NetworkStatus.Available + ) { + continue; + } + + const endpointIsInfura = isInfuraEndpoint(defaultRpcEndpoint.url); + + // For custom endpoints (non-Infura), find an Infura endpoint on this + // chain that we could offer to switch to. + let infuraNetworkClientId: string | null = null; + if (!endpointIsInfura) { + const otherInfura = rpcEndpoints.find( + (endpoint, index) => + index !== defaultRpcEndpointIndex && + isInfuraEndpoint(endpoint.url), + ); + infuraNetworkClientId = otherInfura?.networkClientId ?? null; + } + + failedNetworks.push({ + chainId, + networkClientId: defaultRpcEndpoint.networkClientId, + networkName: name, + rpcUrl: defaultRpcEndpoint.url, + isInfuraEndpoint: endpointIsInfura, + infuraNetworkClientId, + domain: getDomain(defaultRpcEndpoint.url), + }); + } + + if (failedNetworks.length === 0) { + return null; + } + + const firstCustomFailed = failedNetworks.find( + (entry) => !entry.isInfuraEndpoint, + ); + const distinctDomains = new Set( + failedNetworks + .map((entry) => entry.domain) + .filter((domain): domain is string => domain !== null), + ).size; + const areAllEnabledNetworksFailed = + failedNetworks.length === totalEnabled; + + if ( + !firstCustomFailed && + distinctDomains <= 1 && + !areAllEnabledNetworksFailed + ) { + return null; + } + + const selected = firstCustomFailed ?? failedNetworks[0]; + return { + chainId: selected.chainId, + networkClientId: selected.networkClientId, + networkName: selected.networkName, + rpcUrl: selected.rpcUrl, + isInfuraEndpoint: selected.isInfuraEndpoint, + infuraNetworkClientId: selected.infuraNetworkClientId, + }; + } +} + +/** + * Checks if an RPC URL is hosted on the Infura service. Detection is by + * hostname suffix rather than the exact MetaMask-key URL pattern, so any + * `*.infura.io` URL counts. That's the right shape for grouping by provider. + * + * @param url - The RPC URL to check. + * @returns True if the URL's host is on `infura.io`. + */ +function isInfuraEndpoint(url: string): boolean { + try { + return new URL(url).hostname.toLowerCase().endsWith('.infura.io'); + } catch { + return false; + } +} diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts new file mode 100644 index 0000000000..f431d8e5cd --- /dev/null +++ b/packages/network-connection-banner-controller/src/index.ts @@ -0,0 +1,20 @@ +export type { + NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerGetStateAction, + NetworkConnectionBannerControllerActions, + NetworkConnectionBannerControllerStateChangedEvent, + NetworkConnectionBannerControllerEvents, + NetworkConnectionBannerControllerMessenger, + NetworkConnectionBannerControllerOptions, + NetworkConnectionBannerFailedNetwork, + NetworkConnectionBannerStatus, +} from './NetworkConnectionBannerController'; +export type { + NetworkConnectionBannerControllerDismissBannerAction, + NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction, +} from './NetworkConnectionBannerController-method-action-types'; +export { + NetworkConnectionBannerController, + getDefaultNetworkConnectionBannerControllerState, +} from './NetworkConnectionBannerController'; +export { getDomain, isLocalhostOrIPAddress } from './url-utils'; diff --git a/packages/network-connection-banner-controller/src/psl.d.ts b/packages/network-connection-banner-controller/src/psl.d.ts new file mode 100644 index 0000000000..74eede54f3 --- /dev/null +++ b/packages/network-connection-banner-controller/src/psl.d.ts @@ -0,0 +1,17 @@ +// psl ships its own types at psl/types/index.d.ts, but its package.json +// `exports` map omits a `types` condition so TypeScript's moduleResolution:node16 +// can't find them. Mirror the small surface we use. +declare module 'psl' { + export function get(domain: string | null): string | null; + export function isValid(domain: string): boolean; + export function parse(domain: string): + | { + tld: string | null; + sld: string | null; + domain: string | null; + subdomain: string | null; + listed: boolean; + input: string; + } + | { input: string; error: { message: string; code: string } }; +} diff --git a/packages/network-connection-banner-controller/src/url-utils.test.ts b/packages/network-connection-banner-controller/src/url-utils.test.ts new file mode 100644 index 0000000000..a79230eb74 --- /dev/null +++ b/packages/network-connection-banner-controller/src/url-utils.test.ts @@ -0,0 +1,76 @@ +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(); + }); + + it('falls back to the hostname when psl cannot resolve it (e.g., .local suffix)', () => { + // `.local` is reserved but not part of the Public Suffix List, so + // `psl.get` returns null and getDomain returns the hostname verbatim. + expect(getDomain('http://foo.local/')).toBe('foo.local'); + }); +}); diff --git a/packages/network-connection-banner-controller/src/url-utils.ts b/packages/network-connection-banner-controller/src/url-utils.ts new file mode 100644 index 0000000000..a0fad187c6 --- /dev/null +++ b/packages/network-connection-banner-controller/src/url-utils.ts @@ -0,0 +1,53 @@ +import ipRegex from 'ip-regex'; +import { get as pslGet } 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 pslGet(hostname) ?? hostname; +} diff --git a/packages/network-connection-banner-controller/tsconfig.build.json b/packages/network-connection-banner-controller/tsconfig.build.json new file mode 100644 index 0000000000..30afc90512 --- /dev/null +++ b/packages/network-connection-banner-controller/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../connectivity-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../network-enablement-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-connection-banner-controller/tsconfig.json b/packages/network-connection-banner-controller/tsconfig.json new file mode 100644 index 0000000000..8e47db924b --- /dev/null +++ b/packages/network-connection-banner-controller/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../connectivity-controller" }, + { "path": "../messenger" }, + { "path": "../network-controller" }, + { "path": "../network-enablement-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-connection-banner-controller/typedoc.json b/packages/network-connection-banner-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/network-connection-banner-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 00ea5a9aec..9a33f01a7d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -172,6 +172,9 @@ { "path": "./packages/name-controller/tsconfig.build.json" }, + { + "path": "./packages/network-connection-banner-controller/tsconfig.build.json" + }, { "path": "./packages/network-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 1bdf352a2f..593bf01605 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -164,6 +164,9 @@ { "path": "./packages/name-controller" }, + { + "path": "./packages/network-connection-banner-controller" + }, { "path": "./packages/network-controller" }, diff --git a/yarn.lock b/yarn.lock index 539426c4e6..5fd1dc25f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6159,7 +6159,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/connectivity-controller@npm:^0.2.0, @metamask/connectivity-controller@workspace:packages/connectivity-controller": +"@metamask/connectivity-controller@npm:^0.2.0, @metamask/connectivity-controller@workspace:^, @metamask/connectivity-controller@workspace:packages/connectivity-controller": version: 0.0.0-use.local resolution: "@metamask/connectivity-controller@workspace:packages/connectivity-controller" dependencies: @@ -7597,7 +7597,36 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-controller@npm:^32.0.0, @metamask/network-controller@workspace:packages/network-controller": +"@metamask/network-connection-banner-controller@workspace:packages/network-connection-banner-controller": + version: 0.0.0-use.local + resolution: "@metamask/network-connection-banner-controller@workspace:packages/network-connection-banner-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/connectivity-controller": "workspace:^" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/network-controller": "workspace:^" + "@metamask/network-enablement-controller": "workspace:^" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + ip-regex: "npm:^4.3.0" + jest: "npm:^29.7.0" + psl: "npm:^1.15.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + peerDependencies: + "@metamask/connectivity-controller": ^0.2.0 + "@metamask/network-controller": ^25.0.0 + "@metamask/network-enablement-controller": ^0.4.0 + languageName: unknown + linkType: soft + +"@metamask/network-controller@npm:^32.0.0, @metamask/network-controller@workspace:^, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" dependencies: @@ -7646,7 +7675,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-enablement-controller@npm:^5.3.0, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": +"@metamask/network-enablement-controller@npm:^5.3.0, @metamask/network-enablement-controller@workspace:^, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": version: 0.0.0-use.local resolution: "@metamask/network-enablement-controller@workspace:packages/network-enablement-controller" dependencies: @@ -17039,6 +17068,13 @@ __metadata: languageName: node linkType: hard +"ip-regex@npm:^4.3.0": + version: 4.3.0 + resolution: "ip-regex@npm:4.3.0" + checksum: 10/7ff904b891221b1847f3fdf3dbb3e6a8660dc39bc283f79eb7ed88f5338e1a3d1104b779bc83759159be266249c59c2160e779ee39446d79d4ed0890dfd06f08 + languageName: node + linkType: hard + "ipaddr.js@npm:1.9.1": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -21965,6 +22001,15 @@ __metadata: languageName: node linkType: hard +"psl@npm:^1.15.0": + version: 1.15.0 + resolution: "psl@npm:1.15.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10/5e7467eb5196eb7900d156783d12907d445c0122f76c73203ce96b148a6ccf8c5450cc805887ffada38ff92d634afcf33720c24053cb01d5b6598d1c913c5caf + languageName: node + linkType: hard + "punycode@npm:2.1.0": version: 2.1.0 resolution: "punycode@npm:2.1.0" @@ -21972,7 +22017,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0, punycode@npm:^2.1.1": +"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10/febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 From 9b432bef50121acc81e70a74f4478e398940beda Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 16:46:30 +0200 Subject: [PATCH 02/48] fix: regenerate yarn.lock after constraints rewrote dep ranges --- yarn.lock | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5fd1dc25f6..018260baf5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6159,7 +6159,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/connectivity-controller@npm:^0.2.0, @metamask/connectivity-controller@workspace:^, @metamask/connectivity-controller@workspace:packages/connectivity-controller": +"@metamask/connectivity-controller@npm:^0.2.0, @metamask/connectivity-controller@workspace:packages/connectivity-controller": version: 0.0.0-use.local resolution: "@metamask/connectivity-controller@workspace:packages/connectivity-controller" dependencies: @@ -7603,10 +7603,10 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/connectivity-controller": "workspace:^" + "@metamask/connectivity-controller": "npm:^0.2.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/network-controller": "workspace:^" - "@metamask/network-enablement-controller": "workspace:^" + "@metamask/network-controller": "npm:^32.0.0" + "@metamask/network-enablement-controller": "npm:^5.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -7619,14 +7619,10 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - peerDependencies: - "@metamask/connectivity-controller": ^0.2.0 - "@metamask/network-controller": ^25.0.0 - "@metamask/network-enablement-controller": ^0.4.0 languageName: unknown linkType: soft -"@metamask/network-controller@npm:^32.0.0, @metamask/network-controller@workspace:^, @metamask/network-controller@workspace:packages/network-controller": +"@metamask/network-controller@npm:^32.0.0, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" dependencies: @@ -7675,7 +7671,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-enablement-controller@npm:^5.3.0, @metamask/network-enablement-controller@workspace:^, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": +"@metamask/network-enablement-controller@npm:^5.3.0, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": version: 0.0.0-use.local resolution: "@metamask/network-enablement-controller@workspace:packages/network-enablement-controller" dependencies: From 49c1ce0fd4754c1cf6b8f3222ee4f27a81a9ce4c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 16:50:07 +0200 Subject: [PATCH 03/48] docs: reference PR in initial changelog entry --- packages/network-connection-banner-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md index b82868f70c..00382020d9 100644 --- a/packages/network-connection-banner-controller/CHANGELOG.md +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -9,4 +9,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release. +- Initial release ([#9041](https://github.com/MetaMask/core/pull/9041)). From 04c1299dc50fbdaaff10d39e9e83f8ed6f3c4693 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 17:05:41 +0200 Subject: [PATCH 04/48] chore: align @metamask/utils version with monorepo + drop trailing period in changelog --- packages/network-connection-banner-controller/CHANGELOG.md | 2 +- packages/network-connection-banner-controller/package.json | 2 +- yarn.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md index 00382020d9..4506608105 100644 --- a/packages/network-connection-banner-controller/CHANGELOG.md +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -9,4 +9,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release ([#9041](https://github.com/MetaMask/core/pull/9041)). +- Initial release ([#9041](https://github.com/MetaMask/core/pull/9041)) diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json index 6bc6129928..4589cda0cc 100644 --- a/packages/network-connection-banner-controller/package.json +++ b/packages/network-connection-banner-controller/package.json @@ -58,7 +58,7 @@ "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^32.0.0", "@metamask/network-enablement-controller": "^5.3.0", - "@metamask/utils": "^11.11.0", + "@metamask/utils": "^11.9.0", "ip-regex": "^4.3.0", "psl": "^1.15.0" }, diff --git a/yarn.lock b/yarn.lock index 018260baf5..e217a81f46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7607,7 +7607,7 @@ __metadata: "@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.11.0" + "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From 199ffcdbbb8585a0cc5586c94e9a85f230b7609d Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 17:24:04 +0200 Subject: [PATCH 05/48] refactor: read controller state directly instead of via cross-package selectors --- .../NetworkConnectionBannerController.test.ts | 17 +++++++++++++++ .../src/NetworkConnectionBannerController.ts | 21 ++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index a3b167ee78..d7b7cef9bf 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -654,6 +654,23 @@ describe('NetworkConnectionBannerController', () => { }); }); + it('keeps the banner hidden when the enablement map has no EVM namespace at all', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState({ + network: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + enablement: { + enabledNetworkMap: {}, + } as NetworkEnablementControllerState, + connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, + }); + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }); + }); + it('skips configurations whose default RPC endpoint is missing', async () => { await withController(({ controller, setNetworkState }) => { setNetworkState( diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index a861ccdcab..f4cb9fb8c4 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -4,7 +4,7 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { connectivityControllerSelectors } from '@metamask/connectivity-controller'; +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import type { ConnectivityControllerGetStateAction, ConnectivityControllerState, @@ -21,8 +21,8 @@ import type { NetworkEnablementControllerGetStateAction, NetworkEnablementControllerState, } from '@metamask/network-enablement-controller'; -import { selectEnabledEvmNetworks } from '@metamask/network-enablement-controller'; import type { Hex } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; import type { NetworkConnectionBannerControllerMethodActions } from './NetworkConnectionBannerController-method-action-types'; import { getDomain } from './url-utils'; @@ -312,10 +312,10 @@ export class NetworkConnectionBannerController extends BaseController< } #evaluate(): void { - const isOffline = connectivityControllerSelectors.selectIsOffline( - this.messenger.call('ConnectivityController:getState'), + const { connectivityStatus } = this.messenger.call( + 'ConnectivityController:getState', ); - if (isOffline) { + if (connectivityStatus === CONNECTIVITY_STATUSES.Offline) { this.#clearTimers(); if (this.state.status !== 'available' || this.state.network !== null) { this.update((draft) => { @@ -394,9 +394,14 @@ export class NetworkConnectionBannerController extends BaseController< } #findFailedNetworkForBanner(): NetworkConnectionBannerFailedNetwork | null { - const enabledEvmChainIds = selectEnabledEvmNetworks( - this.messenger.call('NetworkEnablementController:getState'), - ) as Hex[]; + const { enabledNetworkMap } = this.messenger.call( + 'NetworkEnablementController:getState', + ); + const enabledEvmChainIds = Object.entries( + enabledNetworkMap[KnownCaipNamespace.Eip155] ?? {}, + ) + .filter(([, enabled]) => enabled) + .map(([chainId]) => chainId as Hex); const { networkConfigurationsByChainId, networksMetadata } = this.messenger.call('NetworkController:getState'); From bd2f1f434469ab3b8b37af5882995d54e7b9f1c1 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 17:39:48 +0200 Subject: [PATCH 06/48] fix: dedupe deps --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index e217a81f46..915aeff5f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21990,14 +21990,7 @@ __metadata: languageName: node linkType: hard -"psl@npm:^1.1.33": - version: 1.9.0 - resolution: "psl@npm:1.9.0" - checksum: 10/d07879d4bfd0ac74796306a8e5a36a93cfb9c4f4e8ee8e63fbb909066c192fe1008cd8f12abd8ba2f62ca28247949a20c8fb32e1d18831d9e71285a1569720f9 - languageName: node - linkType: hard - -"psl@npm:^1.15.0": +"psl@npm:^1.1.33, psl@npm:^1.15.0": version: 1.15.0 resolution: "psl@npm:1.15.0" dependencies: From 4ef01139c9fc98e60d6c4b5ff8bdc2cea33200da Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 18:09:02 +0200 Subject: [PATCH 07/48] chore: register network-connection-banner-controller in teams.json --- teams.json | 1 + 1 file changed, 1 insertion(+) diff --git a/teams.json b/teams.json index 571fe2d410..84649bd2ee 100644 --- a/teams.json +++ b/teams.json @@ -48,6 +48,7 @@ "metamask/connectivity-controller": "team-core-platform", "metamask/geolocation-controller": "team-mobile-platform", "metamask/controller-utils": "team-core-platform", + "metamask/network-connection-banner-controller": "team-core-platform", "metamask/messenger": "team-core-platform", "metamask/messenger-cli": "team-core-platform", "metamask/sample-controllers": "team-core-platform", From e9d2c3b30d1f69ea497644b41350fc8753fab331 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 18:23:21 +0200 Subject: [PATCH 08/48] style: apply oxfmt to controller and test files --- .../NetworkConnectionBannerController.test.ts | 17 ++++++++--------- .../src/NetworkConnectionBannerController.ts | 12 +++--------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index d7b7cef9bf..faf4ca2859 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -11,10 +11,7 @@ import type { NetworkConfiguration, NetworkState, } from '@metamask/network-controller'; -import { - NetworkStatus, - RpcEndpointType, -} from '@metamask/network-controller'; +import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; import type { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; import { KnownCaipNamespace } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -52,7 +49,8 @@ function buildCustomEndpoint( } function buildConfiguration( - overrides: Partial & Pick, + overrides: Partial & + Pick, ): NetworkConfiguration { return { name: 'Ethereum Mainnet', @@ -993,7 +991,7 @@ function buildNetworkState({ metadata = {}, enabledChainIds, }: BuildNetworkStateArgs): StubbedState { - const allChainIds = (enabledChainIds ?? (Object.keys(configurations) as Hex[])); + const allChainIds = enabledChainIds ?? (Object.keys(configurations) as Hex[]); return { network: { networkConfigurationsByChainId: configurations, @@ -1024,7 +1022,9 @@ type WithControllerCallback = (payload: { controllerMessenger: NetworkConnectionBannerControllerMessenger; setNetworkState: (state: StubbedState) => void; setNetworkStateSilently: (state: StubbedState) => void; - setConnectivityStatus: (status: ConnectivityControllerState['connectivityStatus']) => void; + setConnectivityStatus: ( + status: ConnectivityControllerState['connectivityStatus'], + ) => void; updateNetwork: jest.Mock; }) => Promise | ReturnValue; @@ -1052,8 +1052,7 @@ async function withController( ); rootMessenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByChainId', - (chainId) => - currentState.network.networkConfigurationsByChainId?.[chainId], + (chainId) => currentState.network.networkConfigurationsByChainId?.[chainId], ); const updateNetwork = jest.fn(async () => undefined); rootMessenger.registerActionHandler( diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index f4cb9fb8c4..b925d5be64 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -272,11 +272,7 @@ export class NetworkConnectionBannerController extends BaseController< * @throws If the chain configuration cannot be found, or if it has no * Infura endpoint to switch to, or if the default is already Infura. */ - async switchToDefaultInfuraRpc({ - chainId, - }: { - chainId: Hex; - }): Promise { + async switchToDefaultInfuraRpc({ chainId }: { chainId: Hex }): Promise { const networkConfiguration = this.messenger.call( 'NetworkController:getNetworkConfigurationByChainId', chainId, @@ -442,8 +438,7 @@ export class NetworkConnectionBannerController extends BaseController< if (!endpointIsInfura) { const otherInfura = rpcEndpoints.find( (endpoint, index) => - index !== defaultRpcEndpointIndex && - isInfuraEndpoint(endpoint.url), + index !== defaultRpcEndpointIndex && isInfuraEndpoint(endpoint.url), ); infuraNetworkClientId = otherInfura?.networkClientId ?? null; } @@ -471,8 +466,7 @@ export class NetworkConnectionBannerController extends BaseController< .map((entry) => entry.domain) .filter((domain): domain is string => domain !== null), ).size; - const areAllEnabledNetworksFailed = - failedNetworks.length === totalEnabled; + const areAllEnabledNetworksFailed = failedNetworks.length === totalEnabled; if ( !firstCustomFailed && From bddf493ac728ece0735a483818bdd93070e84eac Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 18:30:59 +0200 Subject: [PATCH 09/48] docs: add Unreleased link reference for changelog validation --- packages/network-connection-banner-controller/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md index 4506608105..61c20fa087 100644 --- a/packages/network-connection-banner-controller/CHANGELOG.md +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -10,3 +10,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release ([#9041](https://github.com/MetaMask/core/pull/9041)) + +[Unreleased]: https://github.com/MetaMask/core/ From e3c419473bbd170d09fea1299f075551a4d0c7e9 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 8 Jun 2026 18:36:01 +0200 Subject: [PATCH 10/48] refactor: extract repeated reset-banner block into a helper --- .../src/NetworkConnectionBannerController.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index b925d5be64..5603c13eb3 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -254,13 +254,7 @@ export class NetworkConnectionBannerController extends BaseController< * still hold. */ dismissBanner(): void { - this.#clearTimers(); - if (this.state.status !== 'available' || this.state.network !== null) { - this.update((draft) => { - draft.status = 'available'; - draft.network = null; - }); - } + this.#resetBanner(); } /** @@ -312,26 +306,14 @@ export class NetworkConnectionBannerController extends BaseController< 'ConnectivityController:getState', ); if (connectivityStatus === CONNECTIVITY_STATUSES.Offline) { - this.#clearTimers(); - if (this.state.status !== 'available' || this.state.network !== null) { - this.update((draft) => { - draft.status = 'available'; - draft.network = null; - }); - } + this.#resetBanner(); return; } const failed = this.#findFailedNetworkForBanner(); if (!failed) { - this.#clearTimers(); - if (this.state.status !== 'available' || this.state.network !== null) { - this.update((draft) => { - draft.status = 'available'; - draft.network = null; - }); - } + this.#resetBanner(); return; } @@ -378,6 +360,20 @@ export class NetworkConnectionBannerController extends BaseController< }, DEGRADED_BANNER_TIMEOUT_MS); } + /** + * Clears timers and resets banner state to {@link NetworkConnectionBannerStatus|`available`} + * if it isn't there already. + */ + #resetBanner(): void { + this.#clearTimers(); + if (this.state.status !== 'available' || this.state.network !== null) { + this.update((draft) => { + draft.status = 'available'; + draft.network = null; + }); + } + } + #clearTimers(): void { if (this.#degradedTimer !== undefined) { clearTimeout(this.#degradedTimer); From ae79cbca46b6138c702324a1680ff4010651ec4d Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 10 Jun 2026 23:22:44 +0200 Subject: [PATCH 11/48] fix: align network banner state change events --- .../NetworkConnectionBannerController.test.ts | 89 ++++++++++++------- .../src/NetworkConnectionBannerController.ts | 30 +++---- 2 files changed, 70 insertions(+), 49 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index faf4ca2859..34d82852be 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -8,6 +8,8 @@ import type { MessengerEvents, } from '@metamask/messenger'; import type { + BuiltInNetworkClientId, + InfuraRpcEndpoint, NetworkConfiguration, NetworkState, } from '@metamask/network-controller'; @@ -19,21 +21,19 @@ import type { Hex } from '@metamask/utils'; import type { NetworkConnectionBannerControllerMessenger } from './NetworkConnectionBannerController'; import { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; -const INFURA_PROJECT_ID = 'test-infura-project-id'; - -const MAINNET_CLIENT_ID = 'mainnet'; -const SEPOLIA_CLIENT_ID = 'sepolia'; +const MAINNET_CLIENT_ID = 'mainnet' satisfies BuiltInNetworkClientId; +const SEPOLIA_CLIENT_ID = 'sepolia' satisfies BuiltInNetworkClientId; const POLYGON_CUSTOM_CLIENT_ID = 'polygon-custom'; const ALCHEMY_CLIENT_ID = 'eth-alchemy'; function buildInfuraEndpoint( - networkClientId: string, - subdomain: string, -): NetworkConfiguration['rpcEndpoints'][number] { + networkClientId: BuiltInNetworkClientId, + infuraNetworkType: BuiltInNetworkClientId, +): InfuraRpcEndpoint { return { networkClientId, type: RpcEndpointType.Infura, - url: `https://${subdomain}.infura.io/v3/${INFURA_PROJECT_ID}`, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, }; } @@ -121,7 +121,7 @@ describe('NetworkConnectionBannerController', () => { }); }); - describe('rule evaluation on NetworkController:stateChanged', () => { + describe('rule evaluation on NetworkController:stateChange', () => { it('does not show the banner when only one Infura network is failing alongside healthy peers (single-provider blip)', async () => { await withController(({ controller, setNetworkState }) => { setNetworkState( @@ -566,13 +566,13 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: {}, networksMetadata: {}, }, - enablement: { + enablement: buildEnablementState({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: { '0x1': true, }, - } as NetworkEnablementControllerState['enabledNetworkMap'], - }, + }, + }), connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, }); jest.advanceTimersByTime(30_000); @@ -659,9 +659,7 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: {}, networksMetadata: {}, }, - enablement: { - enabledNetworkMap: {}, - } as NetworkEnablementControllerState, + enablement: buildEnablementState(), connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, }); jest.advanceTimersByTime(30_000); @@ -986,6 +984,16 @@ type StubbedState = { connectivity: ConnectivityControllerState; }; +function buildEnablementState( + overrides: Partial = {}, +): NetworkEnablementControllerState { + return { + enabledNetworkMap: {}, + nativeAssetIdentifiers: {}, + ...overrides, + }; +} + function buildNetworkState({ configurations, metadata = {}, @@ -995,21 +1003,26 @@ function buildNetworkState({ return { network: { networkConfigurationsByChainId: configurations, - networksMetadata: metadata as NetworkState['networksMetadata'], + networksMetadata: metadata, }, - enablement: { + enablement: buildEnablementState({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: Object.fromEntries( allChainIds.map((chainId) => [chainId, true]), ), - } as NetworkEnablementControllerState['enabledNetworkMap'], - }, + }, + }), connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online, }, }; } +type AllNetworkConnectionBannerControllerActions = + MessengerActions; +type AllNetworkConnectionBannerControllerEvents = + MessengerEvents; + type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, @@ -1040,9 +1053,7 @@ async function withController( networkConfigurationsByChainId: {}, networksMetadata: {}, }, - enablement: { - enabledNetworkMap: {}, - } as NetworkEnablementControllerState, + enablement: buildEnablementState(), connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, }; @@ -1054,7 +1065,11 @@ async function withController( 'NetworkController:getNetworkConfigurationByChainId', (chainId) => currentState.network.networkConfigurationsByChainId?.[chainId], ); - const updateNetwork = jest.fn(async () => undefined); + const updateNetwork = jest.fn( + async (chainId: Hex): Promise => + currentState.network.networkConfigurationsByChainId?.[chainId] ?? + buildConfiguration({ chainId }), + ); rootMessenger.registerActionHandler( 'NetworkController:updateNetwork', updateNetwork, @@ -1070,13 +1085,18 @@ async function withController( () => currentState.connectivity, ); - const controllerMessenger = new Messenger({ + const messenger = new Messenger< + 'NetworkConnectionBannerController', + AllNetworkConnectionBannerControllerActions, + AllNetworkConnectionBannerControllerEvents, + RootMessenger + >({ namespace: 'NetworkConnectionBannerController', parent: rootMessenger, }); rootMessenger.delegate({ - messenger: controllerMessenger, + messenger, actions: [ 'NetworkController:getState', 'NetworkController:getNetworkConfigurationByChainId', @@ -1085,25 +1105,28 @@ async function withController( 'ConnectivityController:getState', ], events: [ - 'NetworkController:stateChanged', - 'NetworkEnablementController:stateChanged', - 'ConnectivityController:stateChanged', + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'NetworkController:stateChange', + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'NetworkEnablementController:stateChange', + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'ConnectivityController:stateChange', ], }); const controller = new NetworkConnectionBannerController({ - messenger: controllerMessenger, + messenger, }); const setNetworkState = (state: StubbedState): void => { currentState = state; rootMessenger.publish( - 'NetworkController:stateChanged', + 'NetworkController:stateChange', currentState.network as NetworkState, [], ); rootMessenger.publish( - 'NetworkEnablementController:stateChanged', + 'NetworkEnablementController:stateChange', currentState.enablement, [], ); @@ -1124,7 +1147,7 @@ async function withController( connectivity: { connectivityStatus: status }, }; rootMessenger.publish( - 'ConnectivityController:stateChanged', + 'ConnectivityController:stateChange', currentState.connectivity, [], ); @@ -1133,7 +1156,7 @@ async function withController( return await testFunction({ controller, rootMessenger, - controllerMessenger, + controllerMessenger: messenger, setNetworkState, setNetworkStateSilently, setConnectivityStatus, diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 5603c13eb3..05c67c499c 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -7,19 +7,19 @@ import { BaseController } from '@metamask/base-controller'; import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import type { ConnectivityControllerGetStateAction, - ConnectivityControllerState, + ConnectivityControllerStateChangeEvent, } from '@metamask/connectivity-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerGetNetworkConfigurationByChainIdAction, NetworkControllerGetStateAction, NetworkControllerUpdateNetworkAction, - NetworkState, + NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; import { NetworkStatus } from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, - NetworkEnablementControllerState, + NetworkEnablementControllerStateChangeEvent, } from '@metamask/network-enablement-controller'; import type { Hex } from '@metamask/utils'; import { KnownCaipNamespace } from '@metamask/utils'; @@ -32,7 +32,7 @@ import { getDomain } from './url-utils'; * the controller's actions and events and to namespace the controller's state * data when composed with other controllers. */ -export const controllerName = 'NetworkConnectionBannerController'; +const controllerName = 'NetworkConnectionBannerController'; /** * Status the banner can be in. `available` means no banner is shown; the @@ -155,15 +155,9 @@ export type NetworkConnectionBannerControllerEvents = * {@link NetworkConnectionBannerControllerMessenger} subscribes to. */ type AllowedEvents = - | ControllerStateChangedEvent<'NetworkController', NetworkState> - | ControllerStateChangedEvent< - 'NetworkEnablementController', - NetworkEnablementControllerState - > - | ControllerStateChangedEvent< - 'ConnectivityController', - ConnectivityControllerState - >; + | NetworkControllerStateChangeEvent + | NetworkEnablementControllerStateChangeEvent + | ConnectivityControllerStateChangeEvent; /** * The messenger restricted to actions and events accessed by @@ -232,15 +226,19 @@ export class NetworkConnectionBannerController extends BaseController< }); const onStateChange = (): void => this.#evaluate(); - this.messenger.subscribe('NetworkController:stateChanged', onStateChange); + // Upstream controllers still expose :stateChange; switch to :stateChanged + // once those packages migrate their event types. + /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ + this.messenger.subscribe('NetworkController:stateChange', onStateChange); this.messenger.subscribe( - 'NetworkEnablementController:stateChanged', + 'NetworkEnablementController:stateChange', onStateChange, ); this.messenger.subscribe( - 'ConnectivityController:stateChanged', + 'ConnectivityController:stateChange', onStateChange, ); + /* eslint-enable no-restricted-syntax */ this.messenger.registerMethodActionHandlers( this, From 8f4af115b4240fe2b09ee3568a6d334b38edce57 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 00:30:01 +0200 Subject: [PATCH 12/48] fix: preserve pending banner escalation timer --- .../NetworkConnectionBannerController.test.ts | 34 +++++++++++++++++++ .../src/NetworkConnectionBannerController.ts | 12 +++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 34d82852be..20dafd9d36 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -406,6 +406,40 @@ describe('NetworkConnectionBannerController', () => { }); }); + it('does not restart the degraded timer when the same network fails across re-evaluations', async () => { + await withController(({ controller, setNetworkState }) => { + const failingState = buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + setNetworkState(failingState); + jest.advanceTimersByTime(4_000); + + setNetworkState(failingState); + jest.advanceTimersByTime(1_000); + + expect(controller.state.status).toBe('degraded'); + }); + }); + it('cancels the banner if the network recovers between the degraded-timer scheduling and its firing', async () => { await withController(({ controller, setNetworkState }) => { setNetworkState( diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 05c67c499c..5e3865e6a6 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -211,6 +211,8 @@ export class NetworkConnectionBannerController extends BaseController< #unavailableTimer: ReturnType | undefined; + #pendingNetworkClientId: string | undefined; + /** * Constructs a new {@link NetworkConnectionBannerController}. * @@ -315,9 +317,6 @@ export class NetworkConnectionBannerController extends BaseController< return; } - // If the banner is currently showing for a different network or status, - // restart the escalation timeline for the new one. Otherwise let the - // existing timers continue. if ( this.state.status !== 'available' && this.state.network?.networkClientId === failed.networkClientId @@ -328,14 +327,20 @@ export class NetworkConnectionBannerController extends BaseController< return; } + if (this.#pendingNetworkClientId === failed.networkClientId) { + return; + } + this.#clearTimers(); this.update((draft) => { draft.status = 'available'; draft.network = null; }); + this.#pendingNetworkClientId = failed.networkClientId; this.#degradedTimer = setTimeout(() => { this.#degradedTimer = undefined; + this.#pendingNetworkClientId = undefined; const stillFailed = this.#findFailedNetworkForBanner(); if (!stillFailed) { return; @@ -373,6 +378,7 @@ export class NetworkConnectionBannerController extends BaseController< } #clearTimers(): void { + this.#pendingNetworkClientId = undefined; if (this.#degradedTimer !== undefined) { clearTimeout(this.#degradedTimer); this.#degradedTimer = undefined; From 3cb9037ad5275155736f303dfdc1a40ada955dd0 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 00:33:47 +0200 Subject: [PATCH 13/48] feat(network-connection-banner-controller): add selectors Mirrors connectivity-controller: a `networkConnectionBannerControllerSelectors` object with `selectNetworkConnectionBannerStatus`, `selectNetworkConnectionBannerNetwork`, and a derived `selectIsNetworkConnectionBannerVisible`. --- .../package.json | 3 +- .../src/index.ts | 1 + .../src/selectors.test.ts | 98 +++++++++++++++++++ .../src/selectors.ts | 50 ++++++++++ yarn.lock | 1 + 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/network-connection-banner-controller/src/selectors.test.ts create mode 100644 packages/network-connection-banner-controller/src/selectors.ts diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json index 4589cda0cc..67b1716dba 100644 --- a/packages/network-connection-banner-controller/package.json +++ b/packages/network-connection-banner-controller/package.json @@ -60,7 +60,8 @@ "@metamask/network-enablement-controller": "^5.3.0", "@metamask/utils": "^11.9.0", "ip-regex": "^4.3.0", - "psl": "^1.15.0" + "psl": "^1.15.0", + "reselect": "^5.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts index f431d8e5cd..ec16ccc9b1 100644 --- a/packages/network-connection-banner-controller/src/index.ts +++ b/packages/network-connection-banner-controller/src/index.ts @@ -18,3 +18,4 @@ export { getDefaultNetworkConnectionBannerControllerState, } from './NetworkConnectionBannerController'; export { getDomain, isLocalhostOrIPAddress } from './url-utils'; +export { networkConnectionBannerControllerSelectors } from './selectors'; diff --git a/packages/network-connection-banner-controller/src/selectors.test.ts b/packages/network-connection-banner-controller/src/selectors.test.ts new file mode 100644 index 0000000000..cd1aab764f --- /dev/null +++ b/packages/network-connection-banner-controller/src/selectors.test.ts @@ -0,0 +1,98 @@ +import type { + NetworkConnectionBannerControllerState, + NetworkConnectionBannerFailedNetwork, +} from './NetworkConnectionBannerController'; +import { networkConnectionBannerControllerSelectors } from './selectors'; + +const failedNetwork: NetworkConnectionBannerFailedNetwork = { + chainId: '0x1', + networkClientId: 'mainnet', + networkName: 'Ethereum Mainnet', + rpcUrl: 'https://mainnet.infura.io/v3/abc', + isInfuraEndpoint: true, + infuraNetworkClientId: null, +}; + +describe('networkConnectionBannerControllerSelectors', () => { + describe('selectNetworkConnectionBannerStatus', () => { + it.each(['available', 'degraded', 'unavailable'] as const)( + 'returns %s when status is %s', + (status) => { + const state: NetworkConnectionBannerControllerState = { + status, + network: status === 'available' ? null : failedNetwork, + }; + + const result = + networkConnectionBannerControllerSelectors.selectNetworkConnectionBannerStatus( + state, + ); + + expect(result).toBe(status); + }, + ); + }); + + describe('selectNetworkConnectionBannerNetwork', () => { + it('returns null when no banner is shown', () => { + const state: NetworkConnectionBannerControllerState = { + status: 'available', + network: null, + }; + + const result = + networkConnectionBannerControllerSelectors.selectNetworkConnectionBannerNetwork( + state, + ); + + expect(result).toBeNull(); + }); + + it('returns the failing network details when a banner is shown', () => { + const state: NetworkConnectionBannerControllerState = { + status: 'degraded', + network: failedNetwork, + }; + + const result = + networkConnectionBannerControllerSelectors.selectNetworkConnectionBannerNetwork( + state, + ); + + expect(result).toBe(failedNetwork); + }); + }); + + describe('selectIsNetworkConnectionBannerVisible', () => { + it('returns false when status is available', () => { + const state: NetworkConnectionBannerControllerState = { + status: 'available', + network: null, + }; + + const result = + networkConnectionBannerControllerSelectors.selectIsNetworkConnectionBannerVisible( + state, + ); + + expect(result).toBe(false); + }); + + it.each(['degraded', 'unavailable'] as const)( + 'returns true when status is %s', + (status) => { + const state: NetworkConnectionBannerControllerState = { + status, + network: failedNetwork, + }; + + const result = + networkConnectionBannerControllerSelectors.selectIsNetworkConnectionBannerVisible( + state, + ); + + expect(result).toBe(true); + }, + ); + }); +}); diff --git a/packages/network-connection-banner-controller/src/selectors.ts b/packages/network-connection-banner-controller/src/selectors.ts new file mode 100644 index 0000000000..2b86762b72 --- /dev/null +++ b/packages/network-connection-banner-controller/src/selectors.ts @@ -0,0 +1,50 @@ +import { createSelector } from 'reselect'; + +import type { + NetworkConnectionBannerControllerState, + NetworkConnectionBannerFailedNetwork, + NetworkConnectionBannerStatus, +} from './NetworkConnectionBannerController'; + +/** + * Selects the banner status from the controller state. + * + * @param state - The controller state + * @returns The banner status + */ +const selectNetworkConnectionBannerStatus = ( + state: NetworkConnectionBannerControllerState, +): NetworkConnectionBannerStatus => state.status; + +/** + * Selects the failing network the banner describes, or `null` when no banner + * is shown. + * + * @param state - The controller state + * @returns The failing network details, or `null` + */ +const selectNetworkConnectionBannerNetwork = ( + state: NetworkConnectionBannerControllerState, +): NetworkConnectionBannerFailedNetwork | null => state.network; + +/** + * Selects whether the banner is visible (status is `degraded` or + * `unavailable`). + * + * @param state - The controller state + * @returns Whether the banner is visible + */ +const selectIsNetworkConnectionBannerVisible = createSelector( + [selectNetworkConnectionBannerStatus], + (status) => status === 'degraded' || status === 'unavailable', +); + +/** + * Selectors for the NetworkConnectionBannerController state. + * These can be used with Redux or directly with controller state. + */ +export const networkConnectionBannerControllerSelectors = { + selectNetworkConnectionBannerStatus, + selectNetworkConnectionBannerNetwork, + selectIsNetworkConnectionBannerVisible, +}; diff --git a/yarn.lock b/yarn.lock index 915aeff5f8..5ec4085259 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7614,6 +7614,7 @@ __metadata: ip-regex: "npm:^4.3.0" jest: "npm:^29.7.0" psl: "npm:^1.15.0" + reselect: "npm:^5.1.1" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" From 5cab6c5f2585acdb77c48d108c7901a4f7f7b738 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 00:41:04 +0200 Subject: [PATCH 14/48] fix: lint:misc --- .../src/NetworkConnectionBannerController.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 20dafd9d36..7fdd8cad6d 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -424,9 +424,7 @@ describe('NetworkConnectionBannerController', () => { }, enabledChainIds: ['0x89'], metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( - NetworkStatus.Unavailable, - ), + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), }, }); From f313374ea63afadcf9f30a8db0a43886f70c20b6 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 10:53:39 +0200 Subject: [PATCH 15/48] fix: evaluate network banner state on startup --- .../NetworkConnectionBannerController.test.ts | 50 ++++++++++++++++--- .../src/NetworkConnectionBannerController.ts | 2 + 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 7fdd8cad6d..d953421d35 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -119,6 +119,37 @@ describe('NetworkConnectionBannerController', () => { }); }); }); + + it('evaluates existing upstream state on construction', async () => { + const initialState = buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }); + + await withController( + ({ controller }) => { + jest.advanceTimersByTime(5_000); + + expect(controller.state.status).toBe('degraded'); + }, + initialState, + ); + }); }); describe('rule evaluation on NetworkController:stateChange', () => { @@ -1075,19 +1106,22 @@ type WithControllerCallback = (payload: { async function withController( testFunction: WithControllerCallback, + initialState?: StubbedState, ): Promise { const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); - let currentState: StubbedState = { - network: { - networkConfigurationsByChainId: {}, - networksMetadata: {}, - }, - enablement: buildEnablementState(), - connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, - }; + let currentState: StubbedState = + initialState ?? + ({ + network: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + enablement: buildEnablementState(), + connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, + } satisfies StubbedState); rootMessenger.registerActionHandler( 'NetworkController:getState', diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 5e3865e6a6..e71f1aed7f 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -246,6 +246,8 @@ export class NetworkConnectionBannerController extends BaseController< this, MESSENGER_EXPOSED_METHODS, ); + + this.#evaluate(); } /** From ce115cd9a96bf522005b4e0b529f9716c272715a Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 11:24:23 +0200 Subject: [PATCH 16/48] fix: constraints --- packages/network-connection-banner-controller/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json index 67b1716dba..ab2844bfb9 100644 --- a/packages/network-connection-banner-controller/package.json +++ b/packages/network-connection-banner-controller/package.json @@ -58,7 +58,7 @@ "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^32.0.0", "@metamask/network-enablement-controller": "^5.3.0", - "@metamask/utils": "^11.9.0", + "@metamask/utils": "^11.11.0", "ip-regex": "^4.3.0", "psl": "^1.15.0", "reselect": "^5.1.1" diff --git a/yarn.lock b/yarn.lock index 5ec4085259..8fffd7cfd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7607,7 +7607,7 @@ __metadata: "@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" + "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From ea01dbfe5c5f7a2c4707c6b28c84a401e2b8c618 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 11 Jun 2026 11:58:29 +0200 Subject: [PATCH 17/48] fix: ignore missing network status metadata --- .../NetworkConnectionBannerController.test.ts | 46 ++++++++++++++++--- .../src/NetworkConnectionBannerController.ts | 24 +++++----- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index d953421d35..5eeea86983 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -141,14 +141,11 @@ describe('NetworkConnectionBannerController', () => { }, }); - await withController( - ({ controller }) => { - jest.advanceTimersByTime(5_000); + await withController(({ controller }) => { + jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('degraded'); - }, - initialState, - ); + expect(controller.state.status).toBe('degraded'); + }, initialState); }); }); @@ -355,6 +352,41 @@ describe('NetworkConnectionBannerController', () => { }); }); + it('ignores enabled networks with missing metadata when every known network is failing', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + ], + }), + '0xaa36a7': buildConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), + ], + }), + }, + metadata: { + [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + networkClientId: MAINNET_CLIENT_ID, + }); + }); + }); + it('prefers a custom failure over an Infura one when surfacing the banner network', async () => { await withController(({ controller, setNetworkState }) => { setNetworkState( diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index e71f1aed7f..d9daa013ef 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -190,8 +190,8 @@ export type NetworkConnectionBannerControllerOptions = { * - The first failing network's default RPC is a custom (non-Infura) endpoint * — users always want to be informed about errors with RPCs they've chosen. * - Failed RPCs span more than one registrable domain (likely client-side). - * - Every enabled EVM network is failing (escape hatch for single-network - * setups so they still get a signal). + * - Every enabled EVM network with known connectivity status is failing + * (escape hatch for single-network setups so they still get a signal). * * A wide single-provider outage (e.g. every `*.infura.io` network goes down at * once) collapses to one domain and is suppressed, except in the all-down @@ -407,7 +407,7 @@ export class NetworkConnectionBannerController extends BaseController< domain: string | null; }; const failedNetworks: EnrichedFailedNetwork[] = []; - let totalEnabled = 0; + let totalNetworksWithMetadata = 0; for (const chainId of enabledEvmChainIds) { const networkConfiguration = networkConfigurationsByChainId[chainId]; @@ -422,13 +422,14 @@ export class NetworkConnectionBannerController extends BaseController< continue; } - totalEnabled += 1; - const metadata = networksMetadata[defaultRpcEndpoint.networkClientId]; - if ( - metadata === undefined || - metadata.status === NetworkStatus.Available - ) { + if (metadata === undefined) { + continue; + } + + totalNetworksWithMetadata += 1; + + if (metadata.status === NetworkStatus.Available) { continue; } @@ -468,12 +469,13 @@ export class NetworkConnectionBannerController extends BaseController< .map((entry) => entry.domain) .filter((domain): domain is string => domain !== null), ).size; - const areAllEnabledNetworksFailed = failedNetworks.length === totalEnabled; + const areAllKnownNetworksFailed = + failedNetworks.length === totalNetworksWithMetadata; if ( !firstCustomFailed && distinctDomains <= 1 && - !areAllEnabledNetworksFailed + !areAllKnownNetworksFailed ) { return null; } From fbc5d242c1df2e99f1552a5e3964ae45115125c4 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 12 Jun 2026 13:44:56 +0200 Subject: [PATCH 18/48] fix: defer network banner evaluation until init --- .../CHANGELOG.md | 3 + .../README.md | 13 ++++ ...ionBannerController-method-action-types.ts | 12 +++ .../NetworkConnectionBannerController.test.ts | 74 ++++++++++++++++++- .../src/NetworkConnectionBannerController.ts | 22 +++++- .../src/index.ts | 1 + 6 files changed, 122 insertions(+), 3 deletions(-) diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md index 61c20fa087..67c0b81683 100644 --- a/packages/network-connection-banner-controller/CHANGELOG.md +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -10,5 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release ([#9041](https://github.com/MetaMask/core/pull/9041)) +- Add an explicit, idempotent `init()` lifecycle method that starts banner + evaluation after dependent controllers are ready + ([#9041](https://github.com/MetaMask/core/pull/9041)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/network-connection-banner-controller/README.md b/packages/network-connection-banner-controller/README.md index 7db31b8d81..3c0872ecc7 100644 --- a/packages/network-connection-banner-controller/README.md +++ b/packages/network-connection-banner-controller/README.md @@ -5,6 +5,19 @@ connection banner based on RPC endpoint health. It encapsulates the shared rule, the 5s/30s timer state machine, and the eTLD+1 grouping previously duplicated across MetaMask clients. +## Initialization + +After constructing the controller, call `init()` only after the +`NetworkController`, `NetworkEnablementController`, and +`ConnectivityController` have initialized. Until then, upstream state changes +are ignored and no banner timers run. + +```typescript +networkConnectionBannerController.init(); +``` + +`init()` is idempotent and immediately evaluates the latest upstream state. + ## Installation `yarn add @metamask/network-connection-banner-controller` diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts index 0e3b76ca1c..8d3f5fa784 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -5,6 +5,17 @@ import type { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; +/** + * Starts evaluating network connection state. + * + * This method should be called after the upstream network, network + * enablement, and connectivity controllers have been initialized. + */ +export type NetworkConnectionBannerControllerInitAction = { + type: `NetworkConnectionBannerController:init`; + handler: NetworkConnectionBannerController['init']; +}; + /** * Clears the banner state regardless of the current rule outcome. The next * subscription-driven evaluation will re-show the banner if the conditions @@ -33,5 +44,6 @@ export type NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction = { * Union of all NetworkConnectionBannerController action types. */ export type NetworkConnectionBannerControllerMethodActions = + | NetworkConnectionBannerControllerInitAction | NetworkConnectionBannerControllerDismissBannerAction | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction; diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 5eeea86983..14969d1326 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -120,7 +120,7 @@ describe('NetworkConnectionBannerController', () => { }); }); - it('evaluates existing upstream state on construction', async () => { + it('does not evaluate existing upstream state before initialization', async () => { const initialState = buildNetworkState({ configurations: { '0x89': buildConfiguration({ @@ -142,10 +142,76 @@ describe('NetworkConnectionBannerController', () => { }); await withController(({ controller }) => { + jest.advanceTimersByTime(30_000); + + expect(controller.state.status).toBe('available'); + }, initialState, false); + }); + + it('evaluates existing upstream state on initialization', async () => { + const initialState = buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }); + + await withController(({ controller, rootMessenger }) => { + rootMessenger.call('NetworkConnectionBannerController:init'); + rootMessenger.call('NetworkConnectionBannerController:init'); + jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); - }, initialState); + }, initialState, false); + }); + + it('ignores upstream state changes before initialization', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + + controller.init(); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, undefined, false); }); }); @@ -1139,6 +1205,7 @@ type WithControllerCallback = (payload: { async function withController( testFunction: WithControllerCallback, initialState?: StubbedState, + initialize = true, ): Promise { const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -1215,6 +1282,9 @@ async function withController( const controller = new NetworkConnectionBannerController({ messenger, }); + if (initialize) { + controller.init(); + } const setNetworkState = (state: StubbedState): void => { currentState = state; diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index d9daa013ef..ab8151c3a5 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -101,6 +101,7 @@ const DEGRADED_BANNER_TIMEOUT_MS = 5_000; const UNAVAILABLE_BANNER_TIMEOUT_MS = 30_000; const MESSENGER_EXPOSED_METHODS = [ + 'init', 'dismissBanner', 'switchToDefaultInfuraRpc', ] as const; @@ -213,6 +214,8 @@ export class NetworkConnectionBannerController extends BaseController< #pendingNetworkClientId: string | undefined; + #initialized = false; + /** * Constructs a new {@link NetworkConnectionBannerController}. * @@ -227,7 +230,11 @@ export class NetworkConnectionBannerController extends BaseController< state: getDefaultNetworkConnectionBannerControllerState(), }); - const onStateChange = (): void => this.#evaluate(); + const onStateChange = (): void => { + if (this.#initialized) { + this.#evaluate(); + } + }; // Upstream controllers still expose :stateChange; switch to :stateChanged // once those packages migrate their event types. /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ @@ -246,8 +253,21 @@ export class NetworkConnectionBannerController extends BaseController< this, MESSENGER_EXPOSED_METHODS, ); + } + + /** + * Starts evaluating network connection state. + * + * This method should be called after the upstream network, network + * enablement, and connectivity controllers have been initialized. + */ + init(): void { + if (this.#initialized) { + return; + } this.#evaluate(); + this.#initialized = true; } /** diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts index ec16ccc9b1..1dd0d4aa02 100644 --- a/packages/network-connection-banner-controller/src/index.ts +++ b/packages/network-connection-banner-controller/src/index.ts @@ -10,6 +10,7 @@ export type { NetworkConnectionBannerStatus, } from './NetworkConnectionBannerController'; export type { + NetworkConnectionBannerControllerInitAction, NetworkConnectionBannerControllerDismissBannerAction, NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction, } from './NetworkConnectionBannerController-method-action-types'; From ba2e2094481146ed83ef56cf0534628dcb6ad8bc Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 12 Jun 2026 13:52:22 +0200 Subject: [PATCH 19/48] docs: consolidate network banner changelog --- packages/network-connection-banner-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md index 67c0b81683..9a5192e530 100644 --- a/packages/network-connection-banner-controller/CHANGELOG.md +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release ([#9041](https://github.com/MetaMask/core/pull/9041)) -- Add an explicit, idempotent `init()` lifecycle method that starts banner - evaluation after dependent controllers are ready +- Add `NetworkConnectionBannerController`, which evaluates enabled network RPC + health after initialization and manages degraded and unavailable banner state, + dismissal, and switching custom RPC endpoints to an available Infura endpoint ([#9041](https://github.com/MetaMask/core/pull/9041)) [Unreleased]: https://github.com/MetaMask/core/ From d4f5ab64c576c52df4c3e10a82fa2aedda539f2f Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 12 Jun 2026 14:14:48 +0200 Subject: [PATCH 20/48] fix: clear recovered network banner on escalation --- .../NetworkConnectionBannerController.test.ts | 99 +++++++++++-------- .../src/NetworkConnectionBannerController.ts | 1 + 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 14969d1326..f12cba0490 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -141,11 +141,15 @@ describe('NetworkConnectionBannerController', () => { }, }); - await withController(({ controller }) => { - jest.advanceTimersByTime(30_000); + await withController( + ({ controller }) => { + jest.advanceTimersByTime(30_000); - expect(controller.state.status).toBe('available'); - }, initialState, false); + expect(controller.state.status).toBe('available'); + }, + initialState, + false, + ); }); it('evaluates existing upstream state on initialization', async () => { @@ -169,49 +173,57 @@ describe('NetworkConnectionBannerController', () => { }, }); - await withController(({ controller, rootMessenger }) => { - rootMessenger.call('NetworkConnectionBannerController:init'); - rootMessenger.call('NetworkConnectionBannerController:init'); + await withController( + ({ controller, rootMessenger }) => { + rootMessenger.call('NetworkConnectionBannerController:init'); + rootMessenger.call('NetworkConnectionBannerController:init'); - jest.advanceTimersByTime(5_000); + jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('degraded'); - }, initialState, false); + expect(controller.state.status).toBe('degraded'); + }, + initialState, + false, + ); }); it('ignores upstream state changes before initialization', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ - chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', - rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), - ], - }), - }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( - NetworkStatus.Unavailable, - ), - }, - }), - ); + await withController( + ({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); - jest.advanceTimersByTime(30_000); - expect(controller.state.status).toBe('available'); + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); - controller.init(); - jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('degraded'); - }, undefined, false); + controller.init(); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, + undefined, + false, + ); }); }); @@ -671,7 +683,7 @@ describe('NetworkConnectionBannerController', () => { ); }); - it('bails out at the unavailable timer if the underlying state has silently recovered', async () => { + it('clears the banner at the unavailable timer if the underlying state has silently recovered', async () => { await withController( ({ controller, setNetworkState, setNetworkStateSilently }) => { const config = buildConfiguration({ @@ -715,7 +727,10 @@ describe('NetworkConnectionBannerController', () => { ); jest.advanceTimersByTime(25_000); - expect(controller.state.status).toBe('degraded'); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); }, ); }); diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index ab8151c3a5..c2adb8c9ee 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -375,6 +375,7 @@ export class NetworkConnectionBannerController extends BaseController< this.#unavailableTimer = undefined; const stillFailedAtEscalation = this.#findFailedNetworkForBanner(); if (!stillFailedAtEscalation) { + this.#resetBanner(); return; } this.update((draft) => { From 14ccdd3074409e55dc745634f3708c65ab47c010 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 15:46:53 +0200 Subject: [PATCH 21/48] refactor: address review nits on banner controller Batches ten small review comments from #9041: - rename #evaluate to #refreshState - rename #findFailedNetworkForBanner to #findFailedNetwork - rename module-scope isInfuraEndpoint fn to getIsInfuraEndpoint and free the boolean-noun for the local variable - drop _MS suffix on the two timeout constants and document the unit in JSDoc instead - rename local `failed` to `failedNetwork` for readability - use `state` (not `draft`) in this.update callbacks per convention - `if (!metadata)` for consistency with other guards - move `#pendingNetworkClientId = undefined` out of #clearTimers into #resetBanner since it isn't timer-related - trim JSDoc on dismissBanner and getIsInfuraEndpoint --- .../src/NetworkConnectionBannerController.ts | 89 ++++++++++--------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index c2adb8c9ee..92101b0809 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -97,8 +97,17 @@ export function getDefaultNetworkConnectionBannerControllerState(): NetworkConne }; } -const DEGRADED_BANNER_TIMEOUT_MS = 5_000; -const UNAVAILABLE_BANNER_TIMEOUT_MS = 30_000; +/** + * How long (in milliseconds) a failing network must remain in a "failed" + * status ("degraded" or "unavailable") before the degraded banner appears. + */ +const DEGRADED_BANNER_TIMEOUT = 5_000; + +/** + * How long (in milliseconds) a failing network must remain in a "failed" + * status before the banner escalates to "unavailable". + */ +const UNAVAILABLE_BANNER_TIMEOUT = 30_000; const MESSENGER_EXPOSED_METHODS = [ 'init', @@ -232,7 +241,7 @@ export class NetworkConnectionBannerController extends BaseController< const onStateChange = (): void => { if (this.#initialized) { - this.#evaluate(); + this.#refreshState(); } }; // Upstream controllers still expose :stateChange; switch to :stateChanged @@ -266,14 +275,12 @@ export class NetworkConnectionBannerController extends BaseController< return; } - this.#evaluate(); + this.#refreshState(); this.#initialized = true; } /** - * Clears the banner state regardless of the current rule outcome. The next - * subscription-driven evaluation will re-show the banner if the conditions - * still hold. + * Clears the banner state such that the banner will be hidden. */ dismissBanner(): void { this.#resetBanner(); @@ -300,7 +307,7 @@ export class NetworkConnectionBannerController extends BaseController< } const infuraEndpointIndex = networkConfiguration.rpcEndpoints.findIndex( - (endpoint) => isInfuraEndpoint(endpoint.url), + (endpoint) => getIsInfuraEndpoint(endpoint.url), ); if (infuraEndpointIndex === -1) { throw new Error( @@ -323,7 +330,7 @@ export class NetworkConnectionBannerController extends BaseController< ); } - #evaluate(): void { + #refreshState(): void { const { connectivityStatus } = this.messenger.call( 'ConnectivityController:getState', ); @@ -332,58 +339,58 @@ export class NetworkConnectionBannerController extends BaseController< return; } - const failed = this.#findFailedNetworkForBanner(); + const failedNetwork = this.#findFailedNetwork(); - if (!failed) { + if (!failedNetwork) { this.#resetBanner(); return; } if ( this.state.status !== 'available' && - this.state.network?.networkClientId === failed.networkClientId + this.state.network?.networkClientId === failedNetwork.networkClientId ) { - this.update((draft) => { - draft.network = failed; + this.update((state) => { + state.network = failedNetwork; }); return; } - if (this.#pendingNetworkClientId === failed.networkClientId) { + if (this.#pendingNetworkClientId === failedNetwork.networkClientId) { return; } this.#clearTimers(); - this.update((draft) => { - draft.status = 'available'; - draft.network = null; + this.update((state) => { + state.status = 'available'; + state.network = null; }); - this.#pendingNetworkClientId = failed.networkClientId; + this.#pendingNetworkClientId = failedNetwork.networkClientId; this.#degradedTimer = setTimeout(() => { this.#degradedTimer = undefined; this.#pendingNetworkClientId = undefined; - const stillFailed = this.#findFailedNetworkForBanner(); + const stillFailed = this.#findFailedNetwork(); if (!stillFailed) { return; } - this.update((draft) => { - draft.status = 'degraded'; - draft.network = stillFailed; + this.update((state) => { + state.status = 'degraded'; + state.network = stillFailed; }); this.#unavailableTimer = setTimeout(() => { this.#unavailableTimer = undefined; - const stillFailedAtEscalation = this.#findFailedNetworkForBanner(); + const stillFailedAtEscalation = this.#findFailedNetwork(); if (!stillFailedAtEscalation) { this.#resetBanner(); return; } - this.update((draft) => { - draft.status = 'unavailable'; - draft.network = stillFailedAtEscalation; + this.update((state) => { + state.status = 'unavailable'; + state.network = stillFailedAtEscalation; }); - }, UNAVAILABLE_BANNER_TIMEOUT_MS - DEGRADED_BANNER_TIMEOUT_MS); - }, DEGRADED_BANNER_TIMEOUT_MS); + }, UNAVAILABLE_BANNER_TIMEOUT - DEGRADED_BANNER_TIMEOUT); + }, DEGRADED_BANNER_TIMEOUT); } /** @@ -392,16 +399,16 @@ export class NetworkConnectionBannerController extends BaseController< */ #resetBanner(): void { this.#clearTimers(); + this.#pendingNetworkClientId = undefined; if (this.state.status !== 'available' || this.state.network !== null) { - this.update((draft) => { - draft.status = 'available'; - draft.network = null; + this.update((state) => { + state.status = 'available'; + state.network = null; }); } } #clearTimers(): void { - this.#pendingNetworkClientId = undefined; if (this.#degradedTimer !== undefined) { clearTimeout(this.#degradedTimer); this.#degradedTimer = undefined; @@ -412,7 +419,7 @@ export class NetworkConnectionBannerController extends BaseController< } } - #findFailedNetworkForBanner(): NetworkConnectionBannerFailedNetwork | null { + #findFailedNetwork(): NetworkConnectionBannerFailedNetwork | null { const { enabledNetworkMap } = this.messenger.call( 'NetworkEnablementController:getState', ); @@ -444,7 +451,7 @@ export class NetworkConnectionBannerController extends BaseController< } const metadata = networksMetadata[defaultRpcEndpoint.networkClientId]; - if (metadata === undefined) { + if (!metadata) { continue; } @@ -454,15 +461,15 @@ export class NetworkConnectionBannerController extends BaseController< continue; } - const endpointIsInfura = isInfuraEndpoint(defaultRpcEndpoint.url); + const isInfuraEndpoint = getIsInfuraEndpoint(defaultRpcEndpoint.url); // For custom endpoints (non-Infura), find an Infura endpoint on this // chain that we could offer to switch to. let infuraNetworkClientId: string | null = null; - if (!endpointIsInfura) { + if (!isInfuraEndpoint) { const otherInfura = rpcEndpoints.find( (endpoint, index) => - index !== defaultRpcEndpointIndex && isInfuraEndpoint(endpoint.url), + index !== defaultRpcEndpointIndex && getIsInfuraEndpoint(endpoint.url), ); infuraNetworkClientId = otherInfura?.networkClientId ?? null; } @@ -472,7 +479,7 @@ export class NetworkConnectionBannerController extends BaseController< networkClientId: defaultRpcEndpoint.networkClientId, networkName: name, rpcUrl: defaultRpcEndpoint.url, - isInfuraEndpoint: endpointIsInfura, + isInfuraEndpoint: isInfuraEndpoint, infuraNetworkClientId, domain: getDomain(defaultRpcEndpoint.url), }); @@ -516,12 +523,12 @@ export class NetworkConnectionBannerController extends BaseController< /** * Checks if an RPC URL is hosted on the Infura service. Detection is by * hostname suffix rather than the exact MetaMask-key URL pattern, so any - * `*.infura.io` URL counts. That's the right shape for grouping by provider. + * `*.infura.io` URL counts. * * @param url - The RPC URL to check. * @returns True if the URL's host is on `infura.io`. */ -function isInfuraEndpoint(url: string): boolean { +function getIsInfuraEndpoint(url: string): boolean { try { return new URL(url).hostname.toLowerCase().endsWith('.infura.io'); } catch { From 3bdeda1a94e8226f975000b0eba4c6c13d5f75ff Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 16:53:41 +0200 Subject: [PATCH 22/48] refactor!: simplify banner controller public API - Rename `NetworkConnectionBannerFailedNetwork` to `FailedNetwork` - Rename property `networkName` to `name` - Rename property `infuraNetworkClientId` to `switchableInfuraNetworkClientId` - Add `domain` (registrable domain of `rpcUrl`) to `FailedNetwork`, which removes the internal `EnrichedFailedNetwork` intermediate type and collapses the picker to a direct return - Move property docs onto each property; drop the state level nullability note from the type doc --- .../NetworkConnectionBannerController.test.ts | 2 +- .../src/NetworkConnectionBannerController.ts | 53 ++++++++----------- .../src/index.ts | 2 +- .../src/selectors.test.ts | 9 ++-- .../src/selectors.ts | 4 +- 5 files changed, 32 insertions(+), 38 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index f12cba0490..ac49fe9e29 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -894,7 +894,7 @@ describe('NetworkConnectionBannerController', () => { expect(controller.state.network).toMatchObject({ chainId: '0x1', isInfuraEndpoint: false, - infuraNetworkClientId: MAINNET_CLIENT_ID, + switchableInfuraNetworkClientId: MAINNET_CLIENT_ID, // Sanity-check: not null when there's an Infura endpoint to offer. }); }); diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 92101b0809..194d64de2d 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -45,21 +45,25 @@ export type NetworkConnectionBannerStatus = | 'unavailable'; /** - * Details of the failing network the banner should describe. Populated when - * {@link NetworkConnectionBannerControllerState.status} is `degraded` or - * `unavailable`, `null` otherwise. - * - * `infuraNetworkClientId` is the networkClientId of an Infura endpoint on the - * same chain that the user can switch to. `null` when the failing endpoint is - * already Infura, or when no Infura alternative exists. + * Details of a failing network the banner describes. */ -export type NetworkConnectionBannerFailedNetwork = { +export type FailedNetwork = { chainId: Hex; networkClientId: string; - networkName: string; + name: string; rpcUrl: string; isInfuraEndpoint: boolean; - infuraNetworkClientId: string | null; + /** + * The networkClientId of an Infura endpoint on the same chain that the user + * can switch to. `null` when the failing endpoint is already Infura or when + * no Infura alternative exists. + */ + switchableInfuraNetworkClientId: string | null; + /** + * The registrable domain (eTLD+1) of `rpcUrl`, used to group endpoints by + * provider. `null` when the URL is invalid. + */ + domain: string | null; }; /** @@ -67,7 +71,7 @@ export type NetworkConnectionBannerFailedNetwork = { */ export type NetworkConnectionBannerControllerState = { status: NetworkConnectionBannerStatus; - network: NetworkConnectionBannerFailedNetwork | null; + network: FailedNetwork | null; }; const networkConnectionBannerControllerMetadata = { @@ -419,7 +423,7 @@ export class NetworkConnectionBannerController extends BaseController< } } - #findFailedNetwork(): NetworkConnectionBannerFailedNetwork | null { + #findFailedNetwork(): FailedNetwork | null { const { enabledNetworkMap } = this.messenger.call( 'NetworkEnablementController:getState', ); @@ -431,10 +435,7 @@ export class NetworkConnectionBannerController extends BaseController< const { networkConfigurationsByChainId, networksMetadata } = this.messenger.call('NetworkController:getState'); - type EnrichedFailedNetwork = NetworkConnectionBannerFailedNetwork & { - domain: string | null; - }; - const failedNetworks: EnrichedFailedNetwork[] = []; + const failedNetworks: FailedNetwork[] = []; let totalNetworksWithMetadata = 0; for (const chainId of enabledEvmChainIds) { @@ -465,22 +466,22 @@ export class NetworkConnectionBannerController extends BaseController< // For custom endpoints (non-Infura), find an Infura endpoint on this // chain that we could offer to switch to. - let infuraNetworkClientId: string | null = null; + let switchableInfuraNetworkClientId: string | null = null; if (!isInfuraEndpoint) { const otherInfura = rpcEndpoints.find( (endpoint, index) => index !== defaultRpcEndpointIndex && getIsInfuraEndpoint(endpoint.url), ); - infuraNetworkClientId = otherInfura?.networkClientId ?? null; + switchableInfuraNetworkClientId = otherInfura?.networkClientId ?? null; } failedNetworks.push({ chainId, networkClientId: defaultRpcEndpoint.networkClientId, - networkName: name, + name, rpcUrl: defaultRpcEndpoint.url, - isInfuraEndpoint: isInfuraEndpoint, - infuraNetworkClientId, + isInfuraEndpoint, + switchableInfuraNetworkClientId, domain: getDomain(defaultRpcEndpoint.url), }); } @@ -508,15 +509,7 @@ export class NetworkConnectionBannerController extends BaseController< return null; } - const selected = firstCustomFailed ?? failedNetworks[0]; - return { - chainId: selected.chainId, - networkClientId: selected.networkClientId, - networkName: selected.networkName, - rpcUrl: selected.rpcUrl, - isInfuraEndpoint: selected.isInfuraEndpoint, - infuraNetworkClientId: selected.infuraNetworkClientId, - }; + return firstCustomFailed ?? failedNetworks[0]; } } diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts index 1dd0d4aa02..a00dc03eda 100644 --- a/packages/network-connection-banner-controller/src/index.ts +++ b/packages/network-connection-banner-controller/src/index.ts @@ -6,7 +6,7 @@ export type { NetworkConnectionBannerControllerEvents, NetworkConnectionBannerControllerMessenger, NetworkConnectionBannerControllerOptions, - NetworkConnectionBannerFailedNetwork, + FailedNetwork, NetworkConnectionBannerStatus, } from './NetworkConnectionBannerController'; export type { diff --git a/packages/network-connection-banner-controller/src/selectors.test.ts b/packages/network-connection-banner-controller/src/selectors.test.ts index cd1aab764f..d01ef3afaa 100644 --- a/packages/network-connection-banner-controller/src/selectors.test.ts +++ b/packages/network-connection-banner-controller/src/selectors.test.ts @@ -1,16 +1,17 @@ import type { NetworkConnectionBannerControllerState, - NetworkConnectionBannerFailedNetwork, + FailedNetwork, } from './NetworkConnectionBannerController'; import { networkConnectionBannerControllerSelectors } from './selectors'; -const failedNetwork: NetworkConnectionBannerFailedNetwork = { +const failedNetwork: FailedNetwork = { chainId: '0x1', networkClientId: 'mainnet', - networkName: 'Ethereum Mainnet', + name: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/abc', isInfuraEndpoint: true, - infuraNetworkClientId: null, + switchableInfuraNetworkClientId: null, + domain: 'infura.io', }; describe('networkConnectionBannerControllerSelectors', () => { diff --git a/packages/network-connection-banner-controller/src/selectors.ts b/packages/network-connection-banner-controller/src/selectors.ts index 2b86762b72..debcc1f4b3 100644 --- a/packages/network-connection-banner-controller/src/selectors.ts +++ b/packages/network-connection-banner-controller/src/selectors.ts @@ -2,7 +2,7 @@ import { createSelector } from 'reselect'; import type { NetworkConnectionBannerControllerState, - NetworkConnectionBannerFailedNetwork, + FailedNetwork, NetworkConnectionBannerStatus, } from './NetworkConnectionBannerController'; @@ -25,7 +25,7 @@ const selectNetworkConnectionBannerStatus = ( */ const selectNetworkConnectionBannerNetwork = ( state: NetworkConnectionBannerControllerState, -): NetworkConnectionBannerFailedNetwork | null => state.network; +): FailedNetwork | null => state.network; /** * Selects whether the banner is visible (status is `degraded` or From e9d630c454b4ac2fca83f15fec2002ca7d5b3566 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:20:07 +0200 Subject: [PATCH 23/48] refactor: stop exporting url helpers from package entry point `getDomain` and `isLocalhostOrIPAddress` are now purely file-internal. Consumers can read the registrable domain directly off `FailedNetwork`. --- packages/network-connection-banner-controller/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts index a00dc03eda..e7d5c06ba1 100644 --- a/packages/network-connection-banner-controller/src/index.ts +++ b/packages/network-connection-banner-controller/src/index.ts @@ -18,5 +18,4 @@ export { NetworkConnectionBannerController, getDefaultNetworkConnectionBannerControllerState, } from './NetworkConnectionBannerController'; -export { getDomain, isLocalhostOrIPAddress } from './url-utils'; export { networkConnectionBannerControllerSelectors } from './selectors'; From c0bf31acbad0ece87ea7cb6d40d8139a324d03b2 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:27:19 +0200 Subject: [PATCH 24/48] refactor: move psl ambient shim to shared types dir Matches the convention used by other monorepo shims (ethjs-query.d.ts, etc). No tsconfig changes needed since `../../types` is already in the `include` list. --- .../network-connection-banner-controller/src => types}/psl.d.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {packages/network-connection-banner-controller/src => types}/psl.d.ts (100%) diff --git a/packages/network-connection-banner-controller/src/psl.d.ts b/types/psl.d.ts similarity index 100% rename from packages/network-connection-banner-controller/src/psl.d.ts rename to types/psl.d.ts From 9852e44b8d93fc0a7fe59d4d9c8a0ca2cd5cd57b Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:32:54 +0200 Subject: [PATCH 25/48] refactor: match Infura endpoints by type instead of URL suffix Use `RpcEndpointType.Infura` from network-controller (a strongly typed signal set by the endpoint's classifier) instead of matching any `*.infura.io` URL. Removes `getIsInfuraEndpoint` and its hostname parse. --- .../src/NetworkConnectionBannerController.ts | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 194d64de2d..8dab2844b3 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -16,7 +16,7 @@ import type { NetworkControllerUpdateNetworkAction, NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; -import { NetworkStatus } from '@metamask/network-controller'; +import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, NetworkEnablementControllerStateChangeEvent, @@ -311,7 +311,7 @@ export class NetworkConnectionBannerController extends BaseController< } const infuraEndpointIndex = networkConfiguration.rpcEndpoints.findIndex( - (endpoint) => getIsInfuraEndpoint(endpoint.url), + (endpoint) => endpoint.type === RpcEndpointType.Infura, ); if (infuraEndpointIndex === -1) { throw new Error( @@ -462,7 +462,8 @@ export class NetworkConnectionBannerController extends BaseController< continue; } - const isInfuraEndpoint = getIsInfuraEndpoint(defaultRpcEndpoint.url); + const isInfuraEndpoint = + defaultRpcEndpoint.type === RpcEndpointType.Infura; // For custom endpoints (non-Infura), find an Infura endpoint on this // chain that we could offer to switch to. @@ -470,7 +471,8 @@ export class NetworkConnectionBannerController extends BaseController< if (!isInfuraEndpoint) { const otherInfura = rpcEndpoints.find( (endpoint, index) => - index !== defaultRpcEndpointIndex && getIsInfuraEndpoint(endpoint.url), + index !== defaultRpcEndpointIndex && + endpoint.type === RpcEndpointType.Infura, ); switchableInfuraNetworkClientId = otherInfura?.networkClientId ?? null; } @@ -512,19 +514,3 @@ export class NetworkConnectionBannerController extends BaseController< return firstCustomFailed ?? failedNetworks[0]; } } - -/** - * Checks if an RPC URL is hosted on the Infura service. Detection is by - * hostname suffix rather than the exact MetaMask-key URL pattern, so any - * `*.infura.io` URL counts. - * - * @param url - The RPC URL to check. - * @returns True if the URL's host is on `infura.io`. - */ -function getIsInfuraEndpoint(url: string): boolean { - try { - return new URL(url).hostname.toLowerCase().endsWith('.infura.io'); - } catch { - return false; - } -} From b258d155156d66a2c040dc8f55affa17c4208fe5 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:34:06 +0200 Subject: [PATCH 26/48] refactor: split #findFailedNetwork into two passes First pass collects enabled networks that have a default rpc endpoint and metadata; second pass filters failing ones and enriches into FailedNetwork. Drops the manual `totalNetworksWithMetadata` counter in favor of `networksWithMetadata.length`. --- .../src/NetworkConnectionBannerController.ts | 82 ++++++++++--------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 8dab2844b3..72eddeb339 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -435,58 +435,62 @@ export class NetworkConnectionBannerController extends BaseController< const { networkConfigurationsByChainId, networksMetadata } = this.messenger.call('NetworkController:getState'); - const failedNetworks: FailedNetwork[] = []; - let totalNetworksWithMetadata = 0; - - for (const chainId of enabledEvmChainIds) { + const networksWithMetadata = enabledEvmChainIds.flatMap((chainId) => { const networkConfiguration = networkConfigurationsByChainId[chainId]; if (!networkConfiguration) { - continue; + return []; } - const { rpcEndpoints, defaultRpcEndpointIndex, name } = networkConfiguration; const defaultRpcEndpoint = rpcEndpoints[defaultRpcEndpointIndex]; if (!defaultRpcEndpoint) { - continue; + return []; } - const metadata = networksMetadata[defaultRpcEndpoint.networkClientId]; if (!metadata) { - continue; - } - - totalNetworksWithMetadata += 1; - - if (metadata.status === NetworkStatus.Available) { - continue; + return []; } + return [ + { + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + defaultRpcEndpoint, + metadata, + }, + ]; + }); - const isInfuraEndpoint = - defaultRpcEndpoint.type === RpcEndpointType.Infura; - - // For custom endpoints (non-Infura), find an Infura endpoint on this - // chain that we could offer to switch to. - let switchableInfuraNetworkClientId: string | null = null; - if (!isInfuraEndpoint) { - const otherInfura = rpcEndpoints.find( - (endpoint, index) => - index !== defaultRpcEndpointIndex && - endpoint.type === RpcEndpointType.Infura, - ); - switchableInfuraNetworkClientId = otherInfura?.networkClientId ?? null; - } + const failedNetworks: FailedNetwork[] = networksWithMetadata + .filter(({ metadata }) => metadata.status !== NetworkStatus.Available) + .map(({ chainId, name, rpcEndpoints, defaultRpcEndpointIndex, defaultRpcEndpoint }) => { + const isInfuraEndpoint = + defaultRpcEndpoint.type === RpcEndpointType.Infura; + + // For custom endpoints (non-Infura), find an Infura endpoint on this + // chain that we could offer to switch to. + let switchableInfuraNetworkClientId: string | null = null; + if (!isInfuraEndpoint) { + const otherInfura = rpcEndpoints.find( + (endpoint, index) => + index !== defaultRpcEndpointIndex && + endpoint.type === RpcEndpointType.Infura, + ); + switchableInfuraNetworkClientId = + otherInfura?.networkClientId ?? null; + } - failedNetworks.push({ - chainId, - networkClientId: defaultRpcEndpoint.networkClientId, - name, - rpcUrl: defaultRpcEndpoint.url, - isInfuraEndpoint, - switchableInfuraNetworkClientId, - domain: getDomain(defaultRpcEndpoint.url), + return { + chainId, + networkClientId: defaultRpcEndpoint.networkClientId, + name, + rpcUrl: defaultRpcEndpoint.url, + isInfuraEndpoint, + switchableInfuraNetworkClientId, + domain: getDomain(defaultRpcEndpoint.url), + }; }); - } if (failedNetworks.length === 0) { return null; @@ -501,7 +505,7 @@ export class NetworkConnectionBannerController extends BaseController< .filter((domain): domain is string => domain !== null), ).size; const areAllKnownNetworksFailed = - failedNetworks.length === totalNetworksWithMetadata; + failedNetworks.length === networksWithMetadata.length; if ( !firstCustomFailed && From fc5d9811c0302419f62312dd93cb578ab6838950 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:36:05 +0200 Subject: [PATCH 27/48] refactor: decompose #findFailedNetwork into focused helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted: - `#getEnabledEvmChainIds` — reads enabled EIP-155 chain ids - `#collectNetworksWithMetadata` — first pass, joins config and metadata - `#buildFailedNetwork` — enrichment for a single failing network - `#pickBannerNetwork` — applies the rule to pick which one to surface Also introduces a `NetworkWithMetadata` intermediate type. --- .../src/NetworkConnectionBannerController.ts | 95 ++++++++++++------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 72eddeb339..a8dd666f8c 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -11,10 +11,13 @@ import type { } from '@metamask/connectivity-controller'; import type { Messenger } from '@metamask/messenger'; import type { + NetworkConfiguration, NetworkControllerGetNetworkConfigurationByChainIdAction, NetworkControllerGetStateAction, NetworkControllerUpdateNetworkAction, NetworkControllerStateChangeEvent, + NetworkMetadata, + RpcEndpoint, } from '@metamask/network-controller'; import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; import type { @@ -424,18 +427,27 @@ export class NetworkConnectionBannerController extends BaseController< } #findFailedNetwork(): FailedNetwork | null { + const networksWithMetadata = this.#collectNetworksWithMetadata(); + const failedNetworks = networksWithMetadata + .filter(({ metadata }) => metadata.status !== NetworkStatus.Available) + .map((network) => this.#buildFailedNetwork(network)); + return this.#pickBannerNetwork(failedNetworks, networksWithMetadata.length); + } + + #getEnabledEvmChainIds(): Hex[] { const { enabledNetworkMap } = this.messenger.call( 'NetworkEnablementController:getState', ); - const enabledEvmChainIds = Object.entries( - enabledNetworkMap[KnownCaipNamespace.Eip155] ?? {}, - ) + return Object.entries(enabledNetworkMap[KnownCaipNamespace.Eip155] ?? {}) .filter(([, enabled]) => enabled) .map(([chainId]) => chainId as Hex); + } + + #collectNetworksWithMetadata(): NetworkWithMetadata[] { const { networkConfigurationsByChainId, networksMetadata } = this.messenger.call('NetworkController:getState'); - const networksWithMetadata = enabledEvmChainIds.flatMap((chainId) => { + return this.#getEnabledEvmChainIds().flatMap((chainId) => { const networkConfiguration = networkConfigurationsByChainId[chainId]; if (!networkConfiguration) { return []; @@ -461,37 +473,45 @@ export class NetworkConnectionBannerController extends BaseController< }, ]; }); + } - const failedNetworks: FailedNetwork[] = networksWithMetadata - .filter(({ metadata }) => metadata.status !== NetworkStatus.Available) - .map(({ chainId, name, rpcEndpoints, defaultRpcEndpointIndex, defaultRpcEndpoint }) => { - const isInfuraEndpoint = - defaultRpcEndpoint.type === RpcEndpointType.Infura; - - // For custom endpoints (non-Infura), find an Infura endpoint on this - // chain that we could offer to switch to. - let switchableInfuraNetworkClientId: string | null = null; - if (!isInfuraEndpoint) { - const otherInfura = rpcEndpoints.find( - (endpoint, index) => - index !== defaultRpcEndpointIndex && - endpoint.type === RpcEndpointType.Infura, - ); - switchableInfuraNetworkClientId = - otherInfura?.networkClientId ?? null; - } + #buildFailedNetwork({ + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + defaultRpcEndpoint, + }: NetworkWithMetadata): FailedNetwork { + const isInfuraEndpoint = + defaultRpcEndpoint.type === RpcEndpointType.Infura; + + // For custom endpoints (non-Infura), find an Infura endpoint on this + // chain that we could offer to switch to. + let switchableInfuraNetworkClientId: string | null = null; + if (!isInfuraEndpoint) { + const otherInfura = rpcEndpoints.find( + (endpoint, index) => + index !== defaultRpcEndpointIndex && + endpoint.type === RpcEndpointType.Infura, + ); + switchableInfuraNetworkClientId = otherInfura?.networkClientId ?? null; + } - return { - chainId, - networkClientId: defaultRpcEndpoint.networkClientId, - name, - rpcUrl: defaultRpcEndpoint.url, - isInfuraEndpoint, - switchableInfuraNetworkClientId, - domain: getDomain(defaultRpcEndpoint.url), - }; - }); + return { + chainId, + networkClientId: defaultRpcEndpoint.networkClientId, + name, + rpcUrl: defaultRpcEndpoint.url, + isInfuraEndpoint, + switchableInfuraNetworkClientId, + domain: getDomain(defaultRpcEndpoint.url), + }; + } + #pickBannerNetwork( + failedNetworks: FailedNetwork[], + totalNetworksWithMetadata: number, + ): FailedNetwork | null { if (failedNetworks.length === 0) { return null; } @@ -505,7 +525,7 @@ export class NetworkConnectionBannerController extends BaseController< .filter((domain): domain is string => domain !== null), ).size; const areAllKnownNetworksFailed = - failedNetworks.length === networksWithMetadata.length; + failedNetworks.length === totalNetworksWithMetadata; if ( !firstCustomFailed && @@ -518,3 +538,12 @@ export class NetworkConnectionBannerController extends BaseController< return firstCustomFailed ?? failedNetworks[0]; } } + +type NetworkWithMetadata = { + chainId: Hex; + name: string; + rpcEndpoints: NetworkConfiguration['rpcEndpoints']; + defaultRpcEndpointIndex: number; + defaultRpcEndpoint: RpcEndpoint; + metadata: NetworkMetadata; +}; From f846550f0cf9f1ed65d53a69fdb4788de5022841 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:42:12 +0200 Subject: [PATCH 28/48] refactor: use selector-scoped subscriptions to upstream state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the controller guidelines: instead of reacting to every `stateChange` on the three upstream controllers, subscribe with narrow selectors so we only re-evaluate when a field we care about (`networksMetadata`, `networkConfigurationsByChainId`, `enabledNetworkMap`, `connectivityStatus`) actually changes. Each subscription needs its own handler reference since the messenger keys subscribers by handler identity — inline arrows wrap `#onUpstreamChange` to keep them distinct. --- .../src/NetworkConnectionBannerController.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index a8dd666f8c..7fd5e78662 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -246,22 +246,34 @@ export class NetworkConnectionBannerController extends BaseController< state: getDefaultNetworkConnectionBannerControllerState(), }); - const onStateChange = (): void => { - if (this.#initialized) { - this.#refreshState(); - } - }; + // Scoped selectors so upstream state changes we don't care about + // (e.g. a `NetworkController` selected-network-client update) don't + // trigger a re-evaluation. Each subscription needs its own handler + // reference — the messenger keys subscribers by handler identity, so + // sharing one would collapse the pair for `NetworkController` into a + // single subscription. // Upstream controllers still expose :stateChange; switch to :stateChanged // once those packages migrate their event types. /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ - this.messenger.subscribe('NetworkController:stateChange', onStateChange); + this.messenger.subscribe( + 'NetworkController:stateChange', + () => this.#onUpstreamChange(), + (state) => state.networksMetadata, + ); + this.messenger.subscribe( + 'NetworkController:stateChange', + () => this.#onUpstreamChange(), + (state) => state.networkConfigurationsByChainId, + ); this.messenger.subscribe( 'NetworkEnablementController:stateChange', - onStateChange, + () => this.#onUpstreamChange(), + (state) => state.enabledNetworkMap, ); this.messenger.subscribe( 'ConnectivityController:stateChange', - onStateChange, + () => this.#onUpstreamChange(), + (state) => state.connectivityStatus, ); /* eslint-enable no-restricted-syntax */ @@ -286,6 +298,12 @@ export class NetworkConnectionBannerController extends BaseController< this.#initialized = true; } + #onUpstreamChange(): void { + if (this.#initialized) { + this.#refreshState(); + } + } + /** * Clears the banner state such that the banner will be hidden. */ From 4b79c860e0f646b82863eb9ee5119964229355e9 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:46:02 +0200 Subject: [PATCH 29/48] refactor: match Infura endpoints by url placeholder Reverts the type based check from the prior commit. Match on the `{infuraProjectId}` placeholder that lives on stored network configurations (the placeholder is substituted with the real project id at request time). URLs that already carry a substituted key are treated as custom since we do not have the project id in this package. --- .../src/NetworkConnectionBannerController.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 7fd5e78662..530bfd6aaf 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -19,7 +19,7 @@ import type { NetworkMetadata, RpcEndpoint, } from '@metamask/network-controller'; -import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; +import { NetworkStatus } from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, NetworkEnablementControllerStateChangeEvent, @@ -332,7 +332,7 @@ export class NetworkConnectionBannerController extends BaseController< } const infuraEndpointIndex = networkConfiguration.rpcEndpoints.findIndex( - (endpoint) => endpoint.type === RpcEndpointType.Infura, + (endpoint) => getIsInfuraEndpoint(endpoint.url), ); if (infuraEndpointIndex === -1) { throw new Error( @@ -500,8 +500,7 @@ export class NetworkConnectionBannerController extends BaseController< defaultRpcEndpointIndex, defaultRpcEndpoint, }: NetworkWithMetadata): FailedNetwork { - const isInfuraEndpoint = - defaultRpcEndpoint.type === RpcEndpointType.Infura; + const isInfuraEndpoint = getIsInfuraEndpoint(defaultRpcEndpoint.url); // For custom endpoints (non-Infura), find an Infura endpoint on this // chain that we could offer to switch to. @@ -510,7 +509,7 @@ export class NetworkConnectionBannerController extends BaseController< const otherInfura = rpcEndpoints.find( (endpoint, index) => index !== defaultRpcEndpointIndex && - endpoint.type === RpcEndpointType.Infura, + getIsInfuraEndpoint(endpoint.url), ); switchableInfuraNetworkClientId = otherInfura?.networkClientId ?? null; } @@ -565,3 +564,18 @@ type NetworkWithMetadata = { defaultRpcEndpoint: RpcEndpoint; metadata: NetworkMetadata; }; + +/** + * Whether an RPC URL is a MetaMask Infura endpoint. Matches the + * `{infuraProjectId}` placeholder form that lives on stored network + * configurations before the wallet substitutes the real project id at + * request time. URLs that already carry a substituted key are treated as + * custom (we do not have the project id in this package). + * + * @param url - The RPC URL to check. + * @returns True if the URL is the placeholder form of a MetaMask Infura + * endpoint. + */ +function getIsInfuraEndpoint(url: string): boolean { + return /^https:\/\/[^./]+\.infura\.io\/v3\/\{infuraProjectId\}$/u.test(url); +} From 23a1a46b653f9df8cce2ff0f56c84ce4cb1b2f40 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:51:48 +0200 Subject: [PATCH 30/48] refactor: align selector subscriptions with controller guidelines Follows the pattern in docs/code-guidelines/controller-guidelines.md: one subscribe per peer with a composed selector. - NetworkController: `createSelector` over `networksMetadata` and `networkConfigurationsByChainId`, memoized so the projected object is reference stable while unrelated fields change. - NetworkEnablementController: peer exposed `selectEnabledNetworkMap`. - ConnectivityController: peer exposed `connectivityControllerSelectors.selectConnectivityStatus`. --- .../src/NetworkConnectionBannerController.ts | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 530bfd6aaf..908ffce730 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -4,7 +4,10 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import { + CONNECTIVITY_STATUSES, + connectivityControllerSelectors, +} from '@metamask/connectivity-controller'; import type { ConnectivityControllerGetStateAction, ConnectivityControllerStateChangeEvent, @@ -17,6 +20,7 @@ import type { NetworkControllerUpdateNetworkAction, NetworkControllerStateChangeEvent, NetworkMetadata, + NetworkState, RpcEndpoint, } from '@metamask/network-controller'; import { NetworkStatus } from '@metamask/network-controller'; @@ -24,12 +28,31 @@ import type { NetworkEnablementControllerGetStateAction, NetworkEnablementControllerStateChangeEvent, } from '@metamask/network-enablement-controller'; +import { selectEnabledNetworkMap } from '@metamask/network-enablement-controller'; import type { Hex } from '@metamask/utils'; import { KnownCaipNamespace } from '@metamask/utils'; +import { createSelector } from 'reselect'; import type { NetworkConnectionBannerControllerMethodActions } from './NetworkConnectionBannerController-method-action-types'; import { getDomain } from './url-utils'; +/** + * Selects the `NetworkController` state fields that influence the banner + * rule. Composed with `createSelector` so the return object stays reference + * stable while unrelated `NetworkController` state (e.g. + * `selectedNetworkClientId`) changes. + */ +const selectNetworkControllerFields = createSelector( + [ + (state: NetworkState) => state.networksMetadata, + (state: NetworkState) => state.networkConfigurationsByChainId, + ], + (networksMetadata, networkConfigurationsByChainId) => ({ + networksMetadata, + networkConfigurationsByChainId, + }), +); + /** * The name of the {@link NetworkConnectionBannerController}, used to namespace * the controller's actions and events and to namespace the controller's state @@ -246,34 +269,26 @@ export class NetworkConnectionBannerController extends BaseController< state: getDefaultNetworkConnectionBannerControllerState(), }); - // Scoped selectors so upstream state changes we don't care about - // (e.g. a `NetworkController` selected-network-client update) don't - // trigger a re-evaluation. Each subscription needs its own handler - // reference — the messenger keys subscribers by handler identity, so - // sharing one would collapse the pair for `NetworkController` into a - // single subscription. + // Scoped selectors per controller guideline so unrelated upstream + // `stateChange` events (e.g. a `NetworkController` selected client id + // update) do not trigger a re-evaluation. // Upstream controllers still expose :stateChange; switch to :stateChanged // once those packages migrate their event types. /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ this.messenger.subscribe( 'NetworkController:stateChange', () => this.#onUpstreamChange(), - (state) => state.networksMetadata, - ); - this.messenger.subscribe( - 'NetworkController:stateChange', - () => this.#onUpstreamChange(), - (state) => state.networkConfigurationsByChainId, + selectNetworkControllerFields, ); this.messenger.subscribe( 'NetworkEnablementController:stateChange', () => this.#onUpstreamChange(), - (state) => state.enabledNetworkMap, + selectEnabledNetworkMap, ); this.messenger.subscribe( 'ConnectivityController:stateChange', () => this.#onUpstreamChange(), - (state) => state.connectivityStatus, + connectivityControllerSelectors.selectConnectivityStatus, ); /* eslint-enable no-restricted-syntax */ From 2d005496bc08ce70d23d86836dd45f55ab8dc0a3 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 17:57:29 +0200 Subject: [PATCH 31/48] refactor: trust the messenger in banner timer callbacks The degraded and unavailable timer callbacks no longer re run `#findFailedNetwork`. Instead they use the `FailedNetwork` captured at schedule time: our upstream selector subscriptions cancel or replace the pending timer whenever peer state changes in a way that matters, so at fire time the captured failure is still the right one. Removes the two silent recovery tests and the `setNetworkStateSilently` helper, which simulated a scenario the messenger contract disallows. --- .../NetworkConnectionBannerController.test.ts | 107 ------------------ .../src/NetworkConnectionBannerController.ts | 17 +-- 2 files changed, 6 insertions(+), 118 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index ac49fe9e29..a40e0578cf 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -637,104 +637,6 @@ describe('NetworkConnectionBannerController', () => { }); }); - it('bails out at the degraded timer if the underlying state has silently recovered', async () => { - await withController( - ({ controller, setNetworkState, setNetworkStateSilently }) => { - const config = buildConfiguration({ - chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', - rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), - ], - }); - setNetworkState( - buildNetworkState({ - configurations: { '0x89': config }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( - NetworkStatus.Unavailable, - ), - }, - }), - ); - - // Underlying state recovers but no event fires; the scheduled - // degraded timer must re-check and skip the update. - setNetworkStateSilently( - buildNetworkState({ - configurations: { '0x89': config }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( - NetworkStatus.Available, - ), - }, - }), - ); - - jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('available'); - }, - ); - }); - - it('clears the banner at the unavailable timer if the underlying state has silently recovered', async () => { - await withController( - ({ controller, setNetworkState, setNetworkStateSilently }) => { - const config = buildConfiguration({ - chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', - rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), - ], - }); - setNetworkState( - buildNetworkState({ - configurations: { '0x89': config }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( - NetworkStatus.Unavailable, - ), - }, - }), - ); - - jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('degraded'); - - // Underlying state recovers but no event fires; the scheduled - // unavailable timer must re-check and skip the escalation. - setNetworkStateSilently( - buildNetworkState({ - configurations: { '0x89': config }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( - NetworkStatus.Available, - ), - }, - }), - ); - - jest.advanceTimersByTime(25_000); - expect(controller.state).toStrictEqual({ - status: 'available', - network: null, - }); - }, - ); - }); - it('skips enabled chains that have no network configuration', async () => { await withController(({ controller, setNetworkState }) => { setNetworkState({ @@ -1210,7 +1112,6 @@ type WithControllerCallback = (payload: { rootMessenger: RootMessenger; controllerMessenger: NetworkConnectionBannerControllerMessenger; setNetworkState: (state: StubbedState) => void; - setNetworkStateSilently: (state: StubbedState) => void; setConnectivityStatus: ( status: ConnectivityControllerState['connectivityStatus'], ) => void; @@ -1315,13 +1216,6 @@ async function withController( ); }; - // Update the upstream state visible to the controller WITHOUT publishing a - // stateChange event. Used to exercise the defensive re-evaluation inside - // the degraded / unavailable timer callbacks. - const setNetworkStateSilently = (state: StubbedState): void => { - currentState = state; - }; - const setConnectivityStatus = ( status: ConnectivityControllerState['connectivityStatus'], ): void => { @@ -1341,7 +1235,6 @@ async function withController( rootMessenger, controllerMessenger: messenger, setNetworkState, - setNetworkStateSilently, setConnectivityStatus, updateNetwork, }); diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 908ffce730..4e46e6ef88 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -406,28 +406,23 @@ export class NetworkConnectionBannerController extends BaseController< state.network = null; }); + // Capture the failing network at schedule time. We trust the messenger + // contract: if the failure resolves or the target changes during the + // wait, our upstream subscriptions will have cancelled or replaced this + // timer via `#clearTimers` before it fires. this.#pendingNetworkClientId = failedNetwork.networkClientId; this.#degradedTimer = setTimeout(() => { this.#degradedTimer = undefined; this.#pendingNetworkClientId = undefined; - const stillFailed = this.#findFailedNetwork(); - if (!stillFailed) { - return; - } this.update((state) => { state.status = 'degraded'; - state.network = stillFailed; + state.network = failedNetwork; }); this.#unavailableTimer = setTimeout(() => { this.#unavailableTimer = undefined; - const stillFailedAtEscalation = this.#findFailedNetwork(); - if (!stillFailedAtEscalation) { - this.#resetBanner(); - return; - } this.update((state) => { state.status = 'unavailable'; - state.network = stillFailedAtEscalation; + state.network = failedNetwork; }); }, UNAVAILABLE_BANNER_TIMEOUT - DEGRADED_BANNER_TIMEOUT); }, DEGRADED_BANNER_TIMEOUT); From 285da041a73b49f99abb2cce750ffaf285718d5d Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 18:12:00 +0200 Subject: [PATCH 32/48] fix: constraints --- .../package.json | 4 +-- yarn.lock | 36 +++---------------- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json index ab2844bfb9..7aceb9fcae 100644 --- a/packages/network-connection-banner-controller/package.json +++ b/packages/network-connection-banner-controller/package.json @@ -56,8 +56,8 @@ "@metamask/base-controller": "^9.1.0", "@metamask/connectivity-controller": "^0.2.0", "@metamask/messenger": "^1.2.0", - "@metamask/network-controller": "^32.0.0", - "@metamask/network-enablement-controller": "^5.3.0", + "@metamask/network-controller": "^34.0.0", + "@metamask/network-enablement-controller": "^5.4.1", "@metamask/utils": "^11.11.0", "ip-regex": "^4.3.0", "psl": "^1.15.0", diff --git a/yarn.lock b/yarn.lock index 27f3f965b4..bba8c7a001 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6263,7 +6263,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.1.0, @metamask/controller-utils@npm:^12.3.0, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@npm:^12.3.0, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: @@ -7694,8 +7694,8 @@ __metadata: "@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/network-controller": "npm:^34.0.0" + "@metamask/network-enablement-controller": "npm:^5.4.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -7712,34 +7712,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-controller@npm:^32.0.0": - version: 32.0.0 - resolution: "@metamask/network-controller@npm:32.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/connectivity-controller": "npm:^0.2.0" - "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/eth-block-tracker": "npm:^15.0.1" - "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^23.1.3" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.5.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.9.0" - async-mutex: "npm:^0.5.0" - fast-deep-equal: "npm:^3.1.3" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - reselect: "npm:^5.1.1" - uri-js: "npm:^4.4.1" - uuid: "npm:^8.3.2" - checksum: 10/263f6355aac3e2014f2d18c2c10b20b83e12823845cf9a12e74dea09d31d5f81be9bd396d9f3b5b7dbaa99ce5ed7f3b0ec4c537656f884f586c349965569ad07 - languageName: node - linkType: hard - "@metamask/network-controller@npm:^34.0.0, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" @@ -7789,7 +7761,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-enablement-controller@npm:^5.3.0, @metamask/network-enablement-controller@npm:^5.4.1, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": +"@metamask/network-enablement-controller@npm:^5.4.1, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": version: 0.0.0-use.local resolution: "@metamask/network-enablement-controller@workspace:packages/network-enablement-controller" dependencies: From 75ac02b8df7bc87ac744819859c85dc6da5e9228 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 18:13:57 +0200 Subject: [PATCH 33/48] fix: derive RpcEndpoint type instead of importing it `RpcEndpoint` is defined inside `@metamask/network-controller` but not re-exported from its barrel. Use `NetworkConfiguration['rpcEndpoints'][number]` so we don't rely on a symbol that isn't part of the peer's public API. --- .../src/NetworkConnectionBannerController.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 4e46e6ef88..ebd76c95d5 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -21,7 +21,6 @@ import type { NetworkControllerStateChangeEvent, NetworkMetadata, NetworkState, - RpcEndpoint, } from '@metamask/network-controller'; import { NetworkStatus } from '@metamask/network-controller'; import type { @@ -571,7 +570,7 @@ type NetworkWithMetadata = { name: string; rpcEndpoints: NetworkConfiguration['rpcEndpoints']; defaultRpcEndpointIndex: number; - defaultRpcEndpoint: RpcEndpoint; + defaultRpcEndpoint: NetworkConfiguration['rpcEndpoints'][number]; metadata: NetworkMetadata; }; From a86c7c0834c56983f30b379d721218feb5de3871 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 18:17:49 +0200 Subject: [PATCH 34/48] chore: regenerate messenger action types Sync JSDoc for `dismissBanner` after the earlier trim. --- .../NetworkConnectionBannerController-method-action-types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts index 8d3f5fa784..9f35642024 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -17,9 +17,7 @@ export type NetworkConnectionBannerControllerInitAction = { }; /** - * Clears the banner state regardless of the current rule outcome. The next - * subscription-driven evaluation will re-show the banner if the conditions - * still hold. + * Clears the banner state such that the banner will be hidden. */ export type NetworkConnectionBannerControllerDismissBannerAction = { type: `NetworkConnectionBannerController:dismissBanner`; From 3c0e8a808afda7d266563b760f639461ac40573a Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 21:36:56 +0200 Subject: [PATCH 35/48] fix: extract inner selectors so createSelector satisfies lint rules The inline arrow selectors triggered `jsdoc/require-param`, `jsdoc/require-returns`, and `@typescript-eslint/explicit-function-return-type`. Extracting them as named consts with proper JSDoc and explicit return types fixes all six errors and matches the pattern in connectivity-controller. --- .../src/NetworkConnectionBannerController.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index ebd76c95d5..79b243c239 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -35,17 +35,39 @@ import { createSelector } from 'reselect'; import type { NetworkConnectionBannerControllerMethodActions } from './NetworkConnectionBannerController-method-action-types'; import { getDomain } from './url-utils'; +/** + * Selects `networksMetadata` from the `NetworkController` state. + * + * @param state - The `NetworkController` state. + * @returns The networks metadata map keyed by network client id. + */ +const selectNetworksMetadata = ( + state: NetworkState, +): NetworkState['networksMetadata'] => state.networksMetadata; + +/** + * Selects `networkConfigurationsByChainId` from the `NetworkController` + * state. + * + * @param state - The `NetworkController` state. + * @returns The network configurations keyed by chain id. + */ +const selectNetworkConfigurationsByChainId = ( + state: NetworkState, +): NetworkState['networkConfigurationsByChainId'] => + state.networkConfigurationsByChainId; + /** * Selects the `NetworkController` state fields that influence the banner * rule. Composed with `createSelector` so the return object stays reference * stable while unrelated `NetworkController` state (e.g. * `selectedNetworkClientId`) changes. + * + * @param state - The `NetworkController` state. + * @returns The relevant network fields. */ const selectNetworkControllerFields = createSelector( - [ - (state: NetworkState) => state.networksMetadata, - (state: NetworkState) => state.networkConfigurationsByChainId, - ], + [selectNetworksMetadata, selectNetworkConfigurationsByChainId], (networksMetadata, networkConfigurationsByChainId) => ({ networksMetadata, networkConfigurationsByChainId, From 44d1ed739f8992886ef27cd76b6a06491a877a6a Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 22:20:00 +0200 Subject: [PATCH 36/48] feat!: replace init with start / stop lifecycle BREAKING CHANGE: `init()` is gone. Consumers of the banner controller now drive its lifecycle via `start()` and `stop()`. The old `init()` model ran the controller from the moment the wallet Engine constructed it, which meant the 5s / 30s escalation timers could complete while the user was still on the lock screen. A first look after unlock could show `unavailable` even though nothing was actually wrong. `start()` and `stop()` tie the controller to the UI that renders the banner. Both are idempotent. `start()` runs the initial evaluation and enables reactions to upstream state changes. `stop()` cancels pending timers, resets banner state to `available`, and ignores upstream changes until the next `start()`. - Rename messenger action `NetworkConnectionBannerController:init` to `NetworkConnectionBannerController:start` and add `NetworkConnectionBannerController:stop`. - Rewrite the README lifecycle section. - Reorganize tests into a dedicated `start / stop` block with coverage for stop cancelling a pending banner, ignoring upstream changes after stop, resuming on start after stop, and idempotence when never started. --- .../README.md | 22 ++- ...ionBannerController-method-action-types.ts | 27 +++- .../NetworkConnectionBannerController.test.ts | 141 ++++++++++++++++-- .../src/NetworkConnectionBannerController.ts | 35 +++-- .../src/index.ts | 3 +- 5 files changed, 193 insertions(+), 35 deletions(-) diff --git a/packages/network-connection-banner-controller/README.md b/packages/network-connection-banner-controller/README.md index 3c0872ecc7..38d4828b51 100644 --- a/packages/network-connection-banner-controller/README.md +++ b/packages/network-connection-banner-controller/README.md @@ -5,18 +5,26 @@ connection banner based on RPC endpoint health. It encapsulates the shared rule, the 5s/30s timer state machine, and the eTLD+1 grouping previously duplicated across MetaMask clients. -## Initialization +## Lifecycle -After constructing the controller, call `init()` only after the -`NetworkController`, `NetworkEnablementController`, and -`ConnectivityController` have initialized. Until then, upstream state changes -are ignored and no banner timers run. +The controller stays dormant after construction so the 5s / 30s escalation +timers do not run before a user is actually looking at the wallet (e.g. while +the app is still on the lock screen). The UI that renders the banner is +responsible for driving the lifecycle: ```typescript -networkConnectionBannerController.init(); +// When the wallet UI that shows the banner becomes active +// (e.g. the home surface mounts after unlock): +networkConnectionBannerController.start(); + +// When it goes away: +networkConnectionBannerController.stop(); ``` -`init()` is idempotent and immediately evaluates the latest upstream state. +Both methods are idempotent. `start` runs the initial evaluation immediately +and enables reactions to upstream state changes. `stop` cancels any pending +timers and resets the banner state to `available`. Upstream state changes are +ignored while stopped. ## Installation diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts index 9f35642024..ca2cbe0414 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -6,14 +6,24 @@ import type { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; /** - * Starts evaluating network connection state. - * - * This method should be called after the upstream network, network - * enablement, and connectivity controllers have been initialized. + * Starts evaluating network connection state. Call this when the wallet + * UI that consumes the banner becomes active (typically when the wallet + * is unlocked and the home surface mounts) so timers do not run while + * the user is not looking at the wallet. Idempotent. + */ +export type NetworkConnectionBannerControllerStartAction = { + type: `NetworkConnectionBannerController:start`; + handler: NetworkConnectionBannerController['start']; +}; + +/** + * Stops evaluating network connection state. Clears any pending banner + * timers and resets state to `available`. Call this when the UI + * consuming the banner is no longer active. Idempotent. */ -export type NetworkConnectionBannerControllerInitAction = { - type: `NetworkConnectionBannerController:init`; - handler: NetworkConnectionBannerController['init']; +export type NetworkConnectionBannerControllerStopAction = { + type: `NetworkConnectionBannerController:stop`; + handler: NetworkConnectionBannerController['stop']; }; /** @@ -42,6 +52,7 @@ export type NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction = { * Union of all NetworkConnectionBannerController action types. */ export type NetworkConnectionBannerControllerMethodActions = - | NetworkConnectionBannerControllerInitAction + | NetworkConnectionBannerControllerStartAction + | NetworkConnectionBannerControllerStopAction | NetworkConnectionBannerControllerDismissBannerAction | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction; diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index a40e0578cf..8ef19cf199 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -119,8 +119,10 @@ describe('NetworkConnectionBannerController', () => { }); }); }); + }); - it('does not evaluate existing upstream state before initialization', async () => { + describe('start / stop', () => { + it('does not evaluate existing upstream state before start', async () => { const initialState = buildNetworkState({ configurations: { '0x89': buildConfiguration({ @@ -152,7 +154,7 @@ describe('NetworkConnectionBannerController', () => { ); }); - it('evaluates existing upstream state on initialization', async () => { + it('evaluates existing upstream state on start', async () => { const initialState = buildNetworkState({ configurations: { '0x89': buildConfiguration({ @@ -175,8 +177,8 @@ describe('NetworkConnectionBannerController', () => { await withController( ({ controller, rootMessenger }) => { - rootMessenger.call('NetworkConnectionBannerController:init'); - rootMessenger.call('NetworkConnectionBannerController:init'); + rootMessenger.call('NetworkConnectionBannerController:start'); + rootMessenger.call('NetworkConnectionBannerController:start'); jest.advanceTimersByTime(5_000); @@ -187,7 +189,7 @@ describe('NetworkConnectionBannerController', () => { ); }); - it('ignores upstream state changes before initialization', async () => { + it('ignores upstream state changes before start', async () => { await withController( ({ controller, setNetworkState }) => { setNetworkState( @@ -217,7 +219,7 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); - controller.init(); + controller.start(); jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); }, @@ -225,6 +227,127 @@ describe('NetworkConnectionBannerController', () => { false, ); }); + + it('cancels a pending banner and resets state on stop', async () => { + await withController(({ controller, setNetworkState }) => { + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + controller.stop(); + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }); + }); + + it('ignores upstream state changes after stop', async () => { + await withController(({ controller, setNetworkState }) => { + controller.stop(); + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }); + }); + + it('resumes evaluation when start is called again after stop', async () => { + await withController(({ controller, setNetworkState }) => { + controller.stop(); + + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + expect(controller.state.status).toBe('available'); + + controller.start(); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }); + }); + + it('stop is idempotent when never started', async () => { + await withController( + ({ controller }) => { + controller.stop(); + controller.stop(); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }, + undefined, + false, + ); + }); }); describe('rule evaluation on NetworkController:stateChange', () => { @@ -1121,7 +1244,7 @@ type WithControllerCallback = (payload: { async function withController( testFunction: WithControllerCallback, initialState?: StubbedState, - initialize = true, + start = true, ): Promise { const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -1198,8 +1321,8 @@ async function withController( const controller = new NetworkConnectionBannerController({ messenger, }); - if (initialize) { - controller.init(); + if (start) { + controller.start(); } const setNetworkState = (state: StubbedState): void => { diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 79b243c239..23ca0c78fb 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -161,7 +161,8 @@ const DEGRADED_BANNER_TIMEOUT = 5_000; const UNAVAILABLE_BANNER_TIMEOUT = 30_000; const MESSENGER_EXPOSED_METHODS = [ - 'init', + 'start', + 'stop', 'dismissBanner', 'switchToDefaultInfuraRpc', ] as const; @@ -274,7 +275,7 @@ export class NetworkConnectionBannerController extends BaseController< #pendingNetworkClientId: string | undefined; - #initialized = false; + #started = false; /** * Constructs a new {@link NetworkConnectionBannerController}. @@ -320,22 +321,36 @@ export class NetworkConnectionBannerController extends BaseController< } /** - * Starts evaluating network connection state. - * - * This method should be called after the upstream network, network - * enablement, and connectivity controllers have been initialized. + * Starts evaluating network connection state. Call this when the wallet + * UI that consumes the banner becomes active (typically when the wallet + * is unlocked and the home surface mounts) so timers do not run while + * the user is not looking at the wallet. Idempotent. */ - init(): void { - if (this.#initialized) { + start(): void { + if (this.#started) { return; } + this.#started = true; this.#refreshState(); - this.#initialized = true; + } + + /** + * Stops evaluating network connection state. Clears any pending banner + * timers and resets state to `available`. Call this when the UI + * consuming the banner is no longer active. Idempotent. + */ + stop(): void { + if (!this.#started) { + return; + } + + this.#started = false; + this.#resetBanner(); } #onUpstreamChange(): void { - if (this.#initialized) { + if (this.#started) { this.#refreshState(); } } diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts index e7d5c06ba1..b15a1e615c 100644 --- a/packages/network-connection-banner-controller/src/index.ts +++ b/packages/network-connection-banner-controller/src/index.ts @@ -10,7 +10,8 @@ export type { NetworkConnectionBannerStatus, } from './NetworkConnectionBannerController'; export type { - NetworkConnectionBannerControllerInitAction, + NetworkConnectionBannerControllerStartAction, + NetworkConnectionBannerControllerStopAction, NetworkConnectionBannerControllerDismissBannerAction, NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction, } from './NetworkConnectionBannerController-method-action-types'; From 9b811eb5ef151230603584a83db980cb15fe2099 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 22:26:40 +0200 Subject: [PATCH 37/48] fix: guard timers against synchronous stop during refresh A synchronous listener on `NetworkConnectionBannerController:stateChanged` could call `stop()` from inside `#refreshState`, either during the pre timer reset `update` or during the degraded timer's own `update`. The enclosing `setTimeout` was still scheduled, so the banner could escalate to `degraded` or `unavailable` after the UI had stopped the controller. Two new guards check `#started` after each `update` before scheduling the next timer. New regression tests exercise both re entry points. --- .../NetworkConnectionBannerController.test.ts | 121 ++++++++++++++++++ .../src/NetworkConnectionBannerController.ts | 11 ++ 2 files changed, 132 insertions(+) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 8ef19cf199..e0e417fcb0 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -348,6 +348,127 @@ describe('NetworkConnectionBannerController', () => { false, ); }); + + it('bails out when a stateChanged listener calls stop synchronously during refresh', async () => { + await withController( + ({ controller, controllerMessenger, setNetworkState }) => { + // Escalate the banner to `unavailable` so state is non default and the + // next refresh's pre timer `update` actually mutates state. + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('unavailable'); + + let stopped = false; + controllerMessenger.subscribe( + 'NetworkConnectionBannerController:stateChanged', + () => { + if (!stopped) { + stopped = true; + controller.stop(); + } + }, + ); + + // Trigger a refresh whose pre timer `update` will fire `stateChanged` + // (previous state was `unavailable`/polygon → available/null). + setNetworkState( + buildNetworkState({ + configurations: { + '0x1': buildConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint( + ALCHEMY_CLIENT_ID, + 'https://eth-mainnet.alchemyapi.io/v2/abc', + ), + ], + }), + }, + enabledChainIds: ['0x1'], + metadata: { + [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }, + ); + }); + + it('bails out when a stateChanged listener calls stop synchronously at the degraded fire', async () => { + await withController( + ({ controller, controllerMessenger, setNetworkState }) => { + controllerMessenger.subscribe( + 'NetworkConnectionBannerController:stateChanged', + (state) => { + if (state.status === 'degraded') { + controller.stop(); + } + }, + ); + + setNetworkState( + buildNetworkState({ + configurations: { + '0x89': buildConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + enabledChainIds: ['0x89'], + metadata: { + [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // Advance to fire the degraded timer; its `update` triggers the + // listener, which calls stop(). The guard should bail before + // scheduling the unavailable escalation. + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }, + ); + }); }); describe('rule evaluation on NetworkController:stateChange', () => { diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 23ca0c78fb..c15123d25e 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -442,6 +442,12 @@ export class NetworkConnectionBannerController extends BaseController< state.network = null; }); + // A synchronous listener on our `stateChanged` event above may have + // called `stop()` re-entrantly. Bail before scheduling anything. + if (!this.#started) { + return; + } + // Capture the failing network at schedule time. We trust the messenger // contract: if the failure resolves or the target changes during the // wait, our upstream subscriptions will have cancelled or replaced this @@ -454,6 +460,11 @@ export class NetworkConnectionBannerController extends BaseController< state.status = 'degraded'; state.network = failedNetwork; }); + // A synchronous listener on our `stateChanged` event above may have + // called `stop()` re-entrantly. Bail before scheduling the escalation. + if (!this.#started) { + return; + } this.#unavailableTimer = setTimeout(() => { this.#unavailableTimer = undefined; this.update((state) => { From eff0473bea2cf249f6228c220c9d2b487037c7f3 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 22:43:29 +0200 Subject: [PATCH 38/48] refactor: apply review nits on banner controller - Rename `controllerName` to `CONTROLLER_NAME` per updated guidelines and move it above the module scope selectors. - Move the `NetworkWithMetadata` intermediate type to the top of the file with the other type definitions. - Add JSDoc to the previously undocumented properties of the `FailedNetwork` type. - Drop the redundant scoped selectors comment above the subscribe block, and the extra blank line before the `failedNetwork` guard in `#refreshState`. - Rewrite the `start` JSDoc to describe what it does, not just when it runs. - Rename `switchToDefaultInfuraRpc` to `switchToDefaultInfuraRpcEndpoint` (RPC endpoints are the term we use) and take `chainId` as a positional argument since there is only one. - Rename `otherInfura` to `infuraEndpoint` in `#buildFailedNetwork` since there is only one Infura endpoint per chain. --- ...ionBannerController-method-action-types.ts | 28 ++++--- .../NetworkConnectionBannerController.test.ts | 18 ++-- .../src/NetworkConnectionBannerController.ts | 83 ++++++++++--------- .../src/index.ts | 2 +- 4 files changed, 72 insertions(+), 59 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts index ca2cbe0414..e047060dcb 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -6,10 +6,14 @@ import type { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; /** - * Starts evaluating network connection state. Call this when the wallet - * UI that consumes the banner becomes active (typically when the wallet - * is unlocked and the home surface mounts) so timers do not run while - * the user is not looking at the wallet. Idempotent. + * Look for a failed network, if any, and populate the initial state of the + * banner. Reacts to upstream state changes from this point on. + * + * Call this when the wallet UI that consumes the banner becomes active + * (typically when the wallet is unlocked and the home surface mounts) so + * timers do not run while the user is not looking at the wallet. Should + * be called after `NetworkController`, `NetworkEnablementController`, and + * `ConnectivityController` have been initialized. Idempotent. */ export type NetworkConnectionBannerControllerStartAction = { type: `NetworkConnectionBannerController:start`; @@ -35,18 +39,18 @@ export type NetworkConnectionBannerControllerDismissBannerAction = { }; /** - * Switches the chain's default RPC endpoint to its first Infura endpoint, + * Switches the chain's default RPC endpoint to its Infura endpoint, * causing the banner to clear once the network becomes available again. * - * @param args - The arguments to this action. - * @param args.chainId - The chain whose default RPC should be switched. + * @param chainId - The chain whose default RPC endpoint should be switched. * @throws If the chain configuration cannot be found, or if it has no * Infura endpoint to switch to, or if the default is already Infura. */ -export type NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction = { - type: `NetworkConnectionBannerController:switchToDefaultInfuraRpc`; - handler: NetworkConnectionBannerController['switchToDefaultInfuraRpc']; -}; +export type NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction = + { + type: `NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint`; + handler: NetworkConnectionBannerController['switchToDefaultInfuraRpcEndpoint']; + }; /** * Union of all NetworkConnectionBannerController action types. @@ -55,4 +59,4 @@ export type NetworkConnectionBannerControllerMethodActions = | NetworkConnectionBannerControllerStartAction | NetworkConnectionBannerControllerStopAction | NetworkConnectionBannerControllerDismissBannerAction - | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction; + | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction; diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index e0e417fcb0..521dca5633 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -1168,7 +1168,7 @@ describe('NetworkConnectionBannerController', () => { }); }); - describe('switchToDefaultInfuraRpc', () => { + describe('switchToDefaultInfuraRpcEndpoint', () => { it('invokes NetworkController:updateNetwork with the Infura endpoint as the new default', async () => { await withController( async ({ rootMessenger, setNetworkState, updateNetwork }) => { @@ -1193,8 +1193,8 @@ describe('NetworkConnectionBannerController', () => { ); await rootMessenger.call( - 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', - { chainId: '0x1' }, + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', ); expect(updateNetwork).toHaveBeenCalledTimes(1); @@ -1225,8 +1225,8 @@ describe('NetworkConnectionBannerController', () => { ); await rootMessenger.call( - 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', - { chainId: '0x1' }, + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', ); expect(updateNetwork).not.toHaveBeenCalled(); @@ -1238,8 +1238,8 @@ describe('NetworkConnectionBannerController', () => { await withController(async ({ rootMessenger }) => { await expect( rootMessenger.call( - 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', - { chainId: '0xdeadbeef' }, + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0xdeadbeef', ), ).rejects.toThrow(/No network configuration found/u); }); @@ -1266,8 +1266,8 @@ describe('NetworkConnectionBannerController', () => { await expect( rootMessenger.call( - 'NetworkConnectionBannerController:switchToDefaultInfuraRpc', - { chainId: '0x1' }, + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', ), ).rejects.toThrow(/No Infura endpoint available/u); }); diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index c15123d25e..9b6e9455f9 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -35,6 +35,13 @@ import { createSelector } from 'reselect'; import type { NetworkConnectionBannerControllerMethodActions } from './NetworkConnectionBannerController-method-action-types'; import { getDomain } from './url-utils'; +/** + * The name of the {@link NetworkConnectionBannerController}, used to namespace + * the controller's actions and events and to namespace the controller's state + * data when composed with other controllers. + */ +const CONTROLLER_NAME = 'NetworkConnectionBannerController'; + /** * Selects `networksMetadata` from the `NetworkController` state. * @@ -74,13 +81,6 @@ const selectNetworkControllerFields = createSelector( }), ); -/** - * The name of the {@link NetworkConnectionBannerController}, used to namespace - * the controller's actions and events and to namespace the controller's state - * data when composed with other controllers. - */ -const controllerName = 'NetworkConnectionBannerController'; - /** * Status the banner can be in. `available` means no banner is shown; the * `degraded` and `unavailable` values mirror the two-tier escalation that the @@ -91,14 +91,33 @@ export type NetworkConnectionBannerStatus = | 'degraded' | 'unavailable'; +/** + * A network from `NetworkController` state that has a default RPC endpoint + * with a known metadata status. Used as the input to the failed-network + * detection pipeline. + */ +type NetworkWithMetadata = { + chainId: Hex; + name: string; + rpcEndpoints: NetworkConfiguration['rpcEndpoints']; + defaultRpcEndpointIndex: number; + defaultRpcEndpoint: NetworkConfiguration['rpcEndpoints'][number]; + metadata: NetworkMetadata; +}; + /** * Details of a failing network the banner describes. */ export type FailedNetwork = { + /** The chain id of the failing network. */ chainId: Hex; + /** The `networkClientId` of the failing default RPC endpoint. */ networkClientId: string; + /** The display name for the failing network. */ name: string; + /** The URL of the failing default RPC endpoint. */ rpcUrl: string; + /** Whether the failing endpoint is a MetaMask Infura endpoint. */ isInfuraEndpoint: boolean; /** * The networkClientId of an Infura endpoint on the same chain that the user @@ -164,7 +183,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'start', 'stop', 'dismissBanner', - 'switchToDefaultInfuraRpc', + 'switchToDefaultInfuraRpcEndpoint', ] as const; /** @@ -172,7 +191,7 @@ const MESSENGER_EXPOSED_METHODS = [ */ export type NetworkConnectionBannerControllerGetStateAction = ControllerGetStateAction< - typeof controllerName, + typeof CONTROLLER_NAME, NetworkConnectionBannerControllerState >; @@ -201,7 +220,7 @@ type AllowedActions = */ export type NetworkConnectionBannerControllerStateChangedEvent = ControllerStateChangedEvent< - typeof controllerName, + typeof CONTROLLER_NAME, NetworkConnectionBannerControllerState >; @@ -226,7 +245,7 @@ type AllowedEvents = * {@link NetworkConnectionBannerController}. */ export type NetworkConnectionBannerControllerMessenger = Messenger< - typeof controllerName, + typeof CONTROLLER_NAME, NetworkConnectionBannerControllerActions | AllowedActions, NetworkConnectionBannerControllerEvents | AllowedEvents >; @@ -262,10 +281,10 @@ export type NetworkConnectionBannerControllerOptions = { * can act on. * * Clients only need to render the banner from the controller's state and wire - * click handlers to {@link dismissBanner} or {@link switchToDefaultInfuraRpc}. + * click handlers to {@link dismissBanner} or {@link switchToDefaultInfuraRpcEndpoint}. */ export class NetworkConnectionBannerController extends BaseController< - typeof controllerName, + typeof CONTROLLER_NAME, NetworkConnectionBannerControllerState, NetworkConnectionBannerControllerMessenger > { @@ -287,13 +306,10 @@ export class NetworkConnectionBannerController extends BaseController< super({ messenger, metadata: networkConnectionBannerControllerMetadata, - name: controllerName, + name: CONTROLLER_NAME, state: getDefaultNetworkConnectionBannerControllerState(), }); - // Scoped selectors per controller guideline so unrelated upstream - // `stateChange` events (e.g. a `NetworkController` selected client id - // update) do not trigger a re-evaluation. // Upstream controllers still expose :stateChange; switch to :stateChanged // once those packages migrate their event types. /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ @@ -321,10 +337,14 @@ export class NetworkConnectionBannerController extends BaseController< } /** - * Starts evaluating network connection state. Call this when the wallet - * UI that consumes the banner becomes active (typically when the wallet - * is unlocked and the home surface mounts) so timers do not run while - * the user is not looking at the wallet. Idempotent. + * Look for a failed network, if any, and populate the initial state of the + * banner. Reacts to upstream state changes from this point on. + * + * Call this when the wallet UI that consumes the banner becomes active + * (typically when the wallet is unlocked and the home surface mounts) so + * timers do not run while the user is not looking at the wallet. Should + * be called after `NetworkController`, `NetworkEnablementController`, and + * `ConnectivityController` have been initialized. Idempotent. */ start(): void { if (this.#started) { @@ -363,15 +383,14 @@ export class NetworkConnectionBannerController extends BaseController< } /** - * Switches the chain's default RPC endpoint to its first Infura endpoint, + * Switches the chain's default RPC endpoint to its Infura endpoint, * causing the banner to clear once the network becomes available again. * - * @param args - The arguments to this action. - * @param args.chainId - The chain whose default RPC should be switched. + * @param chainId - The chain whose default RPC endpoint should be switched. * @throws If the chain configuration cannot be found, or if it has no * Infura endpoint to switch to, or if the default is already Infura. */ - async switchToDefaultInfuraRpc({ chainId }: { chainId: Hex }): Promise { + async switchToDefaultInfuraRpcEndpoint(chainId: Hex): Promise { const networkConfiguration = this.messenger.call( 'NetworkController:getNetworkConfigurationByChainId', chainId, @@ -416,7 +435,6 @@ export class NetworkConnectionBannerController extends BaseController< } const failedNetwork = this.#findFailedNetwork(); - if (!failedNetwork) { this.#resetBanner(); return; @@ -563,12 +581,12 @@ export class NetworkConnectionBannerController extends BaseController< // chain that we could offer to switch to. let switchableInfuraNetworkClientId: string | null = null; if (!isInfuraEndpoint) { - const otherInfura = rpcEndpoints.find( + const infuraEndpoint = rpcEndpoints.find( (endpoint, index) => index !== defaultRpcEndpointIndex && getIsInfuraEndpoint(endpoint.url), ); - switchableInfuraNetworkClientId = otherInfura?.networkClientId ?? null; + switchableInfuraNetworkClientId = infuraEndpoint?.networkClientId ?? null; } return { @@ -613,15 +631,6 @@ export class NetworkConnectionBannerController extends BaseController< } } -type NetworkWithMetadata = { - chainId: Hex; - name: string; - rpcEndpoints: NetworkConfiguration['rpcEndpoints']; - defaultRpcEndpointIndex: number; - defaultRpcEndpoint: NetworkConfiguration['rpcEndpoints'][number]; - metadata: NetworkMetadata; -}; - /** * Whether an RPC URL is a MetaMask Infura endpoint. Matches the * `{infuraProjectId}` placeholder form that lives on stored network diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts index b15a1e615c..e321ffec60 100644 --- a/packages/network-connection-banner-controller/src/index.ts +++ b/packages/network-connection-banner-controller/src/index.ts @@ -13,7 +13,7 @@ export type { NetworkConnectionBannerControllerStartAction, NetworkConnectionBannerControllerStopAction, NetworkConnectionBannerControllerDismissBannerAction, - NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcAction, + NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction, } from './NetworkConnectionBannerController-method-action-types'; export { NetworkConnectionBannerController, From 09c7ab3fd728ea324e940d43a14cbd011da6fab6 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 22:50:41 +0200 Subject: [PATCH 39/48] refactor(test): rename test helpers per review - `StubbedState` type becomes `ExternalState` with controller name keys (`NetworkController`, `NetworkEnablementController`, `ConnectivityController`) that match the messenger namespace. - Rework `BuildExternalStateArgs` to reference peer state types directly and use property names that match `NetworkController` state (`networkConfigurationsByChainId`, `networksMetadata`, `enabledEvmChainIds` since we are strictly on the EIP 155 namespace). - `buildEnablementState` becomes `buildNetworkEnablementControllerState` to match the actual controller name. - `makeMetadata` becomes `buildNetworkMetadata`, and `buildConfiguration` becomes `buildNetworkConfiguration` for consistency with the other `build*` helpers. - `buildNetworkState` becomes `buildExternalState`, `setNetworkState` becomes `publishNetworkStateChanges`, and the `initialState` argument of `withController` becomes `externalState`. - Rename `describe` blocks to match the event they exercise: `on NetworkController:stateChange` and `on ConnectivityController:stateChange`. --- .../NetworkConnectionBannerController.test.ts | 620 +++++++++--------- 1 file changed, 309 insertions(+), 311 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 521dca5633..929096006a 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -48,7 +48,7 @@ function buildCustomEndpoint( }; } -function buildConfiguration( +function buildNetworkConfiguration( overrides: Partial & Pick, ): NetworkConfiguration { @@ -123,9 +123,9 @@ describe('NetworkConnectionBannerController', () => { describe('start / stop', () => { it('does not evaluate existing upstream state before start', async () => { - const initialState = buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + const externalState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -137,9 +137,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }); @@ -149,15 +149,15 @@ describe('NetworkConnectionBannerController', () => { expect(controller.state.status).toBe('available'); }, - initialState, + externalState, false, ); }); it('evaluates existing upstream state on start', async () => { - const initialState = buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + const externalState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -169,9 +169,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }); @@ -184,18 +184,18 @@ describe('NetworkConnectionBannerController', () => { expect(controller.state.status).toBe('degraded'); }, - initialState, + externalState, false, ); }); it('ignores upstream state changes before start', async () => { await withController( - ({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + ({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -207,9 +207,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -229,11 +229,11 @@ describe('NetworkConnectionBannerController', () => { }); it('cancels a pending banner and resets state on stop', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -245,9 +245,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -267,12 +267,12 @@ describe('NetworkConnectionBannerController', () => { }); it('ignores upstream state changes after stop', async () => { - await withController(({ controller, setNetworkState }) => { + await withController(({ controller, publishNetworkStateChanges }) => { controller.stop(); - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -284,9 +284,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -299,13 +299,13 @@ describe('NetworkConnectionBannerController', () => { }); it('resumes evaluation when start is called again after stop', async () => { - await withController(({ controller, setNetworkState }) => { + await withController(({ controller, publishNetworkStateChanges }) => { controller.stop(); - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -317,9 +317,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -351,13 +351,13 @@ describe('NetworkConnectionBannerController', () => { it('bails out when a stateChanged listener calls stop synchronously during refresh', async () => { await withController( - ({ controller, controllerMessenger, setNetworkState }) => { + ({ controller, controllerMessenger, publishNetworkStateChanges }) => { // Escalate the banner to `unavailable` so state is non default and the // next refresh's pre timer `update` actually mutates state. - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -369,9 +369,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -393,10 +393,10 @@ describe('NetworkConnectionBannerController', () => { // Trigger a refresh whose pre timer `update` will fire `stateChanged` // (previous state was `unavailable`/polygon → available/null). - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -406,9 +406,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -424,7 +424,7 @@ describe('NetworkConnectionBannerController', () => { it('bails out when a stateChanged listener calls stop synchronously at the degraded fire', async () => { await withController( - ({ controller, controllerMessenger, setNetworkState }) => { + ({ controller, controllerMessenger, publishNetworkStateChanges }) => { controllerMessenger.subscribe( 'NetworkConnectionBannerController:stateChanged', (state) => { @@ -434,10 +434,10 @@ describe('NetworkConnectionBannerController', () => { }, ); - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -449,9 +449,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -471,19 +471,19 @@ describe('NetworkConnectionBannerController', () => { }); }); - describe('rule evaluation on NetworkController:stateChange', () => { + describe('on NetworkController:stateChange', () => { it('does not show the banner when only one Infura network is failing alongside healthy peers (single-provider blip)', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), - '0xaa36a7': buildConfiguration({ + '0xaa36a7': buildNetworkConfiguration({ chainId: '0xaa36a7', name: 'Sepolia', nativeCurrency: 'SepoliaETH', @@ -492,9 +492,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), - [SEPOLIA_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), }, }), ); @@ -509,17 +509,17 @@ describe('NetworkConnectionBannerController', () => { }); it('does not show the banner when many Infura networks are failing simultaneously alongside a healthy peer on another domain', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), - '0xaa36a7': buildConfiguration({ + '0xaa36a7': buildNetworkConfiguration({ chainId: '0xaa36a7', name: 'Sepolia', nativeCurrency: 'SepoliaETH', @@ -527,7 +527,7 @@ describe('NetworkConnectionBannerController', () => { buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), ], }), - '0x89': buildConfiguration({ + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -539,10 +539,10 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), - [SEPOLIA_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), }, }), ); @@ -554,17 +554,17 @@ describe('NetworkConnectionBannerController', () => { }); it('shows the banner when failures span two different registrable domains', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), - '0xa4b1': buildConfiguration({ + '0xa4b1': buildNetworkConfiguration({ chainId: '0xa4b1', name: 'Arbitrum One', nativeCurrency: 'ETH', @@ -576,9 +576,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), - [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -604,17 +604,17 @@ describe('NetworkConnectionBannerController', () => { }); it('shows the banner when a single custom RPC fails amid healthy Infura peers (custom override)', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), - '0x89': buildConfiguration({ + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -626,9 +626,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Available), - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -646,20 +646,20 @@ describe('NetworkConnectionBannerController', () => { }); it('shows the banner when every enabled network is failing on a single domain (all-down escape hatch)', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -675,17 +675,17 @@ describe('NetworkConnectionBannerController', () => { }); it('ignores enabled networks with missing metadata when every known network is failing', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), - '0xaa36a7': buildConfiguration({ + '0xaa36a7': buildNetworkConfiguration({ chainId: '0xaa36a7', name: 'Sepolia', nativeCurrency: 'SepoliaETH', @@ -694,8 +694,8 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -710,17 +710,17 @@ describe('NetworkConnectionBannerController', () => { }); it('prefers a custom failure over an Infura one when surfacing the banner network', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), - '0x89': buildConfiguration({ + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -732,9 +732,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -748,8 +748,8 @@ describe('NetworkConnectionBannerController', () => { }); it('only updates the failed-network detail (not the timers) when the same chain keeps failing across re-evaluations', async () => { - await withController(({ controller, setNetworkState }) => { - const config = buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + const config = buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -758,12 +758,12 @@ describe('NetworkConnectionBannerController', () => { ), ], }); - setNetworkState( - buildNetworkState({ - configurations: { '0x1': config }, - enabledChainIds: ['0x1'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -774,12 +774,12 @@ describe('NetworkConnectionBannerController', () => { expect(controller.state.status).toBe('degraded'); // Same chain still failing — should be a no-op update (no timer reset). - setNetworkState( - buildNetworkState({ - configurations: { '0x1': config }, - enabledChainIds: ['0x1'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Blocked), + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Blocked), }, }), ); @@ -792,10 +792,10 @@ describe('NetworkConnectionBannerController', () => { }); it('does not restart the degraded timer when the same network fails across re-evaluations', async () => { - await withController(({ controller, setNetworkState }) => { - const failingState = buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + const failingState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -807,16 +807,16 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }); - setNetworkState(failingState); + publishNetworkStateChanges(failingState); jest.advanceTimersByTime(4_000); - setNetworkState(failingState); + publishNetworkStateChanges(failingState); jest.advanceTimersByTime(1_000); expect(controller.state.status).toBe('degraded'); @@ -824,11 +824,11 @@ describe('NetworkConnectionBannerController', () => { }); it('cancels the banner if the network recovers between the degraded-timer scheduling and its firing', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -840,9 +840,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -854,10 +854,10 @@ describe('NetworkConnectionBannerController', () => { // Network recovers in the meantime. The next state-change clears // the timer. - setNetworkState( - buildNetworkState({ - configurations: { - '0x89': buildConfiguration({ + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -869,9 +869,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), }, }), ); @@ -882,20 +882,22 @@ describe('NetworkConnectionBannerController', () => { }); it('skips enabled chains that have no network configuration', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState({ - network: { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges({ + NetworkController: { networkConfigurationsByChainId: {}, networksMetadata: {}, }, - enablement: buildEnablementState({ + NetworkEnablementController: buildNetworkEnablementControllerState({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: { '0x1': true, }, }, }), - connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, }); jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); @@ -903,8 +905,8 @@ describe('NetworkConnectionBannerController', () => { }); it('clears banner state when all enabled networks recover', async () => { - await withController(({ controller, setNetworkState }) => { - const failingConfig = buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + const failingConfig = buildNetworkConfiguration({ chainId: '0x89', name: 'Polygon Mainnet', nativeCurrency: 'MATIC', @@ -915,12 +917,12 @@ describe('NetworkConnectionBannerController', () => { ), ], }); - setNetworkState( - buildNetworkState({ - configurations: { '0x89': failingConfig }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x89': failingConfig }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -930,12 +932,12 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); - setNetworkState( - buildNetworkState({ - configurations: { '0x89': failingConfig }, - enabledChainIds: ['0x89'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata(NetworkStatus.Available), + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x89': failingConfig }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), }, }), ); @@ -948,20 +950,20 @@ describe('NetworkConnectionBannerController', () => { }); it('treats an unparseable RPC URL as non-Infura when classifying failures', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint(MAINNET_CLIENT_ID, 'not a valid url'), ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [MAINNET_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -975,14 +977,16 @@ describe('NetworkConnectionBannerController', () => { }); it('keeps the banner hidden when the enablement map has no EVM namespace at all', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState({ - network: { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges({ + NetworkController: { networkConfigurationsByChainId: {}, networksMetadata: {}, }, - enablement: buildEnablementState(), - connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, + NetworkEnablementController: buildNetworkEnablementControllerState(), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, }); jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); @@ -990,10 +994,10 @@ describe('NetworkConnectionBannerController', () => { }); it('skips configurations whose default RPC endpoint is missing', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': { chainId: '0x1', name: 'Broken', @@ -1004,7 +1008,7 @@ describe('NetworkConnectionBannerController', () => { defaultBlockExplorerUrlIndex: 0, }, }, - enabledChainIds: ['0x1'], + enabledEvmChainIds: ['0x1'], }), ); jest.advanceTimersByTime(30_000); @@ -1013,11 +1017,11 @@ describe('NetworkConnectionBannerController', () => { }); it('reports the Infura endpoint to switch to when the failing network has one', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -1028,9 +1032,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -1047,7 +1051,7 @@ describe('NetworkConnectionBannerController', () => { }); }); - describe('ConnectivityController integration', () => { + describe('on ConnectivityController:stateChange', () => { it('does not touch banner state when going offline while no banner is shown', async () => { await withController(({ controller, setConnectivityStatus }) => { const before = controller.state; @@ -1058,11 +1062,11 @@ describe('NetworkConnectionBannerController', () => { it('suppresses the banner while the device is offline and reinstates it when back online', async () => { await withController( - ({ controller, setNetworkState, setConnectivityStatus }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + ({ controller, publishNetworkStateChanges, setConnectivityStatus }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -1072,9 +1076,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -1106,11 +1110,11 @@ describe('NetworkConnectionBannerController', () => { }); it('clears banner state via direct call', async () => { - await withController(({ controller, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -1120,9 +1124,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -1138,11 +1142,11 @@ describe('NetworkConnectionBannerController', () => { }); it('clears banner state via messenger action', async () => { - await withController(({ controller, rootMessenger, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(({ controller, rootMessenger, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -1152,9 +1156,9 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x1'], - metadata: { - [POLYGON_CUSTOM_CLIENT_ID]: makeMetadata( + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, @@ -1171,8 +1175,8 @@ describe('NetworkConnectionBannerController', () => { describe('switchToDefaultInfuraRpcEndpoint', () => { it('invokes NetworkController:updateNetwork with the Infura endpoint as the new default', async () => { await withController( - async ({ rootMessenger, setNetworkState, updateNetwork }) => { - const config = buildConfiguration({ + async ({ rootMessenger, publishNetworkStateChanges, updateNetwork }) => { + const config = buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -1182,12 +1186,12 @@ describe('NetworkConnectionBannerController', () => { buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }); - setNetworkState( - buildNetworkState({ - configurations: { '0x1': config }, - enabledChainIds: ['0x1'], - metadata: { - [ALCHEMY_CLIENT_ID]: makeMetadata(NetworkStatus.Unavailable), + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), }, }), ); @@ -1209,18 +1213,18 @@ describe('NetworkConnectionBannerController', () => { it('is a no-op when the default is already Infura', async () => { await withController( - async ({ rootMessenger, setNetworkState, updateNetwork }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + async ({ rootMessenger, publishNetworkStateChanges, updateNetwork }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), ], }), }, - enabledChainIds: ['0x1'], + enabledEvmChainIds: ['0x1'], }), ); @@ -1246,11 +1250,11 @@ describe('NetworkConnectionBannerController', () => { }); it('throws when the chain has no Infura endpoint to switch to', async () => { - await withController(async ({ rootMessenger, setNetworkState }) => { - setNetworkState( - buildNetworkState({ - configurations: { - '0x1': buildConfiguration({ + await withController(async ({ rootMessenger, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ buildCustomEndpoint( @@ -1260,7 +1264,7 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledChainIds: ['0x1'], + enabledEvmChainIds: ['0x1'], }), ); @@ -1279,7 +1283,7 @@ describe('NetworkConnectionBannerController', () => { // Test helpers // --------------------------------------------------------------------------- -function makeMetadata(status: NetworkStatus): { +function buildNetworkMetadata(status: NetworkStatus): { // eslint-disable-next-line @typescript-eslint/naming-convention EIPS: Record; status: NetworkStatus; @@ -1287,26 +1291,19 @@ function makeMetadata(status: NetworkStatus): { return { EIPS: {}, status }; } -type BuildNetworkStateArgs = { - configurations: Record; - metadata?: Record< - string, - { - // eslint-disable-next-line @typescript-eslint/naming-convention - EIPS: Record; - status: NetworkStatus; - } - >; - enabledChainIds?: Hex[]; +type BuildExternalStateArgs = { + networkConfigurationsByChainId?: NetworkState['networkConfigurationsByChainId']; + networksMetadata?: NetworkState['networksMetadata']; + enabledEvmChainIds?: Hex[]; }; -type StubbedState = { - network: Partial; - enablement: NetworkEnablementControllerState; - connectivity: ConnectivityControllerState; +type ExternalState = { + NetworkController: Partial; + NetworkEnablementController: NetworkEnablementControllerState; + ConnectivityController: ConnectivityControllerState; }; -function buildEnablementState( +function buildNetworkEnablementControllerState( overrides: Partial = {}, ): NetworkEnablementControllerState { return { @@ -1316,25 +1313,24 @@ function buildEnablementState( }; } -function buildNetworkState({ - configurations, - metadata = {}, - enabledChainIds, -}: BuildNetworkStateArgs): StubbedState { - const allChainIds = enabledChainIds ?? (Object.keys(configurations) as Hex[]); +function buildExternalState({ + networkConfigurationsByChainId = {}, + networksMetadata = {}, + enabledEvmChainIds = Object.keys(networkConfigurationsByChainId) as Hex[], +}: BuildExternalStateArgs = {}): ExternalState { return { - network: { - networkConfigurationsByChainId: configurations, - networksMetadata: metadata, + NetworkController: { + networkConfigurationsByChainId, + networksMetadata, }, - enablement: buildEnablementState({ + NetworkEnablementController: buildNetworkEnablementControllerState({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: Object.fromEntries( - allChainIds.map((chainId) => [chainId, true]), + enabledEvmChainIds.map((chainId) => [chainId, true]), ), }, }), - connectivity: { + ConnectivityController: { connectivityStatus: CONNECTIVITY_STATUSES.Online, }, }; @@ -1355,7 +1351,7 @@ type WithControllerCallback = (payload: { controller: NetworkConnectionBannerController; rootMessenger: RootMessenger; controllerMessenger: NetworkConnectionBannerControllerMessenger; - setNetworkState: (state: StubbedState) => void; + publishNetworkStateChanges: (state: ExternalState) => void; setConnectivityStatus: ( status: ConnectivityControllerState['connectivityStatus'], ) => void; @@ -1364,36 +1360,38 @@ type WithControllerCallback = (payload: { async function withController( testFunction: WithControllerCallback, - initialState?: StubbedState, + externalState?: ExternalState, start = true, ): Promise { const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); - let currentState: StubbedState = - initialState ?? + let currentState: ExternalState = + externalState ?? ({ - network: { + NetworkController: { networkConfigurationsByChainId: {}, networksMetadata: {}, }, - enablement: buildEnablementState(), - connectivity: { connectivityStatus: CONNECTIVITY_STATUSES.Online }, - } satisfies StubbedState); + NetworkEnablementController: buildNetworkEnablementControllerState(), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + } satisfies ExternalState); rootMessenger.registerActionHandler( 'NetworkController:getState', - () => currentState.network as NetworkState, + () => currentState.NetworkController as NetworkState, ); rootMessenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByChainId', - (chainId) => currentState.network.networkConfigurationsByChainId?.[chainId], + (chainId) => currentState.NetworkController.networkConfigurationsByChainId?.[chainId], ); const updateNetwork = jest.fn( async (chainId: Hex): Promise => - currentState.network.networkConfigurationsByChainId?.[chainId] ?? - buildConfiguration({ chainId }), + currentState.NetworkController.networkConfigurationsByChainId?.[chainId] ?? + buildNetworkConfiguration({ chainId }), ); rootMessenger.registerActionHandler( 'NetworkController:updateNetwork', @@ -1402,12 +1400,12 @@ async function withController( rootMessenger.registerActionHandler( 'NetworkEnablementController:getState', - () => currentState.enablement, + () => currentState.NetworkEnablementController, ); rootMessenger.registerActionHandler( 'ConnectivityController:getState', - () => currentState.connectivity, + () => currentState.ConnectivityController, ); const messenger = new Messenger< @@ -1446,16 +1444,16 @@ async function withController( controller.start(); } - const setNetworkState = (state: StubbedState): void => { + const publishNetworkStateChanges = (state: ExternalState): void => { currentState = state; rootMessenger.publish( 'NetworkController:stateChange', - currentState.network as NetworkState, + currentState.NetworkController as NetworkState, [], ); rootMessenger.publish( 'NetworkEnablementController:stateChange', - currentState.enablement, + currentState.NetworkEnablementController, [], ); }; @@ -1465,11 +1463,11 @@ async function withController( ): void => { currentState = { ...currentState, - connectivity: { connectivityStatus: status }, + ConnectivityController: { connectivityStatus: status }, }; rootMessenger.publish( 'ConnectivityController:stateChange', - currentState.connectivity, + currentState.ConnectivityController, [], ); }; @@ -1478,7 +1476,7 @@ async function withController( controller, rootMessenger, controllerMessenger: messenger, - setNetworkState, + publishNetworkStateChanges, setConnectivityStatus, updateNetwork, }); From 699b24f5f4250c458002f77789fc2c66bc0e26a8 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 22:51:26 +0200 Subject: [PATCH 40/48] chore: alphabetize CODEOWNERS entry for banner controller Moves `network-connection-banner-controller` to its alphabetically sorted position after `multichain-api-middleware`. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6a044600aa..bc762944c9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -98,6 +98,7 @@ /packages/messenger @MetaMask/core-platform /packages/messenger-cli @MetaMask/core-platform /packages/multichain-api-middleware @MetaMask/core-platform +/packages/network-connection-banner-controller @MetaMask/core-platform /packages/permission-controller @MetaMask/core-platform /packages/permission-log-controller @MetaMask/core-platform /packages/platform-api-docs @MetaMask/core-platform @@ -110,7 +111,6 @@ /packages/wallet @MetaMask/core-platform /packages/wallet-cli @MetaMask/core-platform @MetaMask/ocap-kernel /packages/wallet-framework-docs @MetaMask/core-platform -/packages/network-connection-banner-controller @MetaMask/core-platform ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth From 5c2329f709bc4406e34559d8b7d9272a0476910c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:05:41 +0200 Subject: [PATCH 41/48] test: split state setters per peer and cover NEC:stateChange Adds `setNetworkControllerState` and `setNetworkEnablementControllerState` so tests that specifically want to exercise one peer's `stateChange` event can publish only that event, matching the code path they claim to cover. `publishNetworkStateChanges` stays as a setup convenience for tests that want to seed both at once. The `start / stop` describe now uses `setNetworkControllerState` directly, moving the enablement setup to the `externalState` argument of `withController`. Adds an `on NetworkEnablementController:stateChange` describe block with coverage for enabling a failing chain and disabling one whose banner is already showing. --- .../NetworkConnectionBannerController.test.ts | 258 +++++++++++++----- 1 file changed, 195 insertions(+), 63 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 929096006a..6b7c7ec9da 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -191,30 +191,27 @@ describe('NetworkConnectionBannerController', () => { it('ignores upstream state changes before start', async () => { await withController( - ({ controller, publishNetworkStateChanges }) => { - publishNetworkStateChanges( - buildExternalState({ - networkConfigurationsByChainId: { - '0x89': buildNetworkConfiguration({ - chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', - rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), - ], - }), - }, - enabledEvmChainIds: ['0x89'], - networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( - NetworkStatus.Unavailable, - ), - }, - }), - ); + ({ controller, setNetworkControllerState }) => { + setNetworkControllerState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); @@ -223,15 +220,15 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); }, - undefined, + buildExternalState({ enabledEvmChainIds: ['0x89'] }), false, ); }); it('cancels a pending banner and resets state on stop', async () => { - await withController(({ controller, publishNetworkStateChanges }) => { - publishNetworkStateChanges( - buildExternalState({ + await withController( + ({ controller, setNetworkControllerState }) => { + setNetworkControllerState({ networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', @@ -245,32 +242,32 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledEvmChainIds: ['0x89'], networksMetadata: { [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, - }), - ); + }); - jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('degraded'); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); - controller.stop(); - jest.advanceTimersByTime(30_000); - expect(controller.state).toStrictEqual({ - status: 'available', - network: null, - }); - }); + controller.stop(); + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }, + buildExternalState({ enabledEvmChainIds: ['0x89'] }), + ); }); it('ignores upstream state changes after stop', async () => { - await withController(({ controller, publishNetworkStateChanges }) => { - controller.stop(); - publishNetworkStateChanges( - buildExternalState({ + await withController( + ({ controller, setNetworkControllerState }) => { + controller.stop(); + setNetworkControllerState({ networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', @@ -284,26 +281,26 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledEvmChainIds: ['0x89'], networksMetadata: { [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, - }), - ); + }); - jest.advanceTimersByTime(30_000); - expect(controller.state.status).toBe('available'); - }); + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }, + buildExternalState({ enabledEvmChainIds: ['0x89'] }), + ); }); it('resumes evaluation when start is called again after stop', async () => { - await withController(({ controller, publishNetworkStateChanges }) => { - controller.stop(); + await withController( + ({ controller, setNetworkControllerState }) => { + controller.stop(); - publishNetworkStateChanges( - buildExternalState({ + setNetworkControllerState({ networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', @@ -317,21 +314,21 @@ describe('NetworkConnectionBannerController', () => { ], }), }, - enabledEvmChainIds: ['0x89'], networksMetadata: { [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), }, - }), - ); + }); - expect(controller.state.status).toBe('available'); + expect(controller.state.status).toBe('available'); - controller.start(); - jest.advanceTimersByTime(5_000); - expect(controller.state.status).toBe('degraded'); - }); + controller.start(); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, + buildExternalState({ enabledEvmChainIds: ['0x89'] }), + ); }); it('stop is idempotent when never started', async () => { @@ -1051,6 +1048,96 @@ describe('NetworkConnectionBannerController', () => { }); }); + describe('on NetworkEnablementController:stateChange', () => { + it('re-evaluates the rule when a failing chain becomes enabled', async () => { + await withController( + ({ controller, setNetworkEnablementControllerState }) => { + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + + setNetworkEnablementControllerState( + buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x89': true, + }, + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + enabledEvmChainIds: [], + }), + ); + }); + + it('clears the banner when the failing chain gets disabled', async () => { + await withController( + ({ controller, setNetworkEnablementControllerState }) => { + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('unavailable'); + + setNetworkEnablementControllerState( + buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x89': false, + }, + }, + }), + ); + + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }, + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomEndpoint( + POLYGON_CUSTOM_CLIENT_ID, + 'https://polygon-rpc.com', + ), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + enabledEvmChainIds: ['0x89'], + }), + ); + }); + }); + describe('on ConnectivityController:stateChange', () => { it('does not touch banner state when going offline while no banner is shown', async () => { await withController(({ controller, setConnectivityStatus }) => { @@ -1351,6 +1438,12 @@ type WithControllerCallback = (payload: { controller: NetworkConnectionBannerController; rootMessenger: RootMessenger; controllerMessenger: NetworkConnectionBannerControllerMessenger; + setNetworkControllerState: ( + networkControllerState: Partial, + ) => void; + setNetworkEnablementControllerState: ( + networkEnablementControllerState: NetworkEnablementControllerState, + ) => void; publishNetworkStateChanges: (state: ExternalState) => void; setConnectivityStatus: ( status: ConnectivityControllerState['connectivityStatus'], @@ -1444,8 +1537,45 @@ async function withController( controller.start(); } + const setNetworkControllerState = ( + networkControllerState: Partial, + ): void => { + currentState = { + ...currentState, + NetworkController: networkControllerState, + }; + rootMessenger.publish( + 'NetworkController:stateChange', + currentState.NetworkController as NetworkState, + [], + ); + }; + + const setNetworkEnablementControllerState = ( + networkEnablementControllerState: NetworkEnablementControllerState, + ): void => { + currentState = { + ...currentState, + NetworkEnablementController: networkEnablementControllerState, + }; + rootMessenger.publish( + 'NetworkEnablementController:stateChange', + currentState.NetworkEnablementController, + [], + ); + }; + + // Setup convenience for tests that want to seed both `NetworkController` + // and `NetworkEnablementController` at once. Tests exercising a specific + // peer event should reach for `setNetworkControllerState` / + // `setNetworkEnablementControllerState` instead so the event they publish + // matches the code path they claim to cover. const publishNetworkStateChanges = (state: ExternalState): void => { - currentState = state; + currentState = { + ...currentState, + NetworkController: state.NetworkController, + NetworkEnablementController: state.NetworkEnablementController, + }; rootMessenger.publish( 'NetworkController:stateChange', currentState.NetworkController as NetworkState, @@ -1476,6 +1606,8 @@ async function withController( controller, rootMessenger, controllerMessenger: messenger, + setNetworkControllerState, + setNetworkEnablementControllerState, publishNetworkStateChanges, setConnectivityStatus, updateNetwork, From f96e665b908f30e281472eed275d41044d9d8507 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:08:54 +0200 Subject: [PATCH 42/48] test: move endpoint builders to bottom with options bag args `buildInfuraEndpoint` and `buildCustomEndpoint` now live with the other test helpers at the bottom of the file and take options bags so the network client id is called out by name at every call site. --- .../NetworkConnectionBannerController.test.ts | 210 ++++++------------ 1 file changed, 69 insertions(+), 141 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 6b7c7ec9da..6ee2759e4f 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -26,28 +26,6 @@ const SEPOLIA_CLIENT_ID = 'sepolia' satisfies BuiltInNetworkClientId; const POLYGON_CUSTOM_CLIENT_ID = 'polygon-custom'; const ALCHEMY_CLIENT_ID = 'eth-alchemy'; -function buildInfuraEndpoint( - networkClientId: BuiltInNetworkClientId, - infuraNetworkType: BuiltInNetworkClientId, -): InfuraRpcEndpoint { - return { - networkClientId, - type: RpcEndpointType.Infura, - url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, - }; -} - -function buildCustomEndpoint( - networkClientId: string, - url: string, -): NetworkConfiguration['rpcEndpoints'][number] { - return { - networkClientId, - type: RpcEndpointType.Custom, - url, - }; -} - function buildNetworkConfiguration( overrides: Partial & Pick, @@ -55,7 +33,7 @@ function buildNetworkConfiguration( return { name: 'Ethereum Mainnet', nativeCurrency: 'ETH', - rpcEndpoints: [buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet')], + rpcEndpoints: [buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' })], defaultRpcEndpointIndex: 0, blockExplorerUrls: [], defaultBlockExplorerUrlIndex: 0, @@ -130,10 +108,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -162,10 +137,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -199,10 +171,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -235,10 +204,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -274,10 +240,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -307,10 +270,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -359,10 +319,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -396,10 +353,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - ALCHEMY_CLIENT_ID, - 'https://eth-mainnet.alchemyapi.io/v2/abc', - ), + buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), ], }), }, @@ -439,10 +393,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -477,7 +428,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), '0xaa36a7': buildNetworkConfiguration({ @@ -485,7 +436,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Sepolia', nativeCurrency: 'SepoliaETH', rpcEndpoints: [ - buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), + buildInfuraEndpoint({ networkClientId: SEPOLIA_CLIENT_ID, infuraNetworkType: 'sepolia' }), ], }), }, @@ -513,7 +464,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), '0xaa36a7': buildNetworkConfiguration({ @@ -521,7 +472,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Sepolia', nativeCurrency: 'SepoliaETH', rpcEndpoints: [ - buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), + buildInfuraEndpoint({ networkClientId: SEPOLIA_CLIENT_ID, infuraNetworkType: 'sepolia' }), ], }), '0x89': buildNetworkConfiguration({ @@ -529,10 +480,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -558,7 +506,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), '0xa4b1': buildNetworkConfiguration({ @@ -566,10 +514,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Arbitrum One', nativeCurrency: 'ETH', rpcEndpoints: [ - buildCustomEndpoint( - ALCHEMY_CLIENT_ID, - 'https://arb-mainnet.g.alchemy.com/v2/abc', - ), + buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://arb-mainnet.g.alchemy.com/v2/abc' }), ], }), }, @@ -608,7 +553,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), '0x89': buildNetworkConfiguration({ @@ -616,10 +561,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -650,7 +592,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), }, @@ -679,7 +621,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), '0xaa36a7': buildNetworkConfiguration({ @@ -687,7 +629,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Sepolia', nativeCurrency: 'SepoliaETH', rpcEndpoints: [ - buildInfuraEndpoint(SEPOLIA_CLIENT_ID, 'sepolia'), + buildInfuraEndpoint({ networkClientId: SEPOLIA_CLIENT_ID, infuraNetworkType: 'sepolia' }), ], }), }, @@ -714,7 +656,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), '0x89': buildNetworkConfiguration({ @@ -722,10 +664,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -749,10 +688,7 @@ describe('NetworkConnectionBannerController', () => { const config = buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }); publishNetworkStateChanges( @@ -797,10 +733,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -830,10 +763,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -859,10 +789,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -908,10 +835,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }); publishNetworkStateChanges( @@ -954,7 +878,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint(MAINNET_CLIENT_ID, 'not a valid url'), + buildCustomEndpoint({ networkClientId: MAINNET_CLIENT_ID, url: 'not a valid url' }), ], }), }, @@ -1021,11 +945,8 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - ALCHEMY_CLIENT_ID, - 'https://eth-mainnet.alchemyapi.io/v2/abc', - ), - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), }, @@ -1075,10 +996,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -1120,10 +1038,7 @@ describe('NetworkConnectionBannerController', () => { name: 'Polygon Mainnet', nativeCurrency: 'MATIC', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -1156,10 +1071,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -1204,10 +1116,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -1236,10 +1145,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - POLYGON_CUSTOM_CLIENT_ID, - 'https://polygon-rpc.com', - ), + buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], }), }, @@ -1266,11 +1172,8 @@ describe('NetworkConnectionBannerController', () => { const config = buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - ALCHEMY_CLIENT_ID, - 'https://eth-mainnet.alchemyapi.io/v2/abc', - ), - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }); publishNetworkStateChanges( @@ -1307,7 +1210,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint(MAINNET_CLIENT_ID, 'mainnet'), + buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), ], }), }, @@ -1344,10 +1247,7 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint( - ALCHEMY_CLIENT_ID, - 'https://eth-mainnet.alchemyapi.io/v2/abc', - ), + buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), ], }), }, @@ -1613,3 +1513,31 @@ async function withController( updateNetwork, }); } + +function buildInfuraEndpoint({ + networkClientId, + infuraNetworkType, +}: { + networkClientId: BuiltInNetworkClientId; + infuraNetworkType: BuiltInNetworkClientId; +}): InfuraRpcEndpoint { + return { + networkClientId, + type: RpcEndpointType.Infura, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }; +} + +function buildCustomEndpoint({ + networkClientId, + url, +}: { + networkClientId: string; + url: string; +}): NetworkConfiguration['rpcEndpoints'][number] { + return { + networkClientId, + type: RpcEndpointType.Custom, + url, + }; +} From 772d8dcee4eda9d89639f1ea77ab48e35452ec18 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:17:32 +0200 Subject: [PATCH 43/48] refactor: rename #started to #isStarted for boolean convention --- .../src/NetworkConnectionBannerController.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 9b6e9455f9..1044029876 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -294,7 +294,7 @@ export class NetworkConnectionBannerController extends BaseController< #pendingNetworkClientId: string | undefined; - #started = false; + #isStarted = false; /** * Constructs a new {@link NetworkConnectionBannerController}. @@ -347,11 +347,11 @@ export class NetworkConnectionBannerController extends BaseController< * `ConnectivityController` have been initialized. Idempotent. */ start(): void { - if (this.#started) { + if (this.#isStarted) { return; } - this.#started = true; + this.#isStarted = true; this.#refreshState(); } @@ -361,16 +361,16 @@ export class NetworkConnectionBannerController extends BaseController< * consuming the banner is no longer active. Idempotent. */ stop(): void { - if (!this.#started) { + if (!this.#isStarted) { return; } - this.#started = false; + this.#isStarted = false; this.#resetBanner(); } #onUpstreamChange(): void { - if (this.#started) { + if (this.#isStarted) { this.#refreshState(); } } @@ -462,7 +462,7 @@ export class NetworkConnectionBannerController extends BaseController< // A synchronous listener on our `stateChanged` event above may have // called `stop()` re-entrantly. Bail before scheduling anything. - if (!this.#started) { + if (!this.#isStarted) { return; } @@ -480,7 +480,7 @@ export class NetworkConnectionBannerController extends BaseController< }); // A synchronous listener on our `stateChanged` event above may have // called `stop()` re-entrantly. Bail before scheduling the escalation. - if (!this.#started) { + if (!this.#isStarted) { return; } this.#unavailableTimer = setTimeout(() => { From aa8e9234b172e9c856e0134c4fd76061033d56cb Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:20:03 +0200 Subject: [PATCH 44/48] test: drop unused name and nativeCurrency test config overrides Neither field influences the banner rule and no test asserts on them. Falling back to the `buildNetworkConfiguration` defaults keeps the Polygon test setups smaller without changing behavior. --- .../NetworkConnectionBannerController.test.ts | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 6ee2759e4f..e6aec06da8 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -105,8 +105,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -134,8 +132,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -168,8 +164,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -201,8 +195,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -237,8 +229,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -267,8 +257,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -316,8 +304,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -390,8 +376,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -477,8 +461,6 @@ describe('NetworkConnectionBannerController', () => { }), '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -558,8 +540,6 @@ describe('NetworkConnectionBannerController', () => { }), '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -661,8 +641,6 @@ describe('NetworkConnectionBannerController', () => { }), '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -730,8 +708,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -760,8 +736,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -786,8 +760,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -832,8 +804,6 @@ describe('NetworkConnectionBannerController', () => { await withController(({ controller, publishNetworkStateChanges }) => { const failingConfig = buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -993,8 +963,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], @@ -1035,8 +1003,6 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', rpcEndpoints: [ buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), ], From 7dcb3d974d1cace1c30f3b89dd101db52880dd6d Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:28:10 +0200 Subject: [PATCH 45/48] refactor: thread peer state through #refreshState `#refreshState` now takes optional `networkControllerState`, `networkEnablementControllerState`, and `connectivityControllerState` parameters (each `Pick`d down to the fields the rule uses), falling back to the respective `:getState` call when omitted. Subscription handlers pass their memoized projection straight in, so event driven re evaluations reuse the value the selector already computed instead of calling `getState` again. `#findFailedNetwork`, `#collectNetworksWithMetadata`, and `#getEnabledEvmChainIds` take state as arguments too. The `#isStarted` gate lives at the top of `#refreshState` now, so the old `#onUpstreamChange` shim goes away. Adds two composed selectors (`selectNetworkEnablementControllerFields`, `selectConnectivityControllerFields`) that return object projections matching the `Pick` shape. --- .../src/NetworkConnectionBannerController.ts | 124 ++++++++++++++---- 1 file changed, 97 insertions(+), 27 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index 1044029876..e5344851ee 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -10,6 +10,7 @@ import { } from '@metamask/connectivity-controller'; import type { ConnectivityControllerGetStateAction, + ConnectivityControllerState, ConnectivityControllerStateChangeEvent, } from '@metamask/connectivity-controller'; import type { Messenger } from '@metamask/messenger'; @@ -25,6 +26,7 @@ import type { import { NetworkStatus } from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerState, NetworkEnablementControllerStateChangeEvent, } from '@metamask/network-enablement-controller'; import { selectEnabledNetworkMap } from '@metamask/network-enablement-controller'; @@ -81,6 +83,30 @@ const selectNetworkControllerFields = createSelector( }), ); +/** + * Selects the `NetworkEnablementController` state field that influences the + * banner rule. + * + * @param state - The `NetworkEnablementController` state. + * @returns The relevant enablement fields. + */ +const selectNetworkEnablementControllerFields = createSelector( + [selectEnabledNetworkMap], + (enabledNetworkMap) => ({ enabledNetworkMap }), +); + +/** + * Selects the `ConnectivityController` state field that influences the + * banner rule. + * + * @param state - The `ConnectivityController` state. + * @returns The relevant connectivity fields. + */ +const selectConnectivityControllerFields = createSelector( + [connectivityControllerSelectors.selectConnectivityStatus], + (connectivityStatus) => ({ connectivityStatus }), +); + /** * Status the banner can be in. `available` means no banner is shown; the * `degraded` and `unavailable` values mirror the two-tier escalation that the @@ -315,18 +341,21 @@ export class NetworkConnectionBannerController extends BaseController< /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ this.messenger.subscribe( 'NetworkController:stateChange', - () => this.#onUpstreamChange(), + (networkControllerState) => + this.#refreshState({ networkControllerState }), selectNetworkControllerFields, ); this.messenger.subscribe( 'NetworkEnablementController:stateChange', - () => this.#onUpstreamChange(), - selectEnabledNetworkMap, + (networkEnablementControllerState) => + this.#refreshState({ networkEnablementControllerState }), + selectNetworkEnablementControllerFields, ); this.messenger.subscribe( 'ConnectivityController:stateChange', - () => this.#onUpstreamChange(), - connectivityControllerSelectors.selectConnectivityStatus, + (connectivityControllerState) => + this.#refreshState({ connectivityControllerState }), + selectConnectivityControllerFields, ); /* eslint-enable no-restricted-syntax */ @@ -369,12 +398,6 @@ export class NetworkConnectionBannerController extends BaseController< this.#resetBanner(); } - #onUpstreamChange(): void { - if (this.#isStarted) { - this.#refreshState(); - } - } - /** * Clears the banner state such that the banner will be hidden. */ @@ -425,16 +448,44 @@ export class NetworkConnectionBannerController extends BaseController< ); } - #refreshState(): void { - const { connectivityStatus } = this.messenger.call( - 'ConnectivityController:getState', - ); + #refreshState({ + networkControllerState, + networkEnablementControllerState, + connectivityControllerState, + }: { + networkControllerState?: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >; + networkEnablementControllerState?: Pick< + NetworkEnablementControllerState, + 'enabledNetworkMap' + >; + connectivityControllerState?: Pick< + ConnectivityControllerState, + 'connectivityStatus' + >; + } = {}): void { + if (!this.#isStarted) { + return; + } + + const { connectivityStatus } = + connectivityControllerState ?? + this.messenger.call('ConnectivityController:getState'); if (connectivityStatus === CONNECTIVITY_STATUSES.Offline) { this.#resetBanner(); return; } - const failedNetwork = this.#findFailedNetwork(); + const networkState = + networkControllerState ?? + this.messenger.call('NetworkController:getState'); + const enablementState = + networkEnablementControllerState ?? + this.messenger.call('NetworkEnablementController:getState'); + + const failedNetwork = this.#findFailedNetwork(networkState, enablementState); if (!failedNetwork) { this.#resetBanner(); return; @@ -519,28 +570,47 @@ export class NetworkConnectionBannerController extends BaseController< } } - #findFailedNetwork(): FailedNetwork | null { - const networksWithMetadata = this.#collectNetworksWithMetadata(); + #findFailedNetwork( + networkState: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >, + enablementState: Pick< + NetworkEnablementControllerState, + 'enabledNetworkMap' + >, + ): FailedNetwork | null { + const networksWithMetadata = this.#collectNetworksWithMetadata( + networkState, + enablementState, + ); const failedNetworks = networksWithMetadata .filter(({ metadata }) => metadata.status !== NetworkStatus.Available) .map((network) => this.#buildFailedNetwork(network)); return this.#pickBannerNetwork(failedNetworks, networksWithMetadata.length); } - #getEnabledEvmChainIds(): Hex[] { - const { enabledNetworkMap } = this.messenger.call( - 'NetworkEnablementController:getState', - ); + #getEnabledEvmChainIds( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): Hex[] { return Object.entries(enabledNetworkMap[KnownCaipNamespace.Eip155] ?? {}) .filter(([, enabled]) => enabled) .map(([chainId]) => chainId as Hex); } - #collectNetworksWithMetadata(): NetworkWithMetadata[] { - const { networkConfigurationsByChainId, networksMetadata } = - this.messenger.call('NetworkController:getState'); - - return this.#getEnabledEvmChainIds().flatMap((chainId) => { + #collectNetworksWithMetadata( + { + networkConfigurationsByChainId, + networksMetadata, + }: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >, + { + enabledNetworkMap, + }: Pick, + ): NetworkWithMetadata[] { + return this.#getEnabledEvmChainIds(enabledNetworkMap).flatMap((chainId) => { const networkConfiguration = networkConfigurationsByChainId[chainId]; if (!networkConfiguration) { return []; From 6b0fd9a7ef85153996c74348597d2145cc351131 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:31:18 +0200 Subject: [PATCH 46/48] test: switch withController to sample-controllers signature Options bag first, then test function. Matches the pattern in `sample-petnames-controller.test.ts`. Existing shorthand `withController(fn)` for the no options case keeps working via the variadic overload. --- .../NetworkConnectionBannerController.test.ts | 514 ++++++++++++------ 1 file changed, 351 insertions(+), 163 deletions(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index e6aec06da8..a5fce1141b 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -33,7 +33,12 @@ function buildNetworkConfiguration( return { name: 'Ethereum Mainnet', nativeCurrency: 'ETH', - rpcEndpoints: [buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' })], + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], defaultRpcEndpointIndex: 0, blockExplorerUrls: [], defaultBlockExplorerUrlIndex: 0, @@ -106,24 +111,28 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, enabledEvmChainIds: ['0x89'], networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }); await withController( + { externalState, start: false }, ({ controller }) => { jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); }, - externalState, - false, ); }); @@ -133,17 +142,23 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, enabledEvmChainIds: ['0x89'], networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }); await withController( + { externalState, start: false }, ({ controller, rootMessenger }) => { rootMessenger.call('NetworkConnectionBannerController:start'); rootMessenger.call('NetworkConnectionBannerController:start'); @@ -152,20 +167,25 @@ describe('NetworkConnectionBannerController', () => { expect(controller.state.status).toBe('degraded'); }, - externalState, - false, ); }); it('ignores upstream state changes before start', async () => { await withController( + { + externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }), + start: false, + }, ({ controller, setNetworkControllerState }) => { setNetworkControllerState({ networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -183,20 +203,22 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); }, - buildExternalState({ enabledEvmChainIds: ['0x89'] }), - false, ); }); it('cancels a pending banner and resets state on stop', async () => { await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, ({ controller, setNetworkControllerState }) => { setNetworkControllerState({ networkConfigurationsByChainId: { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -217,12 +239,12 @@ describe('NetworkConnectionBannerController', () => { network: null, }); }, - buildExternalState({ enabledEvmChainIds: ['0x89'] }), ); }); it('ignores upstream state changes after stop', async () => { await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, ({ controller, setNetworkControllerState }) => { controller.stop(); setNetworkControllerState({ @@ -230,7 +252,10 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -244,12 +269,12 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); }, - buildExternalState({ enabledEvmChainIds: ['0x89'] }), ); }); it('resumes evaluation when start is called again after stop', async () => { await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, ({ controller, setNetworkControllerState }) => { controller.stop(); @@ -258,7 +283,10 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -275,23 +303,18 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); }, - buildExternalState({ enabledEvmChainIds: ['0x89'] }), ); }); it('stop is idempotent when never started', async () => { - await withController( - ({ controller }) => { - controller.stop(); - controller.stop(); - expect(controller.state).toStrictEqual({ - status: 'available', - network: null, - }); - }, - undefined, - false, - ); + await withController({ start: false }, ({ controller }) => { + controller.stop(); + controller.stop(); + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }); }); it('bails out when a stateChanged listener calls stop synchronously during refresh', async () => { @@ -305,7 +328,10 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -339,13 +365,18 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), ], }), }, enabledEvmChainIds: ['0x1'], networksMetadata: { - [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -377,7 +408,10 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -412,7 +446,10 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), '0xaa36a7': buildNetworkConfiguration({ @@ -420,13 +457,20 @@ describe('NetworkConnectionBannerController', () => { name: 'Sepolia', nativeCurrency: 'SepoliaETH', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: SEPOLIA_CLIENT_ID, infuraNetworkType: 'sepolia' }), + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), ], }), }, networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), - [SEPOLIA_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), }, }), ); @@ -448,7 +492,10 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), '0xaa36a7': buildNetworkConfiguration({ @@ -456,20 +503,32 @@ describe('NetworkConnectionBannerController', () => { name: 'Sepolia', nativeCurrency: 'SepoliaETH', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: SEPOLIA_CLIENT_ID, infuraNetworkType: 'sepolia' }), + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), ], }), '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), - [SEPOLIA_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), }, }), ); @@ -488,7 +547,10 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), '0xa4b1': buildNetworkConfiguration({ @@ -496,13 +558,20 @@ describe('NetworkConnectionBannerController', () => { name: 'Arbitrum One', nativeCurrency: 'ETH', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://arb-mainnet.g.alchemy.com/v2/abc' }), + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://arb-mainnet.g.alchemy.com/v2/abc', + }), ], }), }, networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), - [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -535,18 +604,26 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), @@ -572,13 +649,18 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), }, enabledEvmChainIds: ['0x1'], networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -601,7 +683,10 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), '0xaa36a7': buildNetworkConfiguration({ @@ -609,12 +694,17 @@ describe('NetworkConnectionBannerController', () => { name: 'Sepolia', nativeCurrency: 'SepoliaETH', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: SEPOLIA_CLIENT_ID, infuraNetworkType: 'sepolia' }), + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), ], }), }, networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -636,18 +726,26 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( NetworkStatus.Unavailable, ), @@ -666,7 +764,10 @@ describe('NetworkConnectionBannerController', () => { const config = buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }); publishNetworkStateChanges( @@ -690,7 +791,9 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x1': config }, enabledEvmChainIds: ['0x1'], networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Blocked), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Blocked, + ), }, }), ); @@ -709,13 +812,18 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, enabledEvmChainIds: ['0x89'], networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }); @@ -737,7 +845,10 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -761,13 +872,18 @@ describe('NetworkConnectionBannerController', () => { '0x89': buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, enabledEvmChainIds: ['0x89'], networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), }, }), ); @@ -805,7 +921,10 @@ describe('NetworkConnectionBannerController', () => { const failingConfig = buildNetworkConfiguration({ chainId: '0x89', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }); publishNetworkStateChanges( @@ -828,7 +947,9 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x89': failingConfig }, enabledEvmChainIds: ['0x89'], networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Available), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), }, }), ); @@ -848,13 +969,18 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: MAINNET_CLIENT_ID, url: 'not a valid url' }), + buildCustomEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + url: 'not a valid url', + }), ], }), }, enabledEvmChainIds: ['0x1'], networksMetadata: { - [MAINNET_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -915,14 +1041,22 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), }, enabledEvmChainIds: ['0x1'], networksMetadata: { - [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -942,6 +1076,27 @@ describe('NetworkConnectionBannerController', () => { describe('on NetworkEnablementController:stateChange', () => { it('re-evaluates the rule when a failing chain becomes enabled', async () => { await withController( + { + externalState: buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + enabledEvmChainIds: [], + }), + }, ({ controller, setNetworkEnablementControllerState }) => { jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('available'); @@ -959,27 +1114,32 @@ describe('NetworkConnectionBannerController', () => { jest.advanceTimersByTime(5_000); expect(controller.state.status).toBe('degraded'); }, - buildExternalState({ - networkConfigurationsByChainId: { - '0x89': buildNetworkConfiguration({ - chainId: '0x89', - rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), - ], - }), - }, - networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( - NetworkStatus.Unavailable, - ), - }, - enabledEvmChainIds: [], - }), ); }); it('clears the banner when the failing chain gets disabled', async () => { await withController( + { + externalState: buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + enabledEvmChainIds: ['0x89'], + }), + }, ({ controller, setNetworkEnablementControllerState }) => { jest.advanceTimersByTime(30_000); expect(controller.state.status).toBe('unavailable'); @@ -999,22 +1159,6 @@ describe('NetworkConnectionBannerController', () => { network: null, }); }, - buildExternalState({ - networkConfigurationsByChainId: { - '0x89': buildNetworkConfiguration({ - chainId: '0x89', - rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), - ], - }), - }, - networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( - NetworkStatus.Unavailable, - ), - }, - enabledEvmChainIds: ['0x89'], - }), ); }); }); @@ -1037,7 +1181,10 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -1082,7 +1229,10 @@ describe('NetworkConnectionBannerController', () => { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), ], }), }, @@ -1104,42 +1254,57 @@ describe('NetworkConnectionBannerController', () => { }); it('clears banner state via messenger action', async () => { - await withController(({ controller, rootMessenger, publishNetworkStateChanges }) => { - publishNetworkStateChanges( - buildExternalState({ - networkConfigurationsByChainId: { - '0x1': buildNetworkConfiguration({ - chainId: '0x1', - rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: POLYGON_CUSTOM_CLIENT_ID, url: 'https://polygon-rpc.com' }), - ], - }), - }, - enabledEvmChainIds: ['0x1'], - networksMetadata: { - [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( - NetworkStatus.Unavailable, - ), - }, - }), - ); - jest.advanceTimersByTime(5_000); + await withController( + ({ controller, rootMessenger, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(5_000); - rootMessenger.call('NetworkConnectionBannerController:dismissBanner'); - expect(controller.state.status).toBe('available'); - }); + rootMessenger.call('NetworkConnectionBannerController:dismissBanner'); + expect(controller.state.status).toBe('available'); + }, + ); }); }); describe('switchToDefaultInfuraRpcEndpoint', () => { it('invokes NetworkController:updateNetwork with the Infura endpoint as the new default', async () => { await withController( - async ({ rootMessenger, publishNetworkStateChanges, updateNetwork }) => { + async ({ + rootMessenger, + publishNetworkStateChanges, + updateNetwork, + }) => { const config = buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }); publishNetworkStateChanges( @@ -1147,7 +1312,9 @@ describe('NetworkConnectionBannerController', () => { networkConfigurationsByChainId: { '0x1': config }, enabledEvmChainIds: ['0x1'], networksMetadata: { - [ALCHEMY_CLIENT_ID]: buildNetworkMetadata(NetworkStatus.Unavailable), + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), }, }), ); @@ -1169,14 +1336,21 @@ describe('NetworkConnectionBannerController', () => { it('is a no-op when the default is already Infura', async () => { await withController( - async ({ rootMessenger, publishNetworkStateChanges, updateNetwork }) => { + async ({ + rootMessenger, + publishNetworkStateChanges, + updateNetwork, + }) => { publishNetworkStateChanges( buildExternalState({ networkConfigurationsByChainId: { '0x1': buildNetworkConfiguration({ chainId: '0x1', rpcEndpoints: [ - buildInfuraEndpoint({ networkClientId: MAINNET_CLIENT_ID, infuraNetworkType: 'mainnet' }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), ], }), }, @@ -1206,28 +1380,33 @@ describe('NetworkConnectionBannerController', () => { }); it('throws when the chain has no Infura endpoint to switch to', async () => { - await withController(async ({ rootMessenger, publishNetworkStateChanges }) => { - publishNetworkStateChanges( - buildExternalState({ - networkConfigurationsByChainId: { - '0x1': buildNetworkConfiguration({ - chainId: '0x1', - rpcEndpoints: [ - buildCustomEndpoint({ networkClientId: ALCHEMY_CLIENT_ID, url: 'https://eth-mainnet.alchemyapi.io/v2/abc' }), - ], - }), - }, - enabledEvmChainIds: ['0x1'], - }), - ); + await withController( + async ({ rootMessenger, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + }), + ); - await expect( - rootMessenger.call( - 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', - '0x1', - ), - ).rejects.toThrow(/No Infura endpoint available/u); - }); + await expect( + rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', + ), + ).rejects.toThrow(/No Infura endpoint available/u); + }, + ); }); }); }); @@ -1317,11 +1496,18 @@ type WithControllerCallback = (payload: { updateNetwork: jest.Mock; }) => Promise | ReturnValue; +type WithControllerOptions = { + externalState?: ExternalState; + start?: boolean; +}; + async function withController( - testFunction: WithControllerCallback, - externalState?: ExternalState, - start = true, + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] ): Promise { + const [{ externalState, start = true }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -1345,12 +1531,14 @@ async function withController( ); rootMessenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByChainId', - (chainId) => currentState.NetworkController.networkConfigurationsByChainId?.[chainId], + (chainId) => + currentState.NetworkController.networkConfigurationsByChainId?.[chainId], ); const updateNetwork = jest.fn( async (chainId: Hex): Promise => - currentState.NetworkController.networkConfigurationsByChainId?.[chainId] ?? - buildNetworkConfiguration({ chainId }), + currentState.NetworkController.networkConfigurationsByChainId?.[ + chainId + ] ?? buildNetworkConfiguration({ chainId }), ); rootMessenger.registerActionHandler( 'NetworkController:updateNetwork', From c7a4d85c70da8fda6f2781b7827f861d81c6124e Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 2 Jul 2026 23:34:04 +0200 Subject: [PATCH 47/48] style: format long call to #findFailedNetwork --- .../src/NetworkConnectionBannerController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index e5344851ee..87d02113d9 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -485,7 +485,10 @@ export class NetworkConnectionBannerController extends BaseController< networkEnablementControllerState ?? this.messenger.call('NetworkEnablementController:getState'); - const failedNetwork = this.#findFailedNetwork(networkState, enablementState); + const failedNetwork = this.#findFailedNetwork( + networkState, + enablementState, + ); if (!failedNetwork) { this.#resetBanner(); return; From cadd612bb916b3896ca5434d58d54f411a0e525f Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 3 Jul 2026 07:40:20 +0200 Subject: [PATCH 48/48] style: disable naming-convention for ExternalState controller keys --- .../src/NetworkConnectionBannerController.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index a5fce1141b..4d33b374b1 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -1429,11 +1429,14 @@ type BuildExternalStateArgs = { enabledEvmChainIds?: Hex[]; }; +// Keys match the messenger namespace of each upstream controller. +/* eslint-disable @typescript-eslint/naming-convention */ type ExternalState = { NetworkController: Partial; NetworkEnablementController: NetworkEnablementControllerState; ConnectivityController: ConnectivityControllerState; }; +/* eslint-enable @typescript-eslint/naming-convention */ function buildNetworkEnablementControllerState( overrides: Partial = {},