diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6910a0f8952..f64b04da0a7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -72,6 +72,7 @@ /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform /packages/connectivity-controller @MetaMask/core-platform +/packages/config-registry-controller @MetaMask/core-platform /packages/controller-utils @MetaMask/core-platform /packages/error-reporting-service @MetaMask/core-platform /packages/eth-json-rpc-middleware @MetaMask/core-platform diff --git a/.gitignore b/.gitignore index c19f8765313..4135572bb1f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,8 @@ scripts/coverage !.yarn/releases !.yarn/sdks !.yarn/versions - +.yalc +yalc.lock # typescript packages/*/*.tsbuildinfo diff --git a/README.md b/README.md index 7d25e92c139..9fd33665513 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/chain-agnostic-permission`](packages/chain-agnostic-permission) - [`@metamask/claims-controller`](packages/claims-controller) - [`@metamask/composable-controller`](packages/composable-controller) +- [`@metamask/config-registry-controller`](packages/config-registry-controller) - [`@metamask/connectivity-controller`](packages/connectivity-controller) - [`@metamask/controller-utils`](packages/controller-utils) - [`@metamask/core-backend`](packages/core-backend) @@ -113,6 +114,7 @@ linkStyle default opacity:0.5 chain_agnostic_permission(["@metamask/chain-agnostic-permission"]); claims_controller(["@metamask/claims-controller"]); composable_controller(["@metamask/composable-controller"]); + config_registry_controller(["@metamask/config-registry-controller"]); connectivity_controller(["@metamask/connectivity-controller"]); controller_utils(["@metamask/controller-utils"]); core_backend(["@metamask/core-backend"]); diff --git a/package.json b/package.json index 7784000fbfb..68dcf8ded46 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "@metamask/eth-block-tracker": "^15.0.1", "@metamask/eth-json-rpc-provider": "^6.0.0", "@metamask/json-rpc-engine": "^10.2.1", - "@metamask/network-controller": "^29.0.0", "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", @@ -112,7 +111,8 @@ "@keystonehq/bc-ur-registry-eth>hdkey>secp256k1": true, "babel-runtime>core-js": false, "simple-git-hooks": false, - "tsx>esbuild": false + "tsx>esbuild": false, + "eslint-plugin-import-x>unrs-resolver": false } } } diff --git a/packages/config-registry-controller/CHANGELOG.md b/packages/config-registry-controller/CHANGELOG.md new file mode 100644 index 00000000000..fa33519f175 --- /dev/null +++ b/packages/config-registry-controller/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/config-registry-controller` ([#7668](https://github.com/MetaMask/core/pull/7668)) + - Controller for fetching and managing network configurations from a remote API + - ConfigRegistryApiService with ETag support, retries, circuit breaker, and timeout handling + - Network filtering to only include featured, active, non-testnet networks + - Feature flag integration using `config_registry_api_enabled` to enable/disable API fetching + - Fallback configuration support when API is unavailable or feature flag is disabled + - State persistence for configs, version, lastFetched, and etag + - Uses StaticIntervalPollingController for periodic updates (default: 24 hours) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/config-registry-controller/LICENSE b/packages/config-registry-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/config-registry-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 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/config-registry-controller/README.md b/packages/config-registry-controller/README.md new file mode 100644 index 00000000000..1f849943f62 --- /dev/null +++ b/packages/config-registry-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/config-registry-controller` + +Manages configuration registry for MetaMask + +## Installation + +`yarn add @metamask/config-registry-controller` + +or + +`npm install @metamask/config-registry-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/config-registry-controller/jest.config.js b/packages/config-registry-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/config-registry-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * 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, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/config-registry-controller/package.json b/packages/config-registry-controller/package.json new file mode 100644 index 00000000000..5aeb58d9208 --- /dev/null +++ b/packages/config-registry-controller/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/config-registry-controller", + "version": "0.0.1", + "description": "Manages configuration registry for MetaMask", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/config-registry-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "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" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "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/config-registry-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/config-registry-controller", + "publish:preview": "yarn npm publish --tag preview", + "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.0.0", + "@metamask/controller-utils": "^11.18.0", + "@metamask/messenger": "^0.3.0", + "@metamask/polling-controller": "^16.0.2", + "@metamask/profile-sync-controller": "^27.0.0", + "@metamask/remote-feature-flag-controller": "^4.0.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "nock": "^13.3.1", + "sinon": "^9.2.4", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/config-registry-controller/src/ConfigRegistryController.test.ts b/packages/config-registry-controller/src/ConfigRegistryController.test.ts new file mode 100644 index 00000000000..446bcfc54c4 --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController.test.ts @@ -0,0 +1,2713 @@ +import type { MockAnyNamespace } from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import { useFakeTimers } from 'sinon'; + +import type { + FetchConfigResult, + RegistryNetworkConfig, +} from './config-registry-api-service'; +import { + ConfigRegistryController, + DEFAULT_POLLING_INTERVAL, +} from './ConfigRegistryController'; +import type { + ConfigRegistryMessenger, + ConfigRegistryState, + NetworkConfigEntry, +} from './ConfigRegistryController'; +import { advanceTime } from '../../../tests/helpers'; + +const namespace = 'ConfigRegistryController' as const; + +type RootMessenger = Messenger< + MockAnyNamespace, + | { type: 'RemoteFeatureFlagController:getState'; handler: () => unknown } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { etag?: string }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } +>; + +/** + * Constructs a messenger for ConfigRegistryController. + * + * @returns A controller messenger and root messenger. + */ +function getConfigRegistryControllerMessenger(): { + messenger: ConfigRegistryMessenger; + rootMessenger: RootMessenger; +} { + const rootMessenger = new Messenger< + MockAnyNamespace, + | { type: 'RemoteFeatureFlagController:getState'; handler: () => unknown } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { etag?: string }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const configRegistryControllerMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof rootMessenger + >({ + namespace, + parent: rootMessenger, + }) as ConfigRegistryMessenger; + + rootMessenger.delegate({ + messenger: configRegistryControllerMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: ['KeyringController:unlock', 'KeyringController:lock'] as never[], + }); + + return { messenger: configRegistryControllerMessenger, rootMessenger }; +} + +/** + * Creates a mock RegistryNetworkConfig for testing. + * + * @param overrides - Optional properties to override in the default RegistryNetworkConfig. + * @returns A mock RegistryNetworkConfig object. + */ +function createMockNetworkConfig( + overrides: Partial = {}, +): RegistryNetworkConfig { + return { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isActive: true, + isTestnet: false, + isDefault: true, + isFeatured: true, + isDeprecated: false, + priority: 0, + isDeletable: false, + ...overrides, + }; +} + +const MOCK_CONFIG_ENTRY: NetworkConfigEntry = { + key: 'test-key', + value: createMockNetworkConfig({ chainId: '0x1', name: 'Test Network' }), + metadata: { source: 'test' }, +}; + +const MOCK_FALLBACK_CONFIG: Record = { + 'fallback-key': { + key: 'fallback-key', + value: createMockNetworkConfig({ + chainId: '0x2', + name: 'Fallback Network', + }), + }, +}; + +/** + * Builds a mock API service fetch handler. + * + * @param overrides - Optional overrides object containing fetchConfig implementation. + * @param overrides.fetchConfig - Optional fetchConfig function override. + * @returns A handler function for the fetchConfig action. + */ +function buildMockApiServiceHandler(overrides?: { + fetchConfig?: (options?: { etag?: string }) => Promise; +}): (options?: { etag?: string }) => Promise { + const defaultFetchConfig = async (): Promise => { + return { + data: { + data: { + version: '1', + timestamp: Date.now(), + networks: [], + }, + }, + modified: true, + }; + }; + + return overrides?.fetchConfig ?? defaultFetchConfig; +} + +type WithControllerCallback = (args: { + controller: ConfigRegistryController; + clock: sinon.SinonFakeTimers; + rootMessenger: RootMessenger; + messenger: ConfigRegistryMessenger; + mockApiServiceHandler: jest.Mock; + mockRemoteFeatureFlagGetState: jest.Mock; + mockKeyringControllerGetState: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const clock = useFakeTimers(); + const { messenger, rootMessenger } = getConfigRegistryControllerMessenger(); + const mockApiServiceHandler = jest.fn(buildMockApiServiceHandler()); + + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + + const mockRemoteFeatureFlagGetState = jest.fn().mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + + const mockKeyringControllerGetState = jest.fn().mockReturnValue({ + isUnlocked: false, + }); + + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mockKeyringControllerGetState, + ); + + const controller = new ConfigRegistryController({ + messenger, + ...options, + }); + + // Initialize the controller after creation + controller.init(); + + try { + return await testFunction({ + controller, + clock, + rootMessenger, + messenger, + mockApiServiceHandler, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + }); + } finally { + controller.stopPolling(); + controller.destroy(); + clock.restore(); + mockApiServiceHandler.mockReset(); + } +} + +describe('ConfigRegistryController', () => { + describe('constructor', () => { + it('should set default state', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + configs: { networks: {} }, + version: null, + lastFetched: null, + fetchError: null, + etag: null, + }); + }); + }); + + it('should set initial state when provided', async () => { + const initialState: Partial = { + configs: { + networks: { + 'test-key': MOCK_CONFIG_ENTRY, + }, + }, + version: 'v1.0.0', + lastFetched: 1234567890, + }; + + await withController( + { options: { state: initialState } }, + ({ controller }) => { + expect(controller.state.configs.networks).toStrictEqual( + initialState.configs?.networks, + ); + expect(controller.state.version).toBe('v1.0.0'); + expect(controller.state.lastFetched).toBe(1234567890); + }, + ); + }); + + it('should set custom polling interval', async () => { + const customInterval = 5000; + await withController( + { options: { pollingInterval: customInterval } }, + ({ controller }) => { + expect(controller.getIntervalLength()).toBe(customInterval); + }, + ); + }); + + it('should set fallback config', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + ({ controller }) => { + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('should work when API service is registered on messenger', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + configs: { networks: {} }, + version: null, + lastFetched: null, + fetchError: null, + etag: null, + }); + }); + }); + }); + + describe('polling', () => { + it('should start polling', async () => { + await withController(async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + + expect(executePollSpy).toHaveBeenCalledTimes(1); + controller.stopPolling(); + }); + }); + + it('should poll at specified interval', async () => { + const pollingInterval = 1000; + await withController( + { options: { pollingInterval } }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + executePollSpy.mockClear(); + + await advanceTime({ clock, duration: pollingInterval }); + + expect(executePollSpy).toHaveBeenCalledTimes(1); + controller.stopPolling(); + }, + ); + }); + + it('should stop polling', async () => { + await withController(async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + executePollSpy.mockClear(); + + controller.stopPolling(); + + await advanceTime({ clock, duration: DEFAULT_POLLING_INTERVAL }); + + expect(executePollSpy).not.toHaveBeenCalled(); + }); + }); + + it('should use fallback config when no configs exist', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }); + + await testController._executePoll(null); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(testController.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(testController.state.fetchError).toBe('Network error'); + }, + ); + }); + + it('should set fetchError when configs already exist (not use fallback)', async () => { + const existingConfigs = { + networks: { + 'existing-key': { + key: 'existing-key', + value: createMockNetworkConfig({ + chainId: '0x3', + name: 'Existing Network', + }), + }, + }, + }; + + await withController( + { + options: { + state: { configs: existingConfigs }, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }, + }, + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + state: { configs: existingConfigs }, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }); + + await testController._executePoll(null); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(testController.state.configs).toStrictEqual(existingConfigs); + expect(testController.state.fetchError).toBe('Network error'); + }, + ); + }); + + it('should handle errors during polling', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValueOnce({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }); + + await testController._executePoll(null); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(testController.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(testController.state.fetchError).toBe('Network error'); + expect(mockRemoteFeatureFlagGetState).toHaveBeenCalled(); + }, + ); + }); + + it('should handle unmodified response and clear fetchError', async () => { + await withController( + { + options: { + state: { + fetchError: 'Previous error', + }, + }, + }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + etag: '"test-etag"', + }); + + const beforeTimestamp = Date.now(); + await controller._executePoll(null); + const afterTimestamp = Date.now(); + + expect(controller.state.fetchError).toBeNull(); + expect(controller.state.etag).toBe('"test-etag"'); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('should handle unmodified response and preserve existing etag when not provided', async () => { + await withController( + { + options: { + state: { + fetchError: 'Previous error', + etag: '"existing-etag"', + }, + }, + }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + }); + + const beforeTimestamp = Date.now(); + await controller._executePoll(null); + const afterTimestamp = Date.now(); + + expect(controller.state.fetchError).toBeNull(); + expect(controller.state.etag).toBe('"existing-etag"'); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('should handle unmodified response and set etag to null when explicitly null', async () => { + await withController( + { + options: { + state: { + fetchError: 'Previous error', + etag: '"existing-etag"', + }, + }, + }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + etag: null, + }); + + const beforeTimestamp = Date.now(); + await controller._executePoll(null); + const afterTimestamp = Date.now(); + + expect(controller.state.fetchError).toBeNull(); + expect(controller.state.etag).toBeNull(); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('should handle validation error from service', async () => { + await withController( + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error from superstruct', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + }); + + await testController._executePoll(null); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error from superstruct', + }), + ); + expect(testController.state.fetchError).toBe( + 'Validation error from superstruct', + ); + }, + ); + }); + + it('should handle validation error when result.data.data is missing', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.data is missing', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + await controller._executePoll(null); + + expect(controller.state.fetchError).toBe( + 'Validation error: data.data is missing', + ); + }, + ); + }); + + it('should handle validation error when result.data.data.networks is not an array', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.data.networks is not an array', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + await controller._executePoll(null); + + expect(controller.state.fetchError).toBe( + 'Validation error: data.data.networks is not an array', + ); + }, + ); + }); + + it('should handle validation error when result.data.data.version is not a string', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.data.version is not a string', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + await controller._executePoll(null); + + expect(controller.state.fetchError).toBe( + 'Validation error: data.data.version is not a string', + ); + }, + ); + }); + + it('should skip fetch when lastFetched is within polling interval', async () => { + const recentTimestamp = Date.now() - 1000; + await withController( + { + options: { + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, messenger, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const fetchConfigSpy = jest.spyOn( + messenger, + 'call', + ) as jest.SpyInstance; + + await controller._executePoll(null); + + expect(fetchConfigSpy).not.toHaveBeenCalledWith( + 'ConfigRegistryApiService:fetchConfig', + expect.anything(), + ); + }, + ); + }); + + it('should proceed with fetch when lastFetched is null', async () => { + await withController( + { + options: { + state: { + lastFetched: null, + }, + }, + }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + networks: [ + createMockNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + ], + version: '1.0.0', + }, + }, + etag: '"test-etag"', + modified: true, + }); + + await controller._executePoll(null); + + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.lastFetched).not.toBeNull(); + }, + ); + }); + + it('should proceed with fetch when enough time has passed since lastFetched', async () => { + const now = Date.now(); + const oldTimestamp = now - DEFAULT_POLLING_INTERVAL - 1000; + await withController( + { + options: { + state: { + lastFetched: oldTimestamp, + }, + }, + }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: now, + }); + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + networks: [ + createMockNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + ], + version: '1.0.0', + }, + }, + etag: '"test-etag"', + modified: true, + }); + + await controller._executePoll(null); + + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.lastFetched).not.toBe(oldTimestamp); + + jest.restoreAllMocks(); + }, + ); + }); + + it('should use custom polling interval when checking lastFetched', async () => { + const customInterval = 5000; + const recentTimestamp = Date.now() - 3000; + await withController( + { + options: { + pollingInterval: customInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, messenger, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const fetchConfigSpy = jest.spyOn( + messenger, + 'call', + ) as jest.SpyInstance; + + await controller._executePoll(null); + + expect(fetchConfigSpy).not.toHaveBeenCalledWith( + 'ConfigRegistryApiService:fetchConfig', + expect.anything(), + ); + }, + ); + }); + + it('should use DEFAULT_POLLING_INTERVAL when getIntervalLength returns undefined', async () => { + const recentTimestamp = Date.now() - 1000; + await withController( + { + options: { + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, messenger, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + jest + .spyOn(controller, 'getIntervalLength') + .mockReturnValue(undefined); + + const fetchConfigSpy = jest.spyOn( + messenger, + 'call', + ) as jest.SpyInstance; + + await controller._executePoll(null); + + expect(fetchConfigSpy).not.toHaveBeenCalledWith( + 'ConfigRegistryApiService:fetchConfig', + expect.anything(), + ); + }, + ); + }); + + it('should handle non-Error exceptions', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue('String error'); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }); + + await testController._executePoll(null); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'String error' }), + ); + expect(testController.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(testController.state.fetchError).toBe( + 'Unknown error occurred', + ); + }, + ); + }); + + it('should handle error when state.configs is null', async () => { + await withController( + { + options: { + fallbackConfig: MOCK_FALLBACK_CONFIG, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: { configs: null as any }, + }, + }, + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + fallbackConfig: MOCK_FALLBACK_CONFIG, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: { configs: null as any }, + }); + + await testController._executePoll(null); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(testController.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(testController.state.fetchError).toBe('Network error'); + }, + ); + }); + + it('should work via messenger actions', async () => { + await withController(async ({ controller, messenger, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + const token = messenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + messenger.call('ConfigRegistryController:stopPolling'); + await advanceTime({ clock, duration: DEFAULT_POLLING_INTERVAL }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('state persistence', () => { + it('should persist version', async () => { + await withController( + { options: { state: { version: 'v1.0.0' } } }, + ({ controller }) => { + expect(controller.state.version).toBe('v1.0.0'); + }, + ); + }); + + it('should persist lastFetched', async () => { + const timestamp = Date.now(); + await withController( + { options: { state: { lastFetched: timestamp } } }, + ({ controller }) => { + expect(controller.state.lastFetched).toBe(timestamp); + }, + ); + }); + + it('should persist fetchError', async () => { + await withController( + { options: { state: { fetchError: 'Test error' } } }, + ({ controller }) => { + expect(controller.state.fetchError).toBe('Test error'); + }, + ); + }); + }); + + describe('startPolling', () => { + it('should return a polling token string', async () => { + await withController(({ controller }) => { + const token = controller.startPolling(null); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + + controller.stopPolling(); + }); + }); + + it('should return a polling token string when called without input', async () => { + await withController(({ controller }) => { + const token = controller.startPolling(null); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + + controller.stopPolling(); + }); + }); + + it('should delay first poll when lastFetched is recent', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + const remainingTime = pollingInterval - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + await advanceTime({ clock, duration: remainingTime + 1 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + }, + ); + }); + + it('should proceed immediately when lastFetched is null', async () => { + await withController( + { + options: { + state: { + lastFetched: null, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + }, + ); + }); + + it('should proceed immediately when lastFetched is old enough', async () => { + const pollingInterval = 10000; + const now = Date.now(); + const oldTimestamp = now - pollingInterval - 1000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: oldTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 1 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + jest.restoreAllMocks(); + }, + ); + }); + + it('should proceed immediately when lastFetched is exactly at polling interval', async () => { + const pollingInterval = 10000; + const now = Date.now(); + const exactTimestamp = now - pollingInterval - 1; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: exactTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 1 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + jest.restoreAllMocks(); + }, + ); + }); + + it('should use DEFAULT_POLLING_INTERVAL when getIntervalLength returns undefined', async () => { + const recentTimestamp = Date.now() - 1000; + await withController( + { + options: { + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + jest + .spyOn(controller, 'getIntervalLength') + .mockReturnValue(undefined); + + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + controller.stopPolling(); + }, + ); + }); + + it('should clear existing timeout when startPolling is called multiple times', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + // First call sets a timeout + controller.startPolling(null); + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + const clearTimeoutCallCountBefore = clearTimeoutSpy.mock.calls.length; + + // Second call should clear the existing timeout (from first call) and set a new one + // This tests the defensive check that clears any existing timeout + controller.startPolling(null); + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + // Verify clearTimeout was called to clear the previous timeout + expect(clearTimeoutSpy.mock.calls.length).toBeGreaterThan( + clearTimeoutCallCountBefore, + ); + + controller.stopPolling(); + }, + ); + }); + }); + + describe('feature flag', () => { + it('should use fallback config when feature flag is disabled', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ controller, clock, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: false, + }, + cacheTimestamp: Date.now(), + }); + + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + + expect(executePollSpy).toHaveBeenCalledTimes(1); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(controller.state.fetchError).toBe( + 'Feature flag disabled - using fallback configuration', + ); + + controller.stopPolling(); + }, + ); + }); + + it('should use API when feature flag is enabled', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockNetworks = [ + { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + ]; + + const fetchConfigSpy = jest.fn().mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + networks: mockNetworks, + }, + }, + modified: true, + etag: 'test-etag', + }); + + mockApiServiceHandler.mockImplementation(fetchConfigSpy); + + await controller._executePoll(null); + + expect(fetchConfigSpy).toHaveBeenCalled(); + expect(controller.state.configs.networks['0x1']).toBeDefined(); + expect(controller.state.version).toBe('1.0.0'); + expect(controller.state.fetchError).toBeNull(); + }, + ); + }); + + it('should filter networks to only include featured, active, non-testnet networks', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockNetworks = [ + { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + { + chainId: '0x5', + name: 'Goerli', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://goerli.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'goerli', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://goerli.etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: true, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + { + chainId: '0xa', + name: 'Optimism', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://optimism.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'optimism', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://optimistic.etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: false, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + url: 'https://polygon.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'polygon', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://polygonscan.com'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: false, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + ]; + + const fetchConfigSpy = jest.fn().mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + networks: mockNetworks, + }, + }, + modified: true, + etag: 'test-etag', + }); + + mockApiServiceHandler.mockImplementation(fetchConfigSpy); + + await controller._executePoll(null); + + expect(controller.state.configs.networks['0x1']).toBeDefined(); + expect(controller.state.configs.networks['0x5']).toBeUndefined(); + expect(controller.state.configs.networks['0xa']).toBeUndefined(); + expect(controller.state.configs.networks['0x89']).toBeUndefined(); + expect(Object.keys(controller.state.configs.networks)).toHaveLength( + 1, + ); + }, + ); + }); + + it('should handle duplicate chainIds by keeping highest priority network and logging warning', async () => { + await withController( + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + }); + + // Mock API response with duplicate chainIds + const mockNetworks = [ + { + chainId: '0x1', + name: 'Ethereum Mainnet (Low Priority)', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 10, // Lower priority (higher number) + isDeletable: false, + }, + { + chainId: '0x1', + name: 'Ethereum Mainnet (High Priority)', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.alchemy.io/v2/{alchemyApiKey}', + type: 'alchemy', + networkClientId: 'mainnet-alchemy', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, // Higher priority (lower number) + isDeletable: false, + }, + { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + url: 'https://polygon.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'polygon', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://polygonscan.com'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + networks: mockNetworks, + }, + }, + modified: true, + etag: 'test-etag', + }); + + await testController._executePoll(null); + + // Verify warning was logged + expect(captureExceptionSpy).toHaveBeenCalled(); + const warningCall = captureExceptionSpy.mock.calls.find((call) => + call[0]?.message?.includes('Duplicate chainId 0x1'), + ); + expect(warningCall).toBeDefined(); + expect(warningCall?.[0]?.message).toContain( + 'Ethereum Mainnet (Low Priority), Ethereum Mainnet (High Priority)', + ); + + // Verify highest priority network was kept + expect(testController.state.configs.networks['0x1']).toBeDefined(); + expect(testController.state.configs.networks['0x1']?.value.name).toBe( + 'Ethereum Mainnet (High Priority)', + ); + expect( + testController.state.configs.networks['0x1']?.value.rpcEndpoints[0] + .type, + ).toBe('alchemy'); + + // Verify other networks are still present + expect(testController.state.configs.networks['0x89']).toBeDefined(); + }, + ); + }); + + it('should handle duplicate chainIds with same priority by keeping first occurrence', async () => { + await withController( + async ({ mockRemoteFeatureFlagGetState, mockApiServiceHandler }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const captureExceptionSpy = jest.fn(); + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + captureException: captureExceptionSpy, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const testController = new ConfigRegistryController({ + messenger: testMessenger, + }); + + // Mock API response with duplicate chainIds having same priority + const mockNetworks = [ + { + chainId: '0x1', + name: 'Ethereum Mainnet (First)', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 5, // Same priority + isDeletable: false, + }, + { + chainId: '0x1', + name: 'Ethereum Mainnet (Second)', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.alchemy.io/v2/{alchemyApiKey}', + type: 'alchemy', + networkClientId: 'mainnet-alchemy', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 5, // Same priority + isDeletable: false, + }, + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + networks: mockNetworks, + }, + }, + modified: true, + etag: 'test-etag', + }); + + await testController._executePoll(null); + + // Verify warning was logged + expect(captureExceptionSpy).toHaveBeenCalled(); + const warningCall = captureExceptionSpy.mock.calls.find((call) => + call[0]?.message?.includes('Duplicate chainId 0x1'), + ); + expect(warningCall).toBeDefined(); + + // Verify first occurrence was kept (since priorities are equal) + expect(testController.state.configs.networks['0x1']).toBeDefined(); + expect(testController.state.configs.networks['0x1']?.value.name).toBe( + 'Ethereum Mainnet (First)', + ); + }, + ); + }); + + it('should use custom isConfigRegistryApiEnabled function when provided', async () => { + const customIsEnabled = jest.fn().mockReturnValue(true); + await withController( + { + options: { + isConfigRegistryApiEnabled: customIsEnabled, + }, + }, + async ({ controller, messenger, mockApiServiceHandler }) => { + const mockNetworks = [ + { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isTestnet: false, + isFeatured: true, + isActive: true, + isDefault: false, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + networks: mockNetworks, + }, + }, + modified: true, + etag: 'test-etag', + }); + + await controller._executePoll(null); + + expect(customIsEnabled).toHaveBeenCalledWith(messenger); + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.configs.networks['0x1']).toBeDefined(); + expect(controller.state.version).toBe('1.0.0'); + }, + ); + }); + + it('should use custom isConfigRegistryApiEnabled function returning false', async () => { + const customIsEnabled = jest.fn().mockReturnValue(false); + await withController( + { + options: { + fallbackConfig: MOCK_FALLBACK_CONFIG, + isConfigRegistryApiEnabled: customIsEnabled, + }, + }, + async ({ controller, messenger, mockApiServiceHandler }) => { + await controller._executePoll(null); + + expect(customIsEnabled).toHaveBeenCalledWith(messenger); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(controller.state.fetchError).toBe( + 'Feature flag disabled - using fallback configuration', + ); + }, + ); + }); + + it('should default to fallback when feature flag is not set', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ controller, clock, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }); + + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + + expect(executePollSpy).toHaveBeenCalledTimes(1); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(controller.state.fetchError).toBe( + 'Feature flag disabled - using fallback configuration', + ); + + controller.stopPolling(); + }, + ); + }); + + it('should default to fallback when RemoteFeatureFlagController is unavailable', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ controller, clock, mockRemoteFeatureFlagGetState }) => { + mockRemoteFeatureFlagGetState.mockImplementation(() => { + throw new Error('RemoteFeatureFlagController not available'); + }); + + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + + expect(executePollSpy).toHaveBeenCalledTimes(1); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(controller.state.fetchError).toBe( + 'Feature flag disabled - using fallback configuration', + ); + + controller.stopPolling(); + }, + ); + }); + }); + + describe('KeyringController event listeners', () => { + it('should start polling when KeyringController is already unlocked on initialization', async () => { + await withController(async ({ clock }) => { + const mockKeyringControllerGetState = jest.fn().mockReturnValue({ + isUnlocked: true, + }); + + const testRootMessenger = new Messenger< + MockAnyNamespace, + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => unknown; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: { + etag?: string; + }) => Promise; + }, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] } + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const testMessenger = new Messenger< + typeof namespace, + never, + | { type: 'KeyringController:unlock'; payload: [] } + | { type: 'KeyringController:lock'; payload: [] }, + typeof testRootMessenger + >({ + namespace, + parent: testRootMessenger, + }) as ConfigRegistryMessenger; + + testRootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + jest.fn(buildMockApiServiceHandler()), + ); + testRootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }), + ); + testRootMessenger.registerActionHandler( + 'KeyringController:getState', + mockKeyringControllerGetState, + ); + testRootMessenger.delegate({ + messenger: testMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + 'ConfigRegistryApiService:fetchConfig', + ] as never[], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + ] as never[], + }); + + const controller = new ConfigRegistryController({ + messenger: testMessenger, + }); + + // Initialize the controller after creation + controller.init(); + + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + await advanceTime({ clock, duration: 100 }); + + expect(mockKeyringControllerGetState).toHaveBeenCalled(); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + }); + }); + + it('should handle KeyringController:getState error gracefully when KeyringController is unavailable', async () => { + await withController(({ controller, mockKeyringControllerGetState }) => { + mockKeyringControllerGetState.mockImplementation(() => { + throw new Error('KeyringController not available'); + }); + + expect(controller.state.lastFetched).toBeNull(); + }); + }); + + it('should start polling when KeyringController:unlock event is published', async () => { + await withController(async ({ controller, clock, rootMessenger }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + expect(startPollingSpy).not.toHaveBeenCalled(); + + rootMessenger.publish('KeyringController:unlock'); + + await advanceTime({ clock, duration: 0 }); + + expect(startPollingSpy).toHaveBeenCalledWith(null); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + }); + }); + + it('should stop polling when KeyringController:lock event is published', async () => { + await withController( + async ({ + controller, + clock, + rootMessenger, + mockKeyringControllerGetState, + }) => { + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + }); + + const stopPollingSpy = jest.spyOn(controller, 'stopPolling'); + + await advanceTime({ clock, duration: 0 }); + expect(stopPollingSpy).not.toHaveBeenCalled(); + + rootMessenger.publish('KeyringController:lock'); + + expect(stopPollingSpy).toHaveBeenCalled(); + }, + ); + }); + + it('should call startPolling with default parameter when called without arguments', async () => { + await withController(async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + controller.startPolling(); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + + controller.stopPolling(); + }); + }); + }); + + describe('stopPolling', () => { + it('should clear pending delayed poll timeout when stopping', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + // Verify timeout was set (poll should not execute immediately) + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + // Stop polling should clear the timeout + controller.stopPolling(); + + // Advance time past when the timeout would have fired + await advanceTime({ clock, duration: pollingInterval }); + expect(executePollSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should handle clearing timeout when no timeout exists', async () => { + await withController(({ controller }) => { + // Should not throw when stopping without a pending timeout + expect(() => controller.stopPolling()).not.toThrow(); + }); + }); + + it('should stop delayed poll using placeholder token', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + const token = controller.startPolling(null); + + // Verify timeout was set (poll should not execute immediately) + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + // Stop polling using the placeholder token + controller.stopPolling(token); + + // Advance time past when the timeout would have fired + await advanceTime({ clock, duration: pollingInterval }); + expect(executePollSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should stop delayed poll using placeholder token after timeout fires', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + const remainingTime = pollingInterval - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + const token = controller.startPolling(null); + + // Advance time to when the delayed poll starts + await advanceTime({ clock, duration: remainingTime + 1 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + executePollSpy.mockClear(); + + // Stop polling using the placeholder token (should map to actual token) + controller.stopPolling(token); + + // Advance time to verify polling stopped + await advanceTime({ clock, duration: pollingInterval }); + expect(executePollSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should stop all polling when called without token (backward compatible)', async () => { + await withController(async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + // Start polling from multiple consumers + controller.startPolling(null); + controller.startPolling(null); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + executePollSpy.mockClear(); + + // Stop without token should stop all polling + controller.stopPolling(); + + await advanceTime({ clock, duration: DEFAULT_POLLING_INTERVAL }); + expect(executePollSpy).not.toHaveBeenCalled(); + }); + }); + + it('should stop specific polling session when called with token', async () => { + await withController(async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + // Start polling from consumer A + const tokenA = controller.startPolling(null); + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + executePollSpy.mockClear(); + + // Start polling from consumer B (should reuse same polling session) + controller.startPolling(null); + await advanceTime({ clock, duration: 0 }); + // Since both use same input (null), they share the same polling session + // So stopping one token should stop the shared session + expect(executePollSpy).toHaveBeenCalledTimes(0); + executePollSpy.mockClear(); + + // Stop consumer A's polling session + controller.stopPolling(tokenA); + + // Polling should stop for both since they share the same session + await advanceTime({ clock, duration: DEFAULT_POLLING_INTERVAL }); + expect(executePollSpy).not.toHaveBeenCalled(); + }); + }); + + it('should work via messenger action with token', async () => { + await withController(async ({ controller, messenger, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + const token = messenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + executePollSpy.mockClear(); + + // Stop with token via messenger + messenger.call('ConfigRegistryController:stopPolling', token); + await advanceTime({ clock, duration: DEFAULT_POLLING_INTERVAL }); + expect(executePollSpy).not.toHaveBeenCalled(); + }); + }); + + it('should work via messenger action without token (backward compatible)', async () => { + await withController(async ({ controller, messenger, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + const token = messenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).toHaveBeenCalledTimes(1); + executePollSpy.mockClear(); + + // Stop without token via messenger (backward compatible) + messenger.call('ConfigRegistryController:stopPolling'); + await advanceTime({ clock, duration: DEFAULT_POLLING_INTERVAL }); + expect(executePollSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('destroy', () => { + it('should clean up event listeners and action handlers', async () => { + await withController(({ controller, messenger }) => { + const unsubscribeSpy = jest.spyOn(messenger, 'unsubscribe'); + const unregisterActionHandlerSpy = jest.spyOn( + messenger, + 'unregisterActionHandler', + ); + const stopPollingSpy = jest.spyOn(controller, 'stopPolling'); + + controller.destroy(); + + expect(stopPollingSpy).toHaveBeenCalled(); + expect(unsubscribeSpy).toHaveBeenCalledWith( + 'KeyringController:unlock', + expect.any(Function), + ); + expect(unsubscribeSpy).toHaveBeenCalledWith( + 'KeyringController:lock', + expect.any(Function), + ); + expect(unregisterActionHandlerSpy).toHaveBeenCalledWith( + 'ConfigRegistryController:startPolling', + ); + expect(unregisterActionHandlerSpy).toHaveBeenCalledWith( + 'ConfigRegistryController:stopPolling', + ); + }); + }); + + it('should handle unsubscribe errors gracefully', async () => { + await withController(({ controller, messenger }) => { + jest.spyOn(messenger, 'unsubscribe').mockImplementation(() => { + throw new Error('Handler not subscribed'); + }); + + // Should not throw even if unsubscribe fails + expect(() => controller.destroy()).not.toThrow(); + }); + }); + + it('should clear pending timeout when destroying', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, clock }) => { + const executePollSpy = jest.spyOn(controller, '_executePoll'); + controller.startPolling(null); + + // Verify timeout was set + await advanceTime({ clock, duration: 0 }); + expect(executePollSpy).not.toHaveBeenCalled(); + + // Destroy should clear the timeout + controller.destroy(); + + // Advance time past when the timeout would have fired + await advanceTime({ clock, duration: pollingInterval }); + expect(executePollSpy).not.toHaveBeenCalled(); + }, + ); + }); + }); +}); diff --git a/packages/config-registry-controller/src/ConfigRegistryController.ts b/packages/config-registry-controller/src/ConfigRegistryController.ts new file mode 100644 index 00000000000..13d0bd096b1 --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController.ts @@ -0,0 +1,500 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; + +import type { + FetchConfigOptions, + FetchConfigResult, + RegistryNetworkConfig, +} from './config-registry-api-service'; +import { filterNetworks } from './config-registry-api-service'; +import { isConfigRegistryApiEnabled as defaultIsConfigRegistryApiEnabled } from './utils/feature-flags'; + +const controllerName = 'ConfigRegistryController'; + +export const DEFAULT_POLLING_INTERVAL = inMilliseconds(1, Duration.Day); + +export type NetworkConfigEntry = { + key: string; + value: RegistryNetworkConfig; + metadata?: Json; +}; + +/** + * State for the ConfigRegistryController. + * + * Tracks network configurations fetched from the config registry API, + * along with metadata about the fetch status and caching. + */ +export type ConfigRegistryState = { + /** + * Network configurations organized by chain ID. + * Contains the actual network configuration data fetched from the API. + */ + configs: { + networks: Record; + }; + /** + * Semantic version string of the configuration data from the API. + * Indicates the version/schema of the configuration structure itself + * (e.g., "v1.0.0", "1.0.0"). + * This is different from `etag` which is used for HTTP cache validation. + */ + version: string | null; + /** + * Timestamp (milliseconds since epoch) of when the configuration + * was last successfully fetched from the API. + */ + lastFetched: number | null; + /** + * Error message if the last fetch attempt failed. + * Null if the last fetch was successful. + */ + fetchError: string | null; + /** + * HTTP entity tag (ETag) used for cache validation. + * Sent as `If-None-Match` header in subsequent requests to check + * if the content has changed. If the server returns 304 Not Modified, + * the full response body is not downloaded, improving efficiency. + * This is different from `version` which is a semantic version string + * indicating the schema/version of the configuration data itself. + */ + etag: string | null; +}; + +const stateMetadata = { + configs: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, + version: { + persist: true, + includeInStateLogs: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + lastFetched: { + persist: true, + includeInStateLogs: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + fetchError: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + etag: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +const DEFAULT_FALLBACK_CONFIG: Record = {}; + +export type ConfigRegistryControllerStateChangeEvent = + ControllerStateChangeEvent; + +export type ConfigRegistryControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ConfigRegistryState +>; + +export type ConfigRegistryControllerStartPollingAction = { + type: `${typeof controllerName}:startPolling`; + handler: (input: null) => string; +}; + +export type ConfigRegistryControllerStopPollingAction = { + type: `${typeof controllerName}:stopPolling`; + handler: (token?: string) => void; +}; + +export type ConfigRegistryControllerActions = + | ConfigRegistryControllerGetStateAction + | ConfigRegistryControllerStartPollingAction + | ConfigRegistryControllerStopPollingAction + | { + type: 'RemoteFeatureFlagController:getState'; + handler: () => RemoteFeatureFlagControllerState; + } + | { + type: 'KeyringController:getState'; + handler: () => { isUnlocked: boolean }; + } + | { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: (options?: FetchConfigOptions) => Promise; + }; + +export type ConfigRegistryControllerEvents = + | KeyringControllerUnlockEvent + | KeyringControllerLockEvent + | ConfigRegistryControllerStateChangeEvent; + +export type ConfigRegistryMessenger = Messenger< + typeof controllerName, + ConfigRegistryControllerActions, + ConfigRegistryControllerEvents +>; + +export type ConfigRegistryControllerOptions = { + messenger: ConfigRegistryMessenger; + state?: Partial; + pollingInterval?: number; + fallbackConfig?: Record; + isConfigRegistryApiEnabled?: (messenger: ConfigRegistryMessenger) => boolean; +}; + +export class ConfigRegistryController extends StaticIntervalPollingController()< + typeof controllerName, + ConfigRegistryState, + ConfigRegistryMessenger +> { + readonly #fallbackConfig: Record; + + readonly #isConfigRegistryApiEnabled: ( + messenger: ConfigRegistryMessenger, + ) => boolean; + + readonly #unlockHandler: () => void; + + readonly #lockHandler: () => void; + + #delayedPollTimeoutId: ReturnType | null = null; + + readonly #delayedPollTokenMap: Map = new Map(); + + /** + * @param options - The controller options. + * @param options.messenger - The controller messenger. Must have + * `ConfigRegistryApiService:fetchConfig` action handler registered. + * @param options.state - Initial state. + * @param options.pollingInterval - Polling interval in milliseconds. + * @param options.fallbackConfig - Fallback configuration. + * @param options.isConfigRegistryApiEnabled - Function to check if the config + * registry API is enabled. Defaults to checking the remote feature flag. + */ + constructor({ + messenger, + state = {}, + pollingInterval = DEFAULT_POLLING_INTERVAL, + fallbackConfig = DEFAULT_FALLBACK_CONFIG, + isConfigRegistryApiEnabled = defaultIsConfigRegistryApiEnabled, + }: ConfigRegistryControllerOptions) { + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { + configs: { + networks: state.configs?.networks ?? { ...fallbackConfig }, + }, + version: state.version ?? null, + lastFetched: state.lastFetched ?? null, + fetchError: state.fetchError ?? null, + etag: state.etag ?? null, + }, + }); + + this.setIntervalLength(pollingInterval); + this.#fallbackConfig = fallbackConfig; + this.#isConfigRegistryApiEnabled = isConfigRegistryApiEnabled; + + // Store handlers for cleanup + this.#unlockHandler = (): void => { + this.startPolling(null); + }; + + this.#lockHandler = (): void => { + this.stopPolling(); + }; + + this.messenger.registerActionHandler( + `${controllerName}:startPolling`, + (input: null) => this.startPolling(input), + ); + + this.messenger.registerActionHandler( + `${controllerName}:stopPolling`, + (token?: string) => this.stopPolling(token), + ); + + this.#registerKeyringEventListeners(); + } + + /** + * Initializes the controller by checking the KeyringController unlock state + * and starting polling if already unlocked. + * + * This method should be called after all controllers have been initialized + * to avoid runtime dependencies during construction. If KeyringController + * is not available, this method will silently skip initialization. + */ + init(): void { + try { + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + if (isUnlocked) { + this.startPolling(null); + } + } catch { + // KeyringController may not be available, silently handle + } + } + + async _executePoll(_input: null): Promise { + const isApiEnabled = this.#isConfigRegistryApiEnabled(this.messenger); + + if (!isApiEnabled) { + this.#useFallbackConfig( + 'Feature flag disabled - using fallback configuration', + ); + return; + } + + // Check if enough time has passed since last fetch to respect the polling interval + const pollingInterval = + this.getIntervalLength() ?? DEFAULT_POLLING_INTERVAL; + const now = Date.now(); + const { lastFetched } = this.state; + + if (lastFetched !== null && now - lastFetched < pollingInterval) { + // Not enough time has passed, skip the fetch + return; + } + + try { + const result: FetchConfigResult = await this.messenger.call( + 'ConfigRegistryApiService:fetchConfig', + { + etag: this.state.etag ?? undefined, + }, + ); + + if (!result.modified) { + this.update((state) => { + state.fetchError = null; + state.lastFetched = Date.now(); + if (result.etag !== undefined) { + state.etag = result.etag ?? null; + } + }); + return; + } + + // Filter networks: only featured, active, non-testnet networks + const filteredNetworks = filterNetworks(result.data.data.networks, { + isFeatured: true, + isActive: true, + isTestnet: false, + }); + + // Group networks by chainId to detect duplicates + const networksByChainId = new Map< + string, + { network: RegistryNetworkConfig; index: number }[] + >(); + filteredNetworks.forEach((network, index) => { + const existing = networksByChainId.get(network.chainId) ?? []; + existing.push({ network, index }); + networksByChainId.set(network.chainId, existing); + }); + + // Build configs, handling duplicates by keeping highest priority network + const newConfigs: Record = {}; + for (const [chainId, networks] of networksByChainId) { + if (networks.length > 1) { + // Duplicate chainIds detected - log warning and keep highest priority + const duplicateNames = networks + .map((networkEntry) => networkEntry.network.name) + .join(', '); + const warningMessage = `Duplicate chainId ${chainId} detected in config registry API response. Networks: ${duplicateNames}. Keeping network with highest priority.`; + + if (this.messenger.captureException) { + this.messenger.captureException(new Error(warningMessage)); + } + + // Sort by priority (lower number = higher priority), then by index (first occurrence) + networks.sort((a, b) => { + const priorityDiff = a.network.priority - b.network.priority; + if (priorityDiff === 0) { + return a.index - b.index; + } + return priorityDiff; + }); + } + + // Use the first network (highest priority if duplicates existed) + const selectedNetwork = networks[0].network; + newConfigs[chainId] = { + key: chainId, + value: selectedNetwork, + }; + } + + this.update((state) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Type instantiation is excessively deep + (state.configs as ConfigRegistryState['configs']) = { + networks: newConfigs, + }; + state.version = result.data.data.version; + state.lastFetched = Date.now(); + state.fetchError = null; + state.etag = result.etag ?? null; + }); + } catch (error) { + const errorInstance = + error instanceof Error ? error : new Error(String(error)); + + if (this.messenger.captureException) { + this.messenger.captureException(errorInstance); + } + + this.#handleFetchError(error); + } + } + + #useFallbackConfig(errorMessage: string): void { + this.update((state) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Type instantiation is excessively deep + state.configs.networks = { ...this.#fallbackConfig }; + state.fetchError = errorMessage; + state.etag = null; + }); + } + + #handleFetchError(error: unknown): void { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + + this.update((state) => { + state.fetchError = errorMessage; + state.lastFetched = Date.now(); + }); + } + + /** + * Registers event listeners for KeyringController unlock/lock events. + * The listeners will automatically start polling when unlocked and stop when locked. + */ + #registerKeyringEventListeners(): void { + // Subscribe to unlock event - start polling + this.messenger.subscribe('KeyringController:unlock', this.#unlockHandler); + + // Subscribe to lock event - stop polling + this.messenger.subscribe('KeyringController:lock', this.#lockHandler); + } + + startPolling(input: null = null): string { + // Calculate delay based on lastFetched to respect 24-hour interval + const pollingInterval = + this.getIntervalLength() ?? DEFAULT_POLLING_INTERVAL; + const now = Date.now(); + const { lastFetched } = this.state; + + if (lastFetched !== null) { + const timeSinceLastFetch = now - lastFetched; + const remainingTime = pollingInterval - timeSinceLastFetch; + + if (remainingTime > 0) { + // Not enough time has passed, delay the first poll + // Clear any existing timeout before scheduling a new one + if (this.#delayedPollTimeoutId !== null) { + clearTimeout(this.#delayedPollTimeoutId); + this.#delayedPollTimeoutId = null; + } + + // Generate a placeholder token that will map to the actual token + const placeholderToken = `delayed-${Date.now()}-${Math.random()}`; + + // Schedule the first poll after the remaining time + this.#delayedPollTimeoutId = setTimeout(() => { + this.#delayedPollTimeoutId = null; + const actualToken = super.startPolling(input); + this.#delayedPollTokenMap.set(placeholderToken, actualToken); + }, remainingTime); + + return placeholderToken; + } + } + + // Enough time has passed or first time, proceed with normal polling + return super.startPolling(input); + } + + stopPolling(token?: string): void { + // Clear any pending delayed poll timeout + if (this.#delayedPollTimeoutId !== null) { + clearTimeout(this.#delayedPollTimeoutId); + this.#delayedPollTimeoutId = null; + } + + if (token) { + // Check if this is a placeholder token that needs mapping + const actualToken = this.#delayedPollTokenMap.get(token); + if (actualToken) { + // Remove from map and stop the actual polling session + this.#delayedPollTokenMap.delete(token); + super.stopPollingByPollingToken(actualToken); + } else { + // Stop specific polling session by token + super.stopPollingByPollingToken(token); + } + } else { + // Stop all polling (backward compatible) + this.#delayedPollTokenMap.clear(); + super.stopAllPolling(); + } + } + + /** + * Prepares the controller for garbage collection by cleaning up event listeners, + * action handlers, and timers. + */ + override destroy(): void { + // Stop polling and clear any pending timeouts + this.stopPolling(); + this.#delayedPollTokenMap.clear(); + + // Unsubscribe from event listeners + try { + this.messenger.unsubscribe( + 'KeyringController:unlock', + this.#unlockHandler, + ); + } catch { + // Handler may not be subscribed, silently handle + } + + try { + this.messenger.unsubscribe('KeyringController:lock', this.#lockHandler); + } catch { + // Handler may not be subscribed, silently handle + } + + // Unregister action handlers + this.messenger.unregisterActionHandler(`${controllerName}:startPolling`); + this.messenger.unregisterActionHandler(`${controllerName}:stopPolling`); + + // Call parent destroy to clean up base controller subscriptions + super.destroy(); + } +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts new file mode 100644 index 00000000000..22c3d3c0e9f --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts @@ -0,0 +1,534 @@ +import { SDK } from '@metamask/profile-sync-controller'; +import nock, { cleanAll } from 'nock'; +import { useFakeTimers } from 'sinon'; + +import { + ConfigRegistryApiService, + getConfigRegistryUrl, +} from './config-registry-api-service'; +import type { FetchConfigResult, RegistryConfigApiResponse } from './types'; + +const MOCK_API_RESPONSE: RegistryConfigApiResponse = { + data: { + version: '"24952800ba9dafbc5e2c91f57f386d28"', + timestamp: 1761829548000, + networks: [ + { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: [], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isActive: true, + isTestnet: false, + isDefault: true, + isFeatured: true, + isDeprecated: false, + priority: 0, + isDeletable: false, + }, + ], + }, +}; + +describe('ConfigRegistryApiService', () => { + describe('getConfigRegistryUrl', () => { + it('should return UAT URL for UAT environment', () => { + const url = getConfigRegistryUrl(SDK.Env.UAT); + expect(url).toBe( + 'https://client-config.uat-api.cx.metamask.io/v1/config/networks', + ); + }); + + it('should return DEV URL for DEV environment', () => { + const url = getConfigRegistryUrl(SDK.Env.DEV); + expect(url).toBe( + 'https://client-config.dev-api.cx.metamask.io/v1/config/networks', + ); + }); + + it('should return PRD URL for PRD environment', () => { + const url = getConfigRegistryUrl(SDK.Env.PRD); + expect(url).toBe( + 'https://client-config.api.cx.metamask.io/v1/config/networks', + ); + }); + }); + + describe('constructor', () => { + it('should create instance with default options', () => { + const service = new ConfigRegistryApiService(); + expect(service).toBeInstanceOf(ConfigRegistryApiService); + }); + + it('should create instance with custom options', () => { + const customFetch = jest.fn(); + const service = new ConfigRegistryApiService({ + env: SDK.Env.DEV, + fetch: customFetch, + retries: 5, + }); + expect(service).toBeInstanceOf(ConfigRegistryApiService); + }); + + it('should create instance with empty options object', () => { + const service = new ConfigRegistryApiService({}); + expect(service).toBeInstanceOf(ConfigRegistryApiService); + }); + + it('should use default values for unspecified options', () => { + const service = new ConfigRegistryApiService({ + env: SDK.Env.PRD, + }); + expect(service).toBeInstanceOf(ConfigRegistryApiService); + }); + + it('should use default fetch when not provided', () => { + const service = new ConfigRegistryApiService({ + env: SDK.Env.DEV, + }); + expect(service).toBeInstanceOf(ConfigRegistryApiService); + }); + }); + + describe('fetchConfig', () => { + beforeEach(() => { + cleanAll(); + }); + + afterEach(() => { + cleanAll(); + }); + + it('should successfully fetch config from API', async () => { + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE, { + ETag: '"test-etag-123"', + }); + + const service = new ConfigRegistryApiService(); + const result = await service.fetchConfig(); + + expect(result.modified).toBe(true); + expect(result.etag).toBe('"test-etag-123"'); + expect( + (result as Extract).data, + ).toStrictEqual(MOCK_API_RESPONSE); + expect(scope.isDone()).toBe(true); + }); + + it('should successfully fetch config from API without ETag header', async () => { + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService(); + const result = await service.fetchConfig(); + + expect(result.modified).toBe(true); + expect(result.etag).toBeUndefined(); + expect( + (result as Extract).data, + ).toStrictEqual(MOCK_API_RESPONSE); + expect(scope.isDone()).toBe(true); + }); + + it('should handle 304 Not Modified response', async () => { + const etag = '"test-etag-123"'; + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .matchHeader('If-None-Match', etag) + .reply(304); + + const service = new ConfigRegistryApiService(); + const result = await service.fetchConfig({ etag }); + + expect(result.modified).toBe(false); + expect(scope.isDone()).toBe(true); + }); + + it('should handle 304 Not Modified response without ETag header', async () => { + const mockHeaders = { + get: jest.fn().mockReturnValue(null), + }; + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 304, + headers: mockHeaders, + } as unknown as Response); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + const result = await service.fetchConfig(); + + expect(result.modified).toBe(false); + expect(result.etag).toBeUndefined(); + }); + + it('should include If-None-Match header when etag is provided', async () => { + const etag = '"test-etag-123"'; + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .matchHeader('If-None-Match', etag) + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService(); + await service.fetchConfig({ etag }); + + expect(scope.isDone()).toBe(true); + }); + + it('should not include If-None-Match header when etag is undefined', async () => { + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService(); + await service.fetchConfig({ etag: undefined }); + + expect(scope.isDone()).toBe(true); + }); + + it('should handle fetchConfig called with undefined options', async () => { + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await service.fetchConfig(undefined as any); + + expect(scope.isDone()).toBe(true); + }); + + it('should throw error on invalid response structure', async () => { + const invalidResponse = { invalid: 'data' }; + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, invalidResponse); + + const service = new ConfigRegistryApiService(); + + await expect(service.fetchConfig()).rejects.toThrow(expect.any(Error)); + expect(scope.isDone()).toBe(true); + }); + + it('should throw error when data is null', async () => { + const mockHeaders = { + get: jest.fn().mockReturnValue(null), + }; + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders, + json: async () => null, + } as unknown as Response); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toThrow(expect.any(Error)); + }); + + it('should throw error when data.data is null', async () => { + const mockHeaders = { + get: jest.fn().mockReturnValue(null), + }; + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders, + json: async () => ({ data: null }), + } as unknown as Response); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toThrow(expect.any(Error)); + }); + + it('should throw error when data.data.networks is not an array', async () => { + const mockHeaders = { + get: jest.fn().mockReturnValue(null), + }; + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders, + json: async () => ({ data: { networks: 'not-an-array' } }), + } as unknown as Response); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toThrow(expect.any(Error)); + }); + + it('should throw error on HTTP error status', async () => { + const mockHeaders = { + get: jest.fn().mockReturnValue(null), + }; + const customFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: mockHeaders, + } as unknown as Response); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toThrow( + 'Failed to fetch config: 500 Internal Server Error', + ); + }); + + it('should handle network errors', async () => { + const customFetch = jest + .fn() + .mockRejectedValue(new Error('Network connection failed')); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toThrow( + 'Network connection failed', + ); + }); + + it('should retry on failure', async () => { + nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .replyWithError('Network error'); + nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .replyWithError('Network error'); + const successScope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService({ + retries: 2, + }); + + const result = await service.fetchConfig(); + + expect(result.modified).toBe(true); + expect( + (result as Extract).data, + ).toStrictEqual(MOCK_API_RESPONSE); + expect(successScope.isDone()).toBe(true); + }); + }); + + describe('onBreak', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ now: Date.now() }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should register and call onBreak handler', async () => { + const maximumConsecutiveFailures = 3; + const retries = 0; + + for (let i = 0; i < maximumConsecutiveFailures; i++) { + nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .replyWithError('Network error'); + } + + const onBreakHandler = jest.fn(); + const service = new ConfigRegistryApiService({ + retries, + maximumConsecutiveFailures, + circuitBreakDuration: 10000, + }); + + service.onBreak(onBreakHandler); + + for (let i = 0; i < maximumConsecutiveFailures; i++) { + await expect(service.fetchConfig()).rejects.toThrow(expect.any(Error)); + await clock.tickAsync(100); + } + + const finalPromise = service.fetchConfig(); + finalPromise.catch(() => { + // Expected rejection + }); + await clock.tickAsync(100); + + await expect(finalPromise).rejects.toThrow(expect.any(Error)); + expect(onBreakHandler).toHaveBeenCalled(); + }); + + it('should return the result from policy.onBreak', () => { + const service = new ConfigRegistryApiService(); + const handler = jest.fn(); + const result = service.onBreak(handler); + expect(result).toBeDefined(); + }); + }); + + describe('onDegraded', () => { + it('should register onDegraded handler', () => { + const service = new ConfigRegistryApiService(); + const onDegradedHandler = jest.fn(); + + service.onDegraded(onDegradedHandler); + + expect(service.onDegraded).toBeDefined(); + }); + + it('should call onDegraded handler when service becomes degraded', async () => { + const degradedThreshold = 2000; // 2 seconds + const service = new ConfigRegistryApiService({ + degradedThreshold, + retries: 0, + }); + + const onDegradedHandler = jest.fn(); + service.onDegraded(onDegradedHandler); + + const slowFetch = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + ok: true, + status: 200, + headers: { + get: jest.fn().mockReturnValue('"custom-etag"'), + } as unknown as Headers, + json: async () => MOCK_API_RESPONSE, + } as Response), + degradedThreshold + 100, + ); + }), + ); + + const slowService = new ConfigRegistryApiService({ + fetch: slowFetch, + degradedThreshold, + retries: 0, + }); + + slowService.onDegraded(onDegradedHandler); + + await slowService.fetchConfig(); + + expect(slowService.onDegraded).toBeDefined(); + }); + + it('should return the result from policy.onDegraded', () => { + const service = new ConfigRegistryApiService(); + const handler = jest.fn(); + const result = service.onDegraded(handler); + expect(result).toBeDefined(); + }); + }); + + describe('custom fetch function', () => { + it('should use custom fetch function when provided', async () => { + const mockHeaders = { + get: jest.fn().mockReturnValue('"custom-etag"'), + }; + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders, + json: async () => MOCK_API_RESPONSE, + } as unknown as Response); + + const service = new ConfigRegistryApiService({ + fetch: customFetch, + }); + + const result = await service.fetchConfig(); + + expect(customFetch).toHaveBeenCalled(); + expect(result.modified).toBe(true); + expect( + (result as Extract).data, + ).toStrictEqual(MOCK_API_RESPONSE); + }); + }); + + describe('environment configuration', () => { + it('should use DEV environment URL when env is DEV', async () => { + const scope = nock('https://client-config.dev-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService({ + env: SDK.Env.DEV, + }); + + await service.fetchConfig(); + + expect(scope.isDone()).toBe(true); + }); + + it('should use UAT environment URL when env is UAT', async () => { + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService({ + env: SDK.Env.UAT, + }); + + await service.fetchConfig(); + + expect(scope.isDone()).toBe(true); + }); + + it('should use PRD environment URL when env is PRD', async () => { + const scope = nock('https://client-config.api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService({ + env: SDK.Env.PRD, + }); + + await service.fetchConfig(); + + expect(scope.isDone()).toBe(true); + }); + + it('should default to UAT environment', async () => { + const scope = nock('https://client-config.uat-api.cx.metamask.io') + .get('/v1/config/networks') + .reply(200, MOCK_API_RESPONSE); + + const service = new ConfigRegistryApiService(); + + await service.fetchConfig(); + + expect(scope.isDone()).toBe(true); + }); + }); +}); diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts new file mode 100644 index 00000000000..c3c5540bccf --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts @@ -0,0 +1,133 @@ +import { + createServicePolicy, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, +} from '@metamask/controller-utils'; +import type { ServicePolicy } from '@metamask/controller-utils'; +import { SDK } from '@metamask/profile-sync-controller'; + +import { validateRegistryConfigApiResponse } from './types'; +import type { FetchConfigOptions, FetchConfigResult } from './types'; + +const ENDPOINT_PATH = '/config/networks'; + +/** + * Returns the base URL for the config registry API for the given environment. + * + * @param env - The environment to get the URL for. + * @returns The base URL for the environment. + */ +export function getConfigRegistryUrl(env: SDK.Env): string { + const envPrefix = env === SDK.Env.PRD ? '' : `${env}-`; + return `https://client-config.${envPrefix}api.cx.metamask.io/v1${ENDPOINT_PATH}`; +} + +export type ConfigRegistryApiServiceOptions = { + env?: SDK.Env; + fetch?: typeof fetch; + degradedThreshold?: number; + retries?: number; + maximumConsecutiveFailures?: number; + circuitBreakDuration?: number; +}; + +export class ConfigRegistryApiService { + readonly #policy: ServicePolicy; + + readonly #url: string; + + readonly #fetch: typeof fetch; + + /** + * Construct a Config Registry API Service. + * + * @param options - The options for constructing the service. + * @param options.env - The environment to determine the correct API endpoints. Defaults to UAT. + * @param options.fetch - Custom fetch function for testing or custom implementations. Defaults to the global fetch. + * @param options.degradedThreshold - The length of time (in milliseconds) that governs when the service is regarded as degraded. Defaults to 5 seconds. + * @param options.retries - Number of retry attempts for each fetch request. Defaults to 3. + * @param options.maximumConsecutiveFailures - The maximum number of consecutive failures allowed before breaking the circuit. Defaults to 3. + * @param options.circuitBreakDuration - The amount of time to wait when the circuit breaks from too many consecutive failures. Defaults to 2 minutes. + */ + constructor({ + env = SDK.Env.UAT, + fetch: customFetch = globalThis.fetch, + degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, + retries = DEFAULT_MAX_RETRIES, + maximumConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, + }: ConfigRegistryApiServiceOptions = {}) { + this.#url = getConfigRegistryUrl(env); + this.#fetch = customFetch; + + this.#policy = createServicePolicy({ + maxRetries: retries, + maxConsecutiveFailures: maximumConsecutiveFailures, + circuitBreakDuration, + degradedThreshold, + }); + } + + onBreak( + ...args: Parameters + ): ReturnType { + return this.#policy.onBreak(...args); + } + + onDegraded( + ...args: Parameters + ): ReturnType { + return this.#policy.onDegraded(...args); + } + + async fetchConfig( + options: FetchConfigOptions = {}, + ): Promise { + const headers: HeadersInit = { + 'Cache-Control': 'no-cache', + }; + + if (options.etag) { + headers['If-None-Match'] = options.etag; + } + + const response = await this.#policy.execute(async () => { + const res = await this.#fetch(this.#url, { + headers, + }); + + if (res.status === 304) { + return res; + } + + if (!res.ok) { + throw new Error( + `Failed to fetch config: ${res.status} ${res.statusText}`, + ); + } + + return res; + }); + + if (response.status === 304) { + const etag = response.headers.get('ETag') ?? undefined; + return { + modified: false, + etag, + }; + } + + const etag = response.headers.get('ETag') ?? undefined; + const jsonData = await response.json(); + + validateRegistryConfigApiResponse(jsonData); + + return { + data: jsonData, + etag, + modified: true, + }; + } +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/index.ts b/packages/config-registry-controller/src/config-registry-api-service/index.ts new file mode 100644 index 00000000000..01a3a63ec7e --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/index.ts @@ -0,0 +1,16 @@ +export type { + FetchConfigOptions, + FetchConfigResult, + RegistryNetworkConfig, + RegistryConfigApiResponse, +} from './types'; + +export { + ConfigRegistryApiService, + getConfigRegistryUrl, +} from './config-registry-api-service'; + +export type { ConfigRegistryApiServiceOptions } from './config-registry-api-service'; + +export type { NetworkFilterOptions } from './transformers'; +export { filterNetworks } from './transformers'; diff --git a/packages/config-registry-controller/src/config-registry-api-service/transformers.test.ts b/packages/config-registry-controller/src/config-registry-api-service/transformers.test.ts new file mode 100644 index 00000000000..e19dbb96943 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/transformers.test.ts @@ -0,0 +1,117 @@ +import { filterNetworks } from './transformers'; +import type { RegistryNetworkConfig } from './types'; + +const VALID_NETWORK_CONFIG: RegistryNetworkConfig = { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + failoverUrls: ['https://backup.infura.io/v3/{infuraProjectId}'], + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + isActive: true, + isTestnet: false, + isDefault: true, + isFeatured: true, + isDeprecated: false, + priority: 0, + isDeletable: false, +}; + +describe('transformers', () => { + describe('filterNetworks', () => { + const networks: RegistryNetworkConfig[] = [ + { + ...VALID_NETWORK_CONFIG, + isFeatured: true, + isTestnet: false, + isActive: true, + isDeprecated: false, + isDefault: true, + }, + { + ...VALID_NETWORK_CONFIG, + chainId: '0x5', + isFeatured: false, + isTestnet: true, + isActive: true, + isDeprecated: false, + isDefault: false, + }, + { + ...VALID_NETWORK_CONFIG, + chainId: '0x2a', + isFeatured: true, + isTestnet: false, + isActive: false, + isDeprecated: true, + isDefault: false, + }, + ]; + + it('should return all networks when no filters applied', () => { + const result = filterNetworks(networks); + + expect(result).toHaveLength(3); + }); + + it('should filter by isFeatured', () => { + const result = filterNetworks(networks, { isFeatured: true }); + + expect(result).toHaveLength(2); + expect(result.every((network) => network.isFeatured)).toBe(true); + }); + + it('should filter by isTestnet', () => { + const result = filterNetworks(networks, { isTestnet: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('0x5'); + }); + + it('should filter by isActive', () => { + const result = filterNetworks(networks, { isActive: true }); + + expect(result).toHaveLength(2); + expect(result.every((network) => network.isActive)).toBe(true); + }); + + it('should filter by isDeprecated', () => { + const result = filterNetworks(networks, { isDeprecated: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('0x2a'); + }); + + it('should filter by isDefault', () => { + const result = filterNetworks(networks, { isDefault: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('0x1'); + }); + + it('should filter by multiple criteria', () => { + const result = filterNetworks(networks, { + isFeatured: true, + isActive: true, + isTestnet: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('0x1'); + }); + + it('should return empty array for empty input', () => { + const result = filterNetworks([]); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/config-registry-controller/src/config-registry-api-service/transformers.ts b/packages/config-registry-controller/src/config-registry-api-service/transformers.ts new file mode 100644 index 00000000000..150e10b51eb --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/transformers.ts @@ -0,0 +1,53 @@ +import type { RegistryNetworkConfig } from './types'; + +export type NetworkFilterOptions = { + isFeatured?: boolean; + isTestnet?: boolean; + isActive?: boolean; + isDeprecated?: boolean; + isDefault?: boolean; +}; + +/** + * @param networks - Array of network configurations to filter. + * @param options - Filter options. + * @returns Filtered array of network configurations. + */ +export function filterNetworks( + networks: RegistryNetworkConfig[], + options: NetworkFilterOptions = {}, +): RegistryNetworkConfig[] { + return networks.filter((network) => { + if (options.isFeatured !== undefined) { + if (network.isFeatured !== options.isFeatured) { + return false; + } + } + + if (options.isTestnet !== undefined) { + if (network.isTestnet !== options.isTestnet) { + return false; + } + } + + if (options.isActive !== undefined) { + if (network.isActive !== options.isActive) { + return false; + } + } + + if (options.isDeprecated !== undefined) { + if (network.isDeprecated !== options.isDeprecated) { + return false; + } + } + + if (options.isDefault !== undefined) { + if (network.isDefault !== options.isDefault) { + return false; + } + } + + return true; + }); +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/types.ts b/packages/config-registry-controller/src/config-registry-api-service/types.ts new file mode 100644 index 00000000000..fad9ae93af9 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/types.ts @@ -0,0 +1,98 @@ +import { + array, + assert, + boolean, + number, + optional, + string, + type, +} from '@metamask/superstruct'; + +const RpcEndpointSchema = type({ + url: string(), + type: string(), + networkClientId: string(), + failoverUrls: array(string()), +}); + +export const RegistryNetworkConfigSchema = type({ + chainId: string(), + name: string(), + nativeCurrency: string(), + rpcEndpoints: array(RpcEndpointSchema), + blockExplorerUrls: array(string()), + defaultRpcEndpointIndex: number(), + defaultBlockExplorerUrlIndex: number(), + lastUpdatedAt: optional(number()), + networkImageUrl: optional(string()), + nativeTokenImageUrl: optional(string()), + isActive: boolean(), + isTestnet: boolean(), + isDefault: boolean(), + isFeatured: boolean(), + isDeprecated: boolean(), + priority: number(), + isDeletable: boolean(), +}); + +export const RegistryConfigApiResponseSchema = type({ + data: type({ + version: string(), + timestamp: number(), + networks: array(RegistryNetworkConfigSchema), + }), +}); + +export type RegistryNetworkConfig = { + chainId: string; + name: string; + nativeCurrency: string; + rpcEndpoints: { + url: string; + type: string; + networkClientId: string; + failoverUrls: string[]; + }[]; + blockExplorerUrls: string[]; + defaultRpcEndpointIndex: number; + defaultBlockExplorerUrlIndex: number; + lastUpdatedAt?: number; + networkImageUrl?: string; + nativeTokenImageUrl?: string; + isActive: boolean; + isTestnet: boolean; + isDefault: boolean; + isFeatured: boolean; + isDeprecated: boolean; + priority: number; + isDeletable: boolean; +}; + +export type RegistryConfigApiResponse = { + data: { + version: string; + timestamp: number; + networks: RegistryNetworkConfig[]; + }; +}; + +export function validateRegistryConfigApiResponse( + data: unknown, +): asserts data is RegistryConfigApiResponse { + assert(data, RegistryConfigApiResponseSchema); +} + +export type FetchConfigOptions = { + etag?: string; +}; + +export type FetchConfigResult = + | { + modified: false; + etag?: string; + } + | { + modified: true; + data: RegistryConfigApiResponse; + etag?: string; + }; diff --git a/packages/config-registry-controller/src/index.ts b/packages/config-registry-controller/src/index.ts new file mode 100644 index 00000000000..0e9bad06f22 --- /dev/null +++ b/packages/config-registry-controller/src/index.ts @@ -0,0 +1,30 @@ +export type { + ConfigRegistryState, + ConfigRegistryControllerOptions, + ConfigRegistryControllerActions, + ConfigRegistryControllerGetStateAction, + ConfigRegistryControllerStartPollingAction, + ConfigRegistryControllerStopPollingAction, + ConfigRegistryControllerEvents, + ConfigRegistryControllerStateChangeEvent, + ConfigRegistryMessenger, + NetworkConfigEntry, +} from './ConfigRegistryController'; +export { + ConfigRegistryController, + DEFAULT_POLLING_INTERVAL, +} from './ConfigRegistryController'; +export type { + FetchConfigOptions, + FetchConfigResult, + RegistryNetworkConfig, + RegistryConfigApiResponse, + ConfigRegistryApiServiceOptions, + NetworkFilterOptions, +} from './config-registry-api-service'; +export { + ConfigRegistryApiService, + getConfigRegistryUrl, + filterNetworks, +} from './config-registry-api-service'; +export { isConfigRegistryApiEnabled } from './utils/feature-flags'; diff --git a/packages/config-registry-controller/src/utils/feature-flags.ts b/packages/config-registry-controller/src/utils/feature-flags.ts new file mode 100644 index 00000000000..c20f90e2eeb --- /dev/null +++ b/packages/config-registry-controller/src/utils/feature-flags.ts @@ -0,0 +1,29 @@ +import type { ConfigRegistryMessenger } from '../ConfigRegistryController'; + +const FEATURE_FLAG_KEY = 'configRegistryApiEnabled'; +const DEFAULT_FEATURE_FLAG_VALUE = false; + +/** + * Checks if the config registry API feature flag is enabled. + * + * @param messenger - The controller messenger. + * @returns True if the feature flag is enabled, false otherwise. + */ +export function isConfigRegistryApiEnabled( + messenger: ConfigRegistryMessenger, +): boolean { + try { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = state.remoteFeatureFlags; + + const flagValue = featureFlags[FEATURE_FLAG_KEY]; + + if (typeof flagValue === 'boolean') { + return flagValue; + } + + return DEFAULT_FEATURE_FLAG_VALUE; + } catch { + return DEFAULT_FEATURE_FLAG_VALUE; + } +} diff --git a/packages/config-registry-controller/tsconfig.build.json b/packages/config-registry-controller/tsconfig.build.json new file mode 100644 index 00000000000..28e608feb56 --- /dev/null +++ b/packages/config-registry-controller/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/config-registry-controller/tsconfig.json b/packages/config-registry-controller/tsconfig.json new file mode 100644 index 00000000000..f6522b88f9c --- /dev/null +++ b/packages/config-registry-controller/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" }, + { "path": "../polling-controller" }, + { "path": "../profile-sync-controller" }, + { "path": "../remote-feature-flag-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/config-registry-controller/typedoc.json b/packages/config-registry-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/config-registry-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 c3010cd8223..ee30fd15717 100644 --- a/teams.json +++ b/teams.json @@ -66,5 +66,6 @@ "metamask/permission-log-controller": "team-wallet-integrations,team-core-platform", "metamask/analytics-controller": "team-extension-platform,team-mobile-platform", "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", - "metamask/storage-service": "team-extension-platform,team-mobile-platform" + "metamask/storage-service": "team-extension-platform,team-mobile-platform", + "metamask/config-registry-controller": "team-core-platform" } diff --git a/tests/fake-provider.ts b/tests/fake-provider.ts index 096bed2e40c..a767ffb1c46 100644 --- a/tests/fake-provider.ts +++ b/tests/fake-provider.ts @@ -5,7 +5,6 @@ import type { MiddlewareContext, ResultConstraint, } from '@metamask/json-rpc-engine/v2'; -import type { Provider } from '@metamask/network-controller'; import type { Json, JsonRpcId, @@ -16,6 +15,12 @@ import type { } from '@metamask/utils'; import { inspect, isDeepStrictEqual } from 'util'; +type Provider = InternalProvider< + MiddlewareContext< + { origin: string; skipCache: boolean } & Record + > +>; + // Store this in case it gets stubbed later const originalSetTimeout = global.setTimeout; @@ -221,27 +226,30 @@ export class FakeProvider } throw new Error(message); - } else { - const stub = this.#stubs[index]; + } - if (stub.discardAfterMatching !== false) { - this.#stubs.splice(index, 1); - } + const stub = this.#stubs[index]; + if (stub === undefined) { + throw new Error('Stub not found at index'); + } - if (stub.delay) { - originalSetTimeout(() => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#handleRequest(stub, callback); - }, stub.delay); - } else { + if (stub.discardAfterMatching !== false) { + this.#stubs.splice(index, 1); + } + + if (stub.delay) { + originalSetTimeout(() => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.#handleRequest(stub, callback); - } - - this.calledStubs.push({ ...stub }); + }, stub.delay); + } else { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#handleRequest(stub, callback); } + + this.calledStubs.push(stub); } async #handleRequest( diff --git a/tsconfig.build.json b/tsconfig.build.json index 184a3a378fc..0007719e7db 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -55,6 +55,9 @@ { "path": "./packages/composable-controller/tsconfig.build.json" }, + { + "path": "./packages/config-registry-controller/tsconfig.build.json" + }, { "path": "./packages/connectivity-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 934cd9a381b..cd3599de835 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -56,6 +56,9 @@ { "path": "./packages/composable-controller" }, + { + "path": "./packages/config-registry-controller" + }, { "path": "./packages/connectivity-controller" }, diff --git a/yarn.lock b/yarn.lock index 40a76951d28..01cbed0ea40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2972,6 +2972,33 @@ __metadata: languageName: unknown linkType: soft +"@metamask/config-registry-controller@workspace:packages/config-registry-controller": + version: 0.0.0-use.local + resolution: "@metamask/config-registry-controller@workspace:packages/config-registry-controller" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.0.4" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + nock: "npm:^13.3.1" + sinon: "npm:^9.2.4" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/connectivity-controller@npm:^0.1.0, @metamask/connectivity-controller@workspace:packages/connectivity-controller": version: 0.0.0-use.local resolution: "@metamask/connectivity-controller@workspace:packages/connectivity-controller" @@ -3073,7 +3100,6 @@ __metadata: "@metamask/eth-block-tracker": "npm:^15.0.1" "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/json-rpc-engine": "npm:^10.2.1" - "@metamask/network-controller": "npm:^29.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1"