diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2b66123bd1..bc762944c9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -81,35 +81,36 @@ /packages/geolocation-controller @MetaMask/mobile-platform ## Core Platform Team -/packages/base-controller @MetaMask/core-platform -/packages/base-data-service @MetaMask/core-platform -/packages/build-utils @MetaMask/core-platform -/packages/chain-agnostic-permission @MetaMask/core-platform -/packages/composable-controller @MetaMask/core-platform -/packages/connectivity-controller @MetaMask/core-platform -/packages/controller-utils @MetaMask/core-platform -/packages/eip-5792-middleware @MetaMask/core-platform -/packages/eip1193-permission-middleware @MetaMask/core-platform -/packages/eth-block-tracker @MetaMask/core-platform -/packages/eth-json-rpc-middleware @MetaMask/core-platform -/packages/eth-json-rpc-provider @MetaMask/core-platform -/packages/json-rpc-engine @MetaMask/core-platform -/packages/json-rpc-middleware-stream @MetaMask/core-platform -/packages/messenger @MetaMask/core-platform -/packages/messenger-cli @MetaMask/core-platform -/packages/multichain-api-middleware @MetaMask/core-platform -/packages/permission-controller @MetaMask/core-platform -/packages/permission-log-controller @MetaMask/core-platform -/packages/platform-api-docs @MetaMask/core-platform -/packages/polling-controller @MetaMask/core-platform -/packages/preferences-controller @MetaMask/core-platform -/packages/rate-limit-controller @MetaMask/core-platform -/packages/react-data-query @MetaMask/core-platform -/packages/sample-controllers @MetaMask/core-platform -/packages/selected-network-controller @MetaMask/core-platform -/packages/wallet @MetaMask/core-platform -/packages/wallet-cli @MetaMask/core-platform @MetaMask/ocap-kernel -/packages/wallet-framework-docs @MetaMask/core-platform +/packages/base-controller @MetaMask/core-platform +/packages/base-data-service @MetaMask/core-platform +/packages/build-utils @MetaMask/core-platform +/packages/chain-agnostic-permission @MetaMask/core-platform +/packages/composable-controller @MetaMask/core-platform +/packages/connectivity-controller @MetaMask/core-platform +/packages/controller-utils @MetaMask/core-platform +/packages/eip-5792-middleware @MetaMask/core-platform +/packages/eip1193-permission-middleware @MetaMask/core-platform +/packages/eth-block-tracker @MetaMask/core-platform +/packages/eth-json-rpc-middleware @MetaMask/core-platform +/packages/eth-json-rpc-provider @MetaMask/core-platform +/packages/json-rpc-engine @MetaMask/core-platform +/packages/json-rpc-middleware-stream @MetaMask/core-platform +/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 +/packages/polling-controller @MetaMask/core-platform +/packages/preferences-controller @MetaMask/core-platform +/packages/rate-limit-controller @MetaMask/core-platform +/packages/react-data-query @MetaMask/core-platform +/packages/sample-controllers @MetaMask/core-platform +/packages/selected-network-controller @MetaMask/core-platform +/packages/wallet @MetaMask/core-platform +/packages/wallet-cli @MetaMask/core-platform @MetaMask/ocap-kernel +/packages/wallet-framework-docs @MetaMask/core-platform ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth diff --git a/README.md b/README.md index 1b8c734bbb..52556ddb94 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,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) @@ -201,6 +202,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"]); @@ -454,6 +456,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..9a5192e530 --- /dev/null +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -0,0 +1,17 @@ +# 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 + +- 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/ 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..38d4828b51 --- /dev/null +++ b/packages/network-connection-banner-controller/README.md @@ -0,0 +1,39 @@ +# `@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. + +## Lifecycle + +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 +// 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(); +``` + +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 + +`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..7aceb9fcae --- /dev/null +++ b/packages/network-connection-banner-controller/package.json @@ -0,0 +1,81 @@ +{ + "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": "^34.0.0", + "@metamask/network-enablement-controller": "^5.4.1", + "@metamask/utils": "^11.11.0", + "ip-regex": "^4.3.0", + "psl": "^1.15.0", + "reselect": "^5.1.1" + }, + "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..e047060dcb --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -0,0 +1,62 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NetworkConnectionBannerController } from './NetworkConnectionBannerController'; + +/** + * 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`; + 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 NetworkConnectionBannerControllerStopAction = { + type: `NetworkConnectionBannerController:stop`; + handler: NetworkConnectionBannerController['stop']; +}; + +/** + * Clears the banner state such that the banner will be hidden. + */ +export type NetworkConnectionBannerControllerDismissBannerAction = { + type: `NetworkConnectionBannerController:dismissBanner`; + handler: NetworkConnectionBannerController['dismissBanner']; +}; + +/** + * Switches the chain's default RPC endpoint to its Infura endpoint, + * causing the banner to clear once the network becomes available again. + * + * @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 NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction = + { + type: `NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint`; + handler: NetworkConnectionBannerController['switchToDefaultInfuraRpcEndpoint']; + }; + +/** + * Union of all NetworkConnectionBannerController action types. + */ +export type NetworkConnectionBannerControllerMethodActions = + | NetworkConnectionBannerControllerStartAction + | NetworkConnectionBannerControllerStopAction + | NetworkConnectionBannerControllerDismissBannerAction + | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction; 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..4d33b374b1 --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -0,0 +1,1700 @@ +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 { + BuiltInNetworkClientId, + InfuraRpcEndpoint, + 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 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 buildNetworkConfiguration( + overrides: Partial & + Pick, +): NetworkConfiguration { + return { + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: '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('start / stop', () => { + it('does not evaluate existing upstream state before start', async () => { + const externalState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + await withController( + { externalState, start: false }, + ({ controller }) => { + jest.advanceTimersByTime(30_000); + + expect(controller.state.status).toBe('available'); + }, + ); + }); + + it('evaluates existing upstream state on start', async () => { + const externalState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + await withController( + { externalState, start: false }, + ({ controller, rootMessenger }) => { + rootMessenger.call('NetworkConnectionBannerController:start'); + rootMessenger.call('NetworkConnectionBannerController:start'); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.status).toBe('degraded'); + }, + ); + }); + + 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', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + + controller.start(); + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, + ); + }); + + 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', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, + ({ controller, setNetworkControllerState }) => { + controller.stop(); + setNetworkControllerState({ + 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, + ), + }, + }); + + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }, + ); + }); + + it('resumes evaluation when start is called again after stop', async () => { + await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, + ({ controller, setNetworkControllerState }) => { + controller.stop(); + + setNetworkControllerState({ + 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, + ), + }, + }); + + 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({ 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 () => { + await withController( + ({ controller, controllerMessenger, publishNetworkStateChanges }) => { + // Escalate the banner to `unavailable` so state is non default and the + // next refresh's pre timer `update` actually mutates state. + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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). + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + 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, publishNetworkStateChanges }) => { + controllerMessenger.subscribe( + 'NetworkConnectionBannerController:stateChanged', + (state) => { + if (state.status === 'degraded') { + controller.stop(); + } + }, + ); + + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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('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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xaa36a7': buildNetworkConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata( + 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xaa36a7': buildNetworkConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), + ], + }), + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + 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, + ), + }, + }), + ); + + 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xa4b1': buildNetworkConfiguration({ + chainId: '0xa4b1', + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + 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, + ), + }, + }), + ); + + // 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + chainId: '0x1', + isInfuraEndpoint: true, + }); + }); + }); + + it('ignores enabled networks with missing metadata when every known network is failing', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xaa36a7': buildNetworkConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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, publishNetworkStateChanges }) => { + const config = buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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). + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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('does not restart the degraded timer when the same network fails across re-evaluations', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + const failingState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + publishNetworkStateChanges(failingState); + jest.advanceTimersByTime(4_000); + + publishNetworkStateChanges(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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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. + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state.status).toBe('available'); + }); + }); + + it('skips enabled chains that have no network configuration', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges({ + NetworkController: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + NetworkEnablementController: buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, + }, + }, + }), + ConnectivityController: { + 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, publishNetworkStateChanges }) => { + const failingConfig = buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x89': failingConfig }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x89': failingConfig }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + url: 'not a valid url', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + expect(controller.state.network).toMatchObject({ + isInfuraEndpoint: false, + }); + }); + }); + + it('keeps the banner hidden when the enablement map has no EVM namespace at all', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges({ + NetworkController: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + NetworkEnablementController: buildNetworkEnablementControllerState(), + ConnectivityController: { + 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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Broken', + nativeCurrency: 'ETH', + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + }, + }, + enabledEvmChainIds: ['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, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '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', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.network).toMatchObject({ + chainId: '0x1', + isInfuraEndpoint: false, + switchableInfuraNetworkClientId: MAINNET_CLIENT_ID, + // Sanity-check: not null when there's an Infura endpoint to offer. + }); + }); + }); + }); + + 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'); + + setNetworkEnablementControllerState( + buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x89': true, + }, + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.status).toBe('degraded'); + }, + ); + }); + + 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'); + + setNetworkEnablementControllerState( + buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x89': false, + }, + }, + }), + ); + + expect(controller.state).toStrictEqual({ + status: 'available', + network: null, + }); + }, + ); + }); + }); + + 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; + 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, publishNetworkStateChanges, setConnectivityStatus }) => { + 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); + 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, 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); + 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, 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'); + }, + ); + }); + }); + + describe('switchToDefaultInfuraRpcEndpoint', () => { + it('invokes NetworkController:updateNetwork with the Infura endpoint as the new default', async () => { + await withController( + 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', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '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, + publishNetworkStateChanges, + updateNetwork, + }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + }), + ); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '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:switchToDefaultInfuraRpcEndpoint', + '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, 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); + }, + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function buildNetworkMetadata(status: NetworkStatus): { + // eslint-disable-next-line @typescript-eslint/naming-convention + EIPS: Record; + status: NetworkStatus; +} { + return { EIPS: {}, status }; +} + +type BuildExternalStateArgs = { + networkConfigurationsByChainId?: NetworkState['networkConfigurationsByChainId']; + networksMetadata?: NetworkState['networksMetadata']; + 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 = {}, +): NetworkEnablementControllerState { + return { + enabledNetworkMap: {}, + nativeAssetIdentifiers: {}, + ...overrides, + }; +} + +function buildExternalState({ + networkConfigurationsByChainId = {}, + networksMetadata = {}, + enabledEvmChainIds = Object.keys(networkConfigurationsByChainId) as Hex[], +}: BuildExternalStateArgs = {}): ExternalState { + return { + NetworkController: { + networkConfigurationsByChainId, + networksMetadata, + }, + NetworkEnablementController: buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: Object.fromEntries( + enabledEvmChainIds.map((chainId) => [chainId, true]), + ), + }, + }), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + }; +} + +type AllNetworkConnectionBannerControllerActions = + MessengerActions; +type AllNetworkConnectionBannerControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +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'], + ) => void; + updateNetwork: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + externalState?: ExternalState; + start?: boolean; +}; + +async function withController( + ...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, + }); + + let currentState: ExternalState = + externalState ?? + ({ + NetworkController: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + NetworkEnablementController: buildNetworkEnablementControllerState(), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + } satisfies ExternalState); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => currentState.NetworkController as NetworkState, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + (chainId) => + currentState.NetworkController.networkConfigurationsByChainId?.[chainId], + ); + const updateNetwork = jest.fn( + async (chainId: Hex): Promise => + currentState.NetworkController.networkConfigurationsByChainId?.[ + chainId + ] ?? buildNetworkConfiguration({ chainId }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:updateNetwork', + updateNetwork, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => currentState.NetworkEnablementController, + ); + + rootMessenger.registerActionHandler( + 'ConnectivityController:getState', + () => currentState.ConnectivityController, + ); + + const messenger = new Messenger< + 'NetworkConnectionBannerController', + AllNetworkConnectionBannerControllerActions, + AllNetworkConnectionBannerControllerEvents, + RootMessenger + >({ + namespace: 'NetworkConnectionBannerController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:updateNetwork', + 'NetworkEnablementController:getState', + 'ConnectivityController:getState', + ], + events: [ + // 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, + }); + if (start) { + 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 = { + ...currentState, + NetworkController: state.NetworkController, + NetworkEnablementController: state.NetworkEnablementController, + }; + rootMessenger.publish( + 'NetworkController:stateChange', + currentState.NetworkController as NetworkState, + [], + ); + rootMessenger.publish( + 'NetworkEnablementController:stateChange', + currentState.NetworkEnablementController, + [], + ); + }; + + const setConnectivityStatus = ( + status: ConnectivityControllerState['connectivityStatus'], + ): void => { + currentState = { + ...currentState, + ConnectivityController: { connectivityStatus: status }, + }; + rootMessenger.publish( + 'ConnectivityController:stateChange', + currentState.ConnectivityController, + [], + ); + }; + + return await testFunction({ + controller, + rootMessenger, + controllerMessenger: messenger, + setNetworkControllerState, + setNetworkEnablementControllerState, + publishNetworkStateChanges, + setConnectivityStatus, + 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, + }; +} 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..87d02113d9 --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -0,0 +1,720 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { + CONNECTIVITY_STATUSES, + connectivityControllerSelectors, +} from '@metamask/connectivity-controller'; +import type { + ConnectivityControllerGetStateAction, + ConnectivityControllerState, + ConnectivityControllerStateChangeEvent, +} from '@metamask/connectivity-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkConfiguration, + NetworkControllerGetNetworkConfigurationByChainIdAction, + NetworkControllerGetStateAction, + NetworkControllerUpdateNetworkAction, + NetworkControllerStateChangeEvent, + NetworkMetadata, + NetworkState, +} from '@metamask/network-controller'; +import { NetworkStatus } from '@metamask/network-controller'; +import type { + NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerState, + 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'; + +/** + * 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. + * + * @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( + [selectNetworksMetadata, selectNetworkConfigurationsByChainId], + (networksMetadata, networkConfigurationsByChainId) => ({ + networksMetadata, + networkConfigurationsByChainId, + }), +); + +/** + * 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 + * UI renders. + */ +export type NetworkConnectionBannerStatus = + | 'available' + | '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 + * 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; +}; + +/** + * State for the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerState = { + status: NetworkConnectionBannerStatus; + network: FailedNetwork | 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, + }; +} + +/** + * 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 = [ + 'start', + 'stop', + 'dismissBanner', + 'switchToDefaultInfuraRpcEndpoint', +] as const; + +/** + * Retrieves the state of the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerGetStateAction = + ControllerGetStateAction< + typeof CONTROLLER_NAME, + 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 CONTROLLER_NAME, + NetworkConnectionBannerControllerState + >; + +/** + * Events that {@link NetworkConnectionBannerControllerMessenger} exposes to + * other consumers. + */ +export type NetworkConnectionBannerControllerEvents = + NetworkConnectionBannerControllerStateChangedEvent; + +/** + * Events from other messengers that + * {@link NetworkConnectionBannerControllerMessenger} subscribes to. + */ +type AllowedEvents = + | NetworkControllerStateChangeEvent + | NetworkEnablementControllerStateChangeEvent + | ConnectivityControllerStateChangeEvent; + +/** + * The messenger restricted to actions and events accessed by + * {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerMessenger = Messenger< + typeof CONTROLLER_NAME, + 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 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 + * 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 switchToDefaultInfuraRpcEndpoint}. + */ +export class NetworkConnectionBannerController extends BaseController< + typeof CONTROLLER_NAME, + NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerMessenger +> { + #degradedTimer: ReturnType | undefined; + + #unavailableTimer: ReturnType | undefined; + + #pendingNetworkClientId: string | undefined; + + #isStarted = false; + + /** + * 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: CONTROLLER_NAME, + state: getDefaultNetworkConnectionBannerControllerState(), + }); + + // 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', + (networkControllerState) => + this.#refreshState({ networkControllerState }), + selectNetworkControllerFields, + ); + this.messenger.subscribe( + 'NetworkEnablementController:stateChange', + (networkEnablementControllerState) => + this.#refreshState({ networkEnablementControllerState }), + selectNetworkEnablementControllerFields, + ); + this.messenger.subscribe( + 'ConnectivityController:stateChange', + (connectivityControllerState) => + this.#refreshState({ connectivityControllerState }), + selectConnectivityControllerFields, + ); + /* eslint-enable no-restricted-syntax */ + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * 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.#isStarted) { + return; + } + + this.#isStarted = true; + this.#refreshState(); + } + + /** + * 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.#isStarted) { + return; + } + + this.#isStarted = false; + this.#resetBanner(); + } + + /** + * Clears the banner state such that the banner will be hidden. + */ + dismissBanner(): void { + this.#resetBanner(); + } + + /** + * Switches the chain's default RPC endpoint to its Infura endpoint, + * causing the banner to clear once the network becomes available again. + * + * @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 switchToDefaultInfuraRpcEndpoint(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) => getIsInfuraEndpoint(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 }, + ); + } + + #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 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; + } + + if ( + this.state.status !== 'available' && + this.state.network?.networkClientId === failedNetwork.networkClientId + ) { + this.update((state) => { + state.network = failedNetwork; + }); + return; + } + + if (this.#pendingNetworkClientId === failedNetwork.networkClientId) { + return; + } + + this.#clearTimers(); + this.update((state) => { + state.status = 'available'; + state.network = null; + }); + + // A synchronous listener on our `stateChanged` event above may have + // called `stop()` re-entrantly. Bail before scheduling anything. + if (!this.#isStarted) { + 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 + // timer via `#clearTimers` before it fires. + this.#pendingNetworkClientId = failedNetwork.networkClientId; + this.#degradedTimer = setTimeout(() => { + this.#degradedTimer = undefined; + this.#pendingNetworkClientId = undefined; + this.update((state) => { + 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.#isStarted) { + return; + } + this.#unavailableTimer = setTimeout(() => { + this.#unavailableTimer = undefined; + this.update((state) => { + state.status = 'unavailable'; + state.network = failedNetwork; + }); + }, UNAVAILABLE_BANNER_TIMEOUT - DEGRADED_BANNER_TIMEOUT); + }, DEGRADED_BANNER_TIMEOUT); + } + + /** + * Clears timers and resets banner state to {@link NetworkConnectionBannerStatus|`available`} + * if it isn't there already. + */ + #resetBanner(): void { + this.#clearTimers(); + this.#pendingNetworkClientId = undefined; + if (this.state.status !== 'available' || this.state.network !== null) { + this.update((state) => { + state.status = 'available'; + state.network = null; + }); + } + } + + #clearTimers(): void { + if (this.#degradedTimer !== undefined) { + clearTimeout(this.#degradedTimer); + this.#degradedTimer = undefined; + } + if (this.#unavailableTimer !== undefined) { + clearTimeout(this.#unavailableTimer); + this.#unavailableTimer = undefined; + } + } + + #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( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): Hex[] { + return Object.entries(enabledNetworkMap[KnownCaipNamespace.Eip155] ?? {}) + .filter(([, enabled]) => enabled) + .map(([chainId]) => chainId as Hex); + } + + #collectNetworksWithMetadata( + { + networkConfigurationsByChainId, + networksMetadata, + }: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >, + { + enabledNetworkMap, + }: Pick, + ): NetworkWithMetadata[] { + return this.#getEnabledEvmChainIds(enabledNetworkMap).flatMap((chainId) => { + const networkConfiguration = networkConfigurationsByChainId[chainId]; + if (!networkConfiguration) { + return []; + } + const { rpcEndpoints, defaultRpcEndpointIndex, name } = + networkConfiguration; + const defaultRpcEndpoint = rpcEndpoints[defaultRpcEndpointIndex]; + if (!defaultRpcEndpoint) { + return []; + } + const metadata = networksMetadata[defaultRpcEndpoint.networkClientId]; + if (!metadata) { + return []; + } + return [ + { + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + defaultRpcEndpoint, + metadata, + }, + ]; + }); + } + + #buildFailedNetwork({ + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + defaultRpcEndpoint, + }: NetworkWithMetadata): FailedNetwork { + 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 switchableInfuraNetworkClientId: string | null = null; + if (!isInfuraEndpoint) { + const infuraEndpoint = rpcEndpoints.find( + (endpoint, index) => + index !== defaultRpcEndpointIndex && + getIsInfuraEndpoint(endpoint.url), + ); + switchableInfuraNetworkClientId = infuraEndpoint?.networkClientId ?? null; + } + + 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; + } + + 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 areAllKnownNetworksFailed = + failedNetworks.length === totalNetworksWithMetadata; + + if ( + !firstCustomFailed && + distinctDomains <= 1 && + !areAllKnownNetworksFailed + ) { + return null; + } + + return firstCustomFailed ?? failedNetworks[0]; + } +} + +/** + * 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); +} 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..e321ffec60 --- /dev/null +++ b/packages/network-connection-banner-controller/src/index.ts @@ -0,0 +1,22 @@ +export type { + NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerGetStateAction, + NetworkConnectionBannerControllerActions, + NetworkConnectionBannerControllerStateChangedEvent, + NetworkConnectionBannerControllerEvents, + NetworkConnectionBannerControllerMessenger, + NetworkConnectionBannerControllerOptions, + FailedNetwork, + NetworkConnectionBannerStatus, +} from './NetworkConnectionBannerController'; +export type { + NetworkConnectionBannerControllerStartAction, + NetworkConnectionBannerControllerStopAction, + NetworkConnectionBannerControllerDismissBannerAction, + NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction, +} from './NetworkConnectionBannerController-method-action-types'; +export { + NetworkConnectionBannerController, + getDefaultNetworkConnectionBannerControllerState, +} from './NetworkConnectionBannerController'; +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..d01ef3afaa --- /dev/null +++ b/packages/network-connection-banner-controller/src/selectors.test.ts @@ -0,0 +1,99 @@ +import type { + NetworkConnectionBannerControllerState, + FailedNetwork, +} from './NetworkConnectionBannerController'; +import { networkConnectionBannerControllerSelectors } from './selectors'; + +const failedNetwork: FailedNetwork = { + chainId: '0x1', + networkClientId: 'mainnet', + name: 'Ethereum Mainnet', + rpcUrl: 'https://mainnet.infura.io/v3/abc', + isInfuraEndpoint: true, + switchableInfuraNetworkClientId: null, + domain: 'infura.io', +}; + +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..debcc1f4b3 --- /dev/null +++ b/packages/network-connection-banner-controller/src/selectors.ts @@ -0,0 +1,50 @@ +import { createSelector } from 'reselect'; + +import type { + NetworkConnectionBannerControllerState, + FailedNetwork, + 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, +): FailedNetwork | 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/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/teams.json b/teams.json index 42326184bf..1d847a2110 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", diff --git a/tsconfig.build.json b/tsconfig.build.json index 9014c6871d..c64fc66864 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -187,6 +187,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 488ed40f1c..8997907226 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -179,6 +179,9 @@ { "path": "./packages/name-controller" }, + { + "path": "./packages/network-connection-banner-controller" + }, { "path": "./packages/network-controller" }, diff --git a/types/psl.d.ts b/types/psl.d.ts new file mode 100644 index 0000000000..74eede54f3 --- /dev/null +++ b/types/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/yarn.lock b/yarn.lock index 9e514a392f..bba8c7a001 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,15 +19,15 @@ __metadata: languageName: node linkType: hard -"@algolia/abtesting@npm:1.19.0": - version: 1.19.0 - resolution: "@algolia/abtesting@npm:1.19.0" +"@algolia/abtesting@npm:1.18.1": + version: 1.18.1 + resolution: "@algolia/abtesting@npm:1.18.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/eaa3183c5ee2ceb77e4b658c9656917d59e9ce363526e9fd4a58c63cb8ed445d9413b64aa488b3ed021d22a29d9e734bd2c508dd6ec57b9eeada44d3d75b2333 + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/0b5d0015533166009327b986739388874bc397e42e946819fe237a477cabde9f2ffa56e9b3b45a5f0936d13db5b02cefbf2a054b3c27be36a38c10e666d94544 languageName: node linkType: hard @@ -93,82 +93,82 @@ __metadata: languageName: node linkType: hard -"@algolia/client-abtesting@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-abtesting@npm:5.53.0" +"@algolia/client-abtesting@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-abtesting@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/2087f9c60eec1ace58bb800993dc58a91d595257d3352a8a6f2e83104a92427209ed2a65418228f7c2fabe03b321ab1cbe0f1615edfc9676893556e696a4ed8a + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/93ebca8f5949856fdfda251e42d5935c9f80e155756c3f8708d1f0e5ba63022ae8c9cda7355a10f71b553a196e59f594729b17e014fa3e54017504c153c8e15f languageName: node linkType: hard -"@algolia/client-analytics@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-analytics@npm:5.53.0" +"@algolia/client-analytics@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-analytics@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/432feaab007b5cdde216b4ddfed08631558ba91e3139cfd065619c6301ab7d321df82c6a5f3e615e9c8d399720ed53407ca32531937e362003e78c7991a5009c + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/0949701da07d842d94b0987a12ea7006f6c39a4f5f401e739b26bfbfc9ea071b17bb80f4463f9aec50b994cdd01f8cbda9065f22c2feb3965f8e637af3ce9439 languageName: node linkType: hard -"@algolia/client-common@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-common@npm:5.53.0" - checksum: 10/bcef6d69d6340f16021da1a07602a282e03871867f2f22bd38026832b16721fa5e2d8c3a45472106ec833735dff0756c8d8b2e1a31f7fa2d7fe53c5eb0bc57ee +"@algolia/client-common@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-common@npm:5.52.1" + checksum: 10/3efacea327d7167fc1af404df066683ecf0380c3e437c1ab8dd172697ca69c78978ceeb9d5179d596565188597407e54ce61e44ddd25d7dec7c7545a766207ce languageName: node linkType: hard -"@algolia/client-insights@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-insights@npm:5.53.0" +"@algolia/client-insights@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-insights@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/bb949a2460b759177873da3f470d4590a7370732192ba4b62f9f5f246018e31c7458ebf93747b951108e52a779346cb68a1c3d2fddd6515b373e83477d9683f3 + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/36d33b01f843ece3c8a894c855f78cb1a0eda1f94ee6c6286be9edfd7c2772a46dfba20f083bd0a4e9a7dcc625acd4e711750a65b48f76b65b88c80c382bdc8b languageName: node linkType: hard -"@algolia/client-personalization@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-personalization@npm:5.53.0" +"@algolia/client-personalization@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-personalization@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/6043eee231d1c12274ac2f449a98fb001d0f11682fa5a2409df7ce713b86b54df3fe1c3065163127fee66686cee14e15556b8dedc39efe0bdb06b25d7e2ce4a6 + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/ff2b40e8220d545dbbc275f04c5d5a45884a9ce07d13d06bcb7ef1635828ac42acd1f9a3c439177f2b1baa9b63f7ca33f997a8a755099c2850aa18937f7bf355 languageName: node linkType: hard -"@algolia/client-query-suggestions@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-query-suggestions@npm:5.53.0" +"@algolia/client-query-suggestions@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-query-suggestions@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/2bab41df5b76060504964c7210f4f32b2cd35b7a85352032f4b3126a12cd5aecc671d0b79d5bada75c6fedb968fe7fb2bec8c56798ef59aab0b3410cc3314423 + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/3f17139b29e4133bc14d917b4317cc9f65e0b060328df526c0dafc9000a565a26725315a54630b057f6cd125cd88f9817e880c27e153e243c335b3a9fc60a98b languageName: node linkType: hard -"@algolia/client-search@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/client-search@npm:5.53.0" +"@algolia/client-search@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/client-search@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/8c40a36edd5ebdf438e90bb9ee1dbe27689027747c66c4901b6684149c4f5a2da26e72f11a1583542db60503c8e28a69d7d06f856139a0c356274ee8024216ca + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/cb691b3e995dd09fdd2f1b9f337c9e2038033ae53175f1ae62a6186991c6c0088b67f8bb638bcd7f20e8c3c07d44289bfb1717d84cdebbf7e94a29a80411a5b0 languageName: node linkType: hard @@ -179,66 +179,66 @@ __metadata: languageName: node linkType: hard -"@algolia/ingestion@npm:1.53.0": - version: 1.53.0 - resolution: "@algolia/ingestion@npm:1.53.0" +"@algolia/ingestion@npm:1.52.1": + version: 1.52.1 + resolution: "@algolia/ingestion@npm:1.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/639e4e5575e854f3aa421afaa0fa45e4d98f14d825edc9a88ec3d72e87167b8c748d267ca1da8fe34f6b961df4cdca30ea49bad961aded5605e4f93c1e474374 + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/4bfb3f64ceb5b369544c73a2bbbd8e2bd3fe33afa244ce839ddd989da4cf88eb309722932fbded2a35da2f9e007c0dd8f7e53033bdbe1634db90b684c4788dd1 languageName: node linkType: hard -"@algolia/monitoring@npm:1.53.0": - version: 1.53.0 - resolution: "@algolia/monitoring@npm:1.53.0" +"@algolia/monitoring@npm:1.52.1": + version: 1.52.1 + resolution: "@algolia/monitoring@npm:1.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/ac8e18761392872d44d67d5a897daf25a277e4628a542e1c57ac2fdc793b9b4a658485002a3627829608a592d02b97cc59d1bac44b2ecdaa417cddaf5e06fc3c + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/7c3c8c083884f7d1212c36c5ec0d06106db18dbb466097682c3419a18500aeca8fb176ec91c5532067c01ec6c10378b0acb14b3497134a356ba12e083c661f24 languageName: node linkType: hard -"@algolia/recommend@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/recommend@npm:5.53.0" +"@algolia/recommend@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/recommend@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/ae04821435339d9474edce61c74975a938f128fce6410d72d50d9d31d40125823605c01da3d5c1f97598331cbdd8cf25001d453446a083553630fb9cc683eb4f + "@algolia/client-common": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/9debba40d349e6173fabbb24c95eed39b43bd56c03e8829327766a86d9c04e8d2b1ab369da0e160360a280b509e00e8586e8dae29858813608bbd677d146db01 languageName: node linkType: hard -"@algolia/requester-browser-xhr@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/requester-browser-xhr@npm:5.53.0" +"@algolia/requester-browser-xhr@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/requester-browser-xhr@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - checksum: 10/bddcf5bdf14cbcbb8a5f9b0f834f0f708d7074653222e608efed12d837bfe88fd8df4eb116aff354cac2a3e13389717e2832518139bcf422da1609fa35f5792f + "@algolia/client-common": "npm:5.52.1" + checksum: 10/6446631d19866f781570adaba883e987f538391e66e96ed2fc7449df4b90d557461d5ad733b3c4e96162b7774429bcad0e03643deb0e5cc2f4a90e739073a024 languageName: node linkType: hard -"@algolia/requester-fetch@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/requester-fetch@npm:5.53.0" +"@algolia/requester-fetch@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/requester-fetch@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - checksum: 10/60f333ba7c45d36b24d9b0a3991fdff3a1d40b59c3b3fc6424b34bf9c89dc0c4ec920d3475b2bdd5eb3bc888195f9c7d3ca44f1424d032c94e30e5ea9efe4702 + "@algolia/client-common": "npm:5.52.1" + checksum: 10/f5d7f6e55a381f46afcfdcba583a6f4e9789ba9cc8b40e4ee74ba1d5edc9bfc6032927dd690821b70122816606f8ecd68cc78fc3ed33e0c89877d52ad5e4edfa languageName: node linkType: hard -"@algolia/requester-node-http@npm:5.53.0": - version: 5.53.0 - resolution: "@algolia/requester-node-http@npm:5.53.0" +"@algolia/requester-node-http@npm:5.52.1": + version: 5.52.1 + resolution: "@algolia/requester-node-http@npm:5.52.1" dependencies: - "@algolia/client-common": "npm:5.53.0" - checksum: 10/5cadb77179b812ab29e55bc56de1f45df138093b28e57369ac08e0e0f8ec02f4fec62777bc679663a97cda44b321aa150c9547772e7beb82caaa3bc2b6e0ae38 + "@algolia/client-common": "npm:5.52.1" + checksum: 10/18068056cfe8f3756c3e549714b35a708a39e35fb8f6ba3e67a9c6ce7d7b28d31a8f54babf9ee7556c12478e636c5ce7e6f129491bd3e0a581b115fbdaee9bc8 languageName: node linkType: hard @@ -5101,106 +5101,106 @@ __metadata: languageName: node linkType: hard -"@jsonjoy.com/fs-core@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-core@npm:4.57.3" +"@jsonjoy.com/fs-core@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-core@npm:4.57.2" dependencies: - "@jsonjoy.com/fs-node-builtins": "npm:4.57.3" - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" + "@jsonjoy.com/fs-node-builtins": "npm:4.57.2" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" thingies: "npm:^2.5.0" peerDependencies: tslib: 2 - checksum: 10/4a8c30f690080dc2d189b3461f1c2102082a890a822e109e8c51b6306ea5db897de6da0a48ff01675eb0da8fd95c3fd7c19b937eac8b7702322d054b2294bf48 + checksum: 10/6db8b3a7fb874229c7991bbdc094d752adbde7d774e5ef70df5a787130c7c8ed4ac2d34eaac079383c527269feaa91d1cb4f5c1504af995cca95070af769a0bd languageName: node linkType: hard -"@jsonjoy.com/fs-fsa@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-fsa@npm:4.57.3" +"@jsonjoy.com/fs-fsa@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-fsa@npm:4.57.2" dependencies: - "@jsonjoy.com/fs-core": "npm:4.57.3" - "@jsonjoy.com/fs-node-builtins": "npm:4.57.3" - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" + "@jsonjoy.com/fs-core": "npm:4.57.2" + "@jsonjoy.com/fs-node-builtins": "npm:4.57.2" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" thingies: "npm:^2.5.0" peerDependencies: tslib: 2 - checksum: 10/e503defb49fccb92d9d683b7a901de646d499accc2fa561e161cc3bb9ec5f8bcf7c7c2acf185e6ed5d87330203ac4bd3b3c196cc20280343b46cec036e28ee64 + checksum: 10/0edf3b73d06a27e81f8a8e3b042022b9440c4794bb21d9957c15cd5c87f629e7e2f6695d464f82bb52d16e08fb3e682090ced5e712cc5bb05b41cbe99ce6e393 languageName: node linkType: hard -"@jsonjoy.com/fs-node-builtins@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-node-builtins@npm:4.57.3" +"@jsonjoy.com/fs-node-builtins@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-node-builtins@npm:4.57.2" peerDependencies: tslib: 2 - checksum: 10/de320725c525259647e76a9b6f6a0b4aadf04d5d2db403a89fe51dc7af09438f52c48125084324d8b9b5cbd283b8a50429d9c7f9a54c926e3593dcecc793ec45 + checksum: 10/3284f0f0a989ad2bc0abc485748b2f3581648401c7d86be9b4541374f65050d384b61b5e44eff9b463d43fd1764bead1251783681105962ba5954b5e64b42480 languageName: node linkType: hard -"@jsonjoy.com/fs-node-to-fsa@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-node-to-fsa@npm:4.57.3" +"@jsonjoy.com/fs-node-to-fsa@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-node-to-fsa@npm:4.57.2" dependencies: - "@jsonjoy.com/fs-fsa": "npm:4.57.3" - "@jsonjoy.com/fs-node-builtins": "npm:4.57.3" - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" + "@jsonjoy.com/fs-fsa": "npm:4.57.2" + "@jsonjoy.com/fs-node-builtins": "npm:4.57.2" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" peerDependencies: tslib: 2 - checksum: 10/475b006543025723e25f097300e47cb2c502f60584ee9ca92616ecbafe66b66e4330c3d1e75ce51d9c33dd0fac93cb374cd5e1c34154d7dd81440758b965a16b + checksum: 10/8d6e7447c640c02eb89c03e6a565af13b30607402a83ab462f0e16cced95d1cf0a09cc43fe297c379e51e905e0a6f7e14e65a65d19ece0756c3ae888e618e88c languageName: node linkType: hard -"@jsonjoy.com/fs-node-utils@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-node-utils@npm:4.57.3" +"@jsonjoy.com/fs-node-utils@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-node-utils@npm:4.57.2" dependencies: - "@jsonjoy.com/fs-node-builtins": "npm:4.57.3" + "@jsonjoy.com/fs-node-builtins": "npm:4.57.2" peerDependencies: tslib: 2 - checksum: 10/91622fa99246c3dee82af934cde74f6ee9e9161b91ad8a96a4b9b2af948c96b4e914f4a4fdcf8f21c047a40ea4ca2dc7e9721b89e71f05b1335a3e0b924464cf + checksum: 10/f63c7c8fd5a63a163a01bc70dac262419bcc1ae182186f249e038703b04a401e49eab9043514384555177e9385929b58a76cab945e8c7cdc6809efc2ea50bf31 languageName: node linkType: hard -"@jsonjoy.com/fs-node@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-node@npm:4.57.3" +"@jsonjoy.com/fs-node@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-node@npm:4.57.2" dependencies: - "@jsonjoy.com/fs-core": "npm:4.57.3" - "@jsonjoy.com/fs-node-builtins": "npm:4.57.3" - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" - "@jsonjoy.com/fs-print": "npm:4.57.3" - "@jsonjoy.com/fs-snapshot": "npm:4.57.3" + "@jsonjoy.com/fs-core": "npm:4.57.2" + "@jsonjoy.com/fs-node-builtins": "npm:4.57.2" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" + "@jsonjoy.com/fs-print": "npm:4.57.2" + "@jsonjoy.com/fs-snapshot": "npm:4.57.2" glob-to-regex.js: "npm:^1.0.0" thingies: "npm:^2.5.0" peerDependencies: tslib: 2 - checksum: 10/405f6212c93a4c3c5120a3bd662c32d3339bbc564e77aff577ea1b577ab639786a04787b9ae0dc6108aeffc197fde13984d04444361da844bdbd61371308dda8 + checksum: 10/2e7777874624035b5503a6d7cbefa82c06adcf3ad63140cd8ce83082b46dace5f8981f98121cb27c79f5755b3790623fbc9facf2b27b00aca28498d4d33df611 languageName: node linkType: hard -"@jsonjoy.com/fs-print@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-print@npm:4.57.3" +"@jsonjoy.com/fs-print@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-print@npm:4.57.2" dependencies: - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" tree-dump: "npm:^1.1.0" peerDependencies: tslib: 2 - checksum: 10/fc01aa6945b28c559912949edd882ac29d217d09e2d2f4a87eb52221aa714b85654e64392d3efcbc241b854dac4b265c1ef8d0e170f4b3c7237a9ce012991334 + checksum: 10/4931c8de684a655ab11ba2285016c35f3558f1f8f3444ac42fe7f5ea417556661b6f88d238c107f30c30b2d131eb2e0ecb1bb8626a7f6e0440996f42ea31dd7b languageName: node linkType: hard -"@jsonjoy.com/fs-snapshot@npm:4.57.3": - version: 4.57.3 - resolution: "@jsonjoy.com/fs-snapshot@npm:4.57.3" +"@jsonjoy.com/fs-snapshot@npm:4.57.2": + version: 4.57.2 + resolution: "@jsonjoy.com/fs-snapshot@npm:4.57.2" dependencies: "@jsonjoy.com/buffers": "npm:^17.65.0" - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" "@jsonjoy.com/json-pack": "npm:^17.65.0" "@jsonjoy.com/util": "npm:^17.65.0" peerDependencies: tslib: 2 - checksum: 10/a45d533bf4c5f5fed1f676b199c470acd43fa33e81e86ce0ecc8c6561032c24a995691568b348bf6f9ec362a399ebdd0d2cf0452dbf07931928b7c8b88c5866d + checksum: 10/191b9d9a63f0ad30342da7ab37a45bbe84c935ae204e3780d69acf2fb12fdf07f7da8c3ade38255207de72fdb3b64a30e8dcc2ad93cfe424e77697d3cd419166 languageName: node linkType: hard @@ -7686,6 +7686,32 @@ __metadata: languageName: unknown linkType: soft +"@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": "npm:^0.2.0" + "@metamask/messenger": "npm:^1.2.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" + deepmerge: "npm:^4.2.2" + 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" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@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" @@ -8609,9 +8635,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0": - version: 11.1.0 - resolution: "@metamask/snaps-sdk@npm:11.1.0" +"@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0, @metamask/snaps-sdk@npm:^11.1.1": + version: 11.1.1 + resolution: "@metamask/snaps-sdk@npm:11.1.1" dependencies: "@metamask/key-tree": "npm:^10.1.1" "@metamask/providers": "npm:^22.1.1" @@ -8619,23 +8645,23 @@ __metadata: "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.11.0" luxon: "npm:^3.5.0" - checksum: 10/138c616584d537b9976ae48123090ab5731848d79d5d1f4e979c797dfdfe061329cbf18a5e84d8bd068fe36d5b9d169337f6d74efab0736f30c31ddf4088f70b + checksum: 10/8419625775b83045d98c4ed920002ad884471e2303f6650104c97457c238f095ff35030663ddf111ebb6ce8f4cd915f57b7b9a5597d00288093701dbb79e787c languageName: node linkType: hard "@metamask/snaps-utils@npm:^12.1.2, @metamask/snaps-utils@npm:^12.1.3, @metamask/snaps-utils@npm:^12.2.0": - version: 12.2.0 - resolution: "@metamask/snaps-utils@npm:12.2.0" + version: 12.2.1 + resolution: "@metamask/snaps-utils@npm:12.2.1" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" "@metamask/key-tree": "npm:^10.1.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/permission-controller": "npm:^12.3.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/permission-controller": "npm:^13.1.1" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.4.0" "@metamask/snaps-registry": "npm:^4.0.0" - "@metamask/snaps-sdk": "npm:^11.1.0" + "@metamask/snaps-sdk": "npm:^11.1.1" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.11.0" "@scure/base": "npm:^1.1.1" @@ -8650,7 +8676,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.15.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/0e7cb5a4deebad3dc98404486b7767be049e9f86173195e9b1ae577f197e67cdd392f1e05ebb146a199ffc3cd52013636637a4d1ac7f9fec164f3189be97df55 + checksum: 10/fda8caefa414d9755a2305b345b5bbbf30ee614215a49070449f314d5d906c645457cf0d7f4881a82f69d398aff5ad2fe9c648bbb2a5003c29c2d01e2f42f199 languageName: node linkType: hard @@ -12166,24 +12192,24 @@ __metadata: linkType: hard "algoliasearch@npm:^5.37.0": - version: 5.53.0 - resolution: "algoliasearch@npm:5.53.0" - dependencies: - "@algolia/abtesting": "npm:1.19.0" - "@algolia/client-abtesting": "npm:5.53.0" - "@algolia/client-analytics": "npm:5.53.0" - "@algolia/client-common": "npm:5.53.0" - "@algolia/client-insights": "npm:5.53.0" - "@algolia/client-personalization": "npm:5.53.0" - "@algolia/client-query-suggestions": "npm:5.53.0" - "@algolia/client-search": "npm:5.53.0" - "@algolia/ingestion": "npm:1.53.0" - "@algolia/monitoring": "npm:1.53.0" - "@algolia/recommend": "npm:5.53.0" - "@algolia/requester-browser-xhr": "npm:5.53.0" - "@algolia/requester-fetch": "npm:5.53.0" - "@algolia/requester-node-http": "npm:5.53.0" - checksum: 10/0e87f7c87bde32963c3d83f514775abc2242b9a958150b1c72b94b3b39fb086e84a8745dbe7bfa49c32e7578d4311dcdabf28f457e1e07556df460cb35e84e09 + version: 5.52.1 + resolution: "algoliasearch@npm:5.52.1" + dependencies: + "@algolia/abtesting": "npm:1.18.1" + "@algolia/client-abtesting": "npm:5.52.1" + "@algolia/client-analytics": "npm:5.52.1" + "@algolia/client-common": "npm:5.52.1" + "@algolia/client-insights": "npm:5.52.1" + "@algolia/client-personalization": "npm:5.52.1" + "@algolia/client-query-suggestions": "npm:5.52.1" + "@algolia/client-search": "npm:5.52.1" + "@algolia/ingestion": "npm:1.52.1" + "@algolia/monitoring": "npm:1.52.1" + "@algolia/recommend": "npm:5.52.1" + "@algolia/requester-browser-xhr": "npm:5.52.1" + "@algolia/requester-fetch": "npm:5.52.1" + "@algolia/requester-node-http": "npm:5.52.1" + checksum: 10/c7f4d39079caad9ebe9487bc112c3fce4a9321f6140f6b2b55b92f17068e8613312163799f073a3493c19eb954389c4c3f4f584c35535824be89a594bf7cc3d2 languageName: node linkType: hard @@ -12878,11 +12904,11 @@ __metadata: linkType: hard "brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": - version: 2.1.0 - resolution: "brace-expansion@npm:2.1.0" + version: 2.1.1 + resolution: "brace-expansion@npm:2.1.1" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/c77a7a64aabf94b8d5913955adb4f36957917565374461355bb4276830c027a313d981f32410cea9e38f52573e7eb776d02fe05091c3a79a061958d97e4d2b43 + checksum: 10/4681c533dc4e6c77b3ad795b38683d297fd03c739a17bfb2a338529fa7dcf4540683a79dcd662905f4c5b0db7cfda18daafcd18dd1bbf7c3b076fe0c9c3487eb languageName: node linkType: hard @@ -14695,9 +14721,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.5.328": - version: 1.5.364 - resolution: "electron-to-chromium@npm:1.5.364" - checksum: 10/2d498239abc5f9891eb8b6a7c872e5ace9c1aa6094f42a4adf5a2b386cadd4e1a0ee93b5ffe042db5b6320562e9a07fa83adb435f3ed580d8a2fafff580025d2 + version: 1.5.361 + resolution: "electron-to-chromium@npm:1.5.361" + checksum: 10/1144dc40c7e0588c179fbc64ac97215a9af897f95137191a857961f2ea889126a8ccc412bab35990906cf65645251b45245403f91a7cd293a970c22cbb58a905 languageName: node linkType: hard @@ -14794,12 +14820,12 @@ __metadata: linkType: hard "enhanced-resolve@npm:^5.15.0, enhanced-resolve@npm:^5.17.1, enhanced-resolve@npm:^5.22.0": - version: 5.22.1 - resolution: "enhanced-resolve@npm:5.22.1" + version: 5.22.0 + resolution: "enhanced-resolve@npm:5.22.0" dependencies: graceful-fs: "npm:^4.2.4" tapable: "npm:^2.3.3" - checksum: 10/2124366118c1e93836b23b4aad933352ba8d404c2c50129388b5d83520bfea1021169e975967e048bfca3a82cbf07756ba76a26e1eab305ef06b2ab1a384e6c0 + checksum: 10/faf794fe6edbad45beee2f50418be98507de7d961e7d8c65e0e3d3fcf30fc39ca467e55f4b02ed0a9a65a58d0ca7b17cc3f5c11f574c36acc47a53b16c6f0825 languageName: node linkType: hard @@ -16697,11 +16723,11 @@ __metadata: linkType: hard "hasown@npm:^2.0.2, hasown@npm:^2.0.3": - version: 2.0.4 - resolution: "hasown@npm:2.0.4" + version: 2.0.3 + resolution: "hasown@npm:2.0.3" dependencies: function-bind: "npm:^1.1.2" - checksum: 10/13823863ae48161068b4c51606a3128451c66f14545a5169d667fe9fca168dcd38c27570c7a299e32ef844b8da3d55def7fe88602f8970d4311fb543ee88001a + checksum: 10/619526379cda755409d856cbf3c65b82ea342151719a0a550920cf7d6a7f58f7cf079e5a78f3acd162324fc784a3d3d6f6f61aff613b47a0163c16fbe09ea89f languageName: node linkType: hard @@ -17398,6 +17424,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" @@ -18714,12 +18747,12 @@ __metadata: linkType: hard "launch-editor@npm:^2.6.1": - version: 2.14.0 - resolution: "launch-editor@npm:2.14.0" + version: 2.13.2 + resolution: "launch-editor@npm:2.13.2" dependencies: picocolors: "npm:^1.1.1" - shell-quote: "npm:^1.8.4" - checksum: 10/d5eb75c6463f95af5c14d4d8b534e7ae4178d6f4043e0c239fe7e442c59028ddcb7dbca1b1708407accb683fe07f35d49bc94b019d45b88d62aa0a0209bcd354 + shell-quote: "npm:^1.8.3" + checksum: 10/2b718ae4d3494526c9493a8c8f32e3824a79885e3b3be2e7e0db5ff74811b12af41760c4b904692cb43ddbd815ce65be245910e7ae84c3cc8ecbad4923657115 languageName: node linkType: hard @@ -19332,17 +19365,17 @@ __metadata: linkType: hard "memfs@npm:^4.43.1": - version: 4.57.3 - resolution: "memfs@npm:4.57.3" - dependencies: - "@jsonjoy.com/fs-core": "npm:4.57.3" - "@jsonjoy.com/fs-fsa": "npm:4.57.3" - "@jsonjoy.com/fs-node": "npm:4.57.3" - "@jsonjoy.com/fs-node-builtins": "npm:4.57.3" - "@jsonjoy.com/fs-node-to-fsa": "npm:4.57.3" - "@jsonjoy.com/fs-node-utils": "npm:4.57.3" - "@jsonjoy.com/fs-print": "npm:4.57.3" - "@jsonjoy.com/fs-snapshot": "npm:4.57.3" + version: 4.57.2 + resolution: "memfs@npm:4.57.2" + dependencies: + "@jsonjoy.com/fs-core": "npm:4.57.2" + "@jsonjoy.com/fs-fsa": "npm:4.57.2" + "@jsonjoy.com/fs-node": "npm:4.57.2" + "@jsonjoy.com/fs-node-builtins": "npm:4.57.2" + "@jsonjoy.com/fs-node-to-fsa": "npm:4.57.2" + "@jsonjoy.com/fs-node-utils": "npm:4.57.2" + "@jsonjoy.com/fs-print": "npm:4.57.2" + "@jsonjoy.com/fs-snapshot": "npm:4.57.2" "@jsonjoy.com/json-pack": "npm:^1.11.0" "@jsonjoy.com/util": "npm:^1.9.0" glob-to-regex.js: "npm:^1.0.1" @@ -19351,7 +19384,7 @@ __metadata: tslib: "npm:^2.0.0" peerDependencies: tslib: 2 - checksum: 10/b2cf357688585e3db9786fd51a519deeb8af18ccf2d82d183df9ef5faf2a2d0c9d32d88408de2750f97e9a9c9aa0e987e9ccbd3aca89d4ecc4181e9e5edea670 + checksum: 10/872b08504889b616a2ec28655509632112d80f8f0fd6d8c23219f4f62b4ff7d6a890205e3127379d985f3bdcaf82909a9f2c3a17867495f98b4678bc2af8a458 languageName: node linkType: hard @@ -22410,10 +22443,12 @@ __metadata: languageName: node linkType: hard -"psl@npm:^1.1.33": - version: 1.9.0 - resolution: "psl@npm:1.9.0" - checksum: 10/d07879d4bfd0ac74796306a8e5a36a93cfb9c4f4e8ee8e63fbb909066c192fe1008cd8f12abd8ba2f62ca28247949a20c8fb32e1d18831d9e71285a1569720f9 +"psl@npm:^1.1.33, 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 @@ -22434,7 +22469,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 @@ -23624,7 +23659,7 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:^1.8.4": +"shell-quote@npm:^1.8.3": version: 1.8.4 resolution: "shell-quote@npm:1.8.4" checksum: 10/a3e3796385f2cd5cf0b78207a4439f0c7395c0833fc75b2473084b5d298c109c5c0fa687fcd1c04e4b4484866e5bb8eaae7efae443b80fff71ea7e29baf11f0c @@ -24401,8 +24436,8 @@ __metadata: linkType: hard "terser-webpack-plugin@npm:^5.3.9, terser-webpack-plugin@npm:^5.5.0": - version: 5.6.1 - resolution: "terser-webpack-plugin@npm:5.6.1" + version: 5.6.0 + resolution: "terser-webpack-plugin@npm:5.6.0" dependencies: "@jridgewell/trace-mapping": "npm:^0.3.25" jest-worker: "npm:^27.4.5" @@ -24435,7 +24470,7 @@ __metadata: optional: true uglify-js: optional: true - checksum: 10/75e5ebb6759ccd85f34a5af04c2f1693f61d7691a4a1614f0892a1d2caf29c12395d1d04d4f4014d4c4a77b63f9f7f36ac9241fcbcc24085a7575d4a4310d70f + checksum: 10/15cae5c297146c6909a1c2daf2e3f4f6c1893416a3d19c388363bc963f1a3fd720803aceed44330bc52775434c85aa6df8d80f9524860568673195bafb600316 languageName: node linkType: hard