diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e27e4c836ac..bd11042a420 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -77,6 +77,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..6c1e52eb80d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ examples/*/docs packages/*/coverage packages/*/dist packages/*/docs -!packages/core-backend/docs scripts/coverage # yarn v3 (w/o zero-install) @@ -35,9 +34,4 @@ scripts/coverage !.yarn/versions # typescript -packages/*/*.tsbuildinfo - -# Emacs -\#*\# -.#* -.~ +packages/*/*.tsbuildinfo \ No newline at end of file diff --git a/README.md b/README.md index 3987bb0dbe6..1334e596770 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/client-controller`](packages/client-controller) - [`@metamask/compliance-controller`](packages/compliance-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) @@ -122,6 +123,7 @@ linkStyle default opacity:0.5 client_controller(["@metamask/client-controller"]); compliance_controller(["@metamask/compliance-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"]); @@ -218,6 +220,7 @@ linkStyle default opacity:0.5 assets_controllers --> approval_controller; assets_controllers --> base_controller; assets_controllers --> controller_utils; + assets_controllers --> core_backend; assets_controllers --> keyring_controller; assets_controllers --> messenger; assets_controllers --> multichain_account_service; @@ -241,6 +244,7 @@ linkStyle default opacity:0.5 bridge_controller --> multichain_network_controller; bridge_controller --> network_controller; bridge_controller --> polling_controller; + bridge_controller --> profile_sync_controller; bridge_controller --> remote_feature_flag_controller; bridge_controller --> transaction_controller; bridge_controller --> eth_json_rpc_provider; @@ -251,6 +255,7 @@ linkStyle default opacity:0.5 bridge_status_controller --> gas_fee_controller; bridge_status_controller --> network_controller; bridge_status_controller --> polling_controller; + bridge_status_controller --> profile_sync_controller; bridge_status_controller --> transaction_controller; chain_agnostic_permission --> controller_utils; chain_agnostic_permission --> permission_controller; @@ -267,6 +272,13 @@ linkStyle default opacity:0.5 composable_controller --> base_controller; composable_controller --> messenger; composable_controller --> json_rpc_engine; + config_registry_controller --> base_controller; + config_registry_controller --> controller_utils; + config_registry_controller --> keyring_controller; + config_registry_controller --> messenger; + config_registry_controller --> polling_controller; + config_registry_controller --> profile_sync_controller; + config_registry_controller --> remote_feature_flag_controller; connectivity_controller --> base_controller; connectivity_controller --> messenger; core_backend --> accounts_controller; @@ -444,6 +456,7 @@ linkStyle default opacity:0.5 transaction_controller --> approval_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; + transaction_controller --> core_backend; transaction_controller --> gas_fee_controller; transaction_controller --> messenger; transaction_controller --> network_controller; diff --git a/package.json b/package.json index 36a961d6c81..fc15aba21a6 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,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..a006400c833 --- /dev/null +++ b/packages/config-registry-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# 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 ([#7668](https://github.com/MetaMask/core/pull/7668), [#7809](https://github.com/MetaMask/core/pull/7809)) + +[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..c8a0ff6be3a --- /dev/null +++ b/packages/config-registry-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/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..ca7e500e761 --- /dev/null +++ b/packages/config-registry-controller/package.json @@ -0,0 +1,82 @@ +{ + "name": "@metamask/config-registry-controller", + "version": "0.0.0", + "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.19.0", + "@metamask/keyring-controller": "^25.1.0", + "@metamask/messenger": "^0.3.0", + "@metamask/polling-controller": "^16.0.3", + "@metamask/profile-sync-controller": "^27.1.0", + "@metamask/remote-feature-flag-controller": "^4.1.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "nock": "^13.3.1", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", + "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..3f1de1f2a51 --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController.test.ts @@ -0,0 +1,1618 @@ +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; + +import type { RegistryNetworkConfig } from './config-registry-api-service/types'; +import type { FetchConfigResult } from './config-registry-api-service/types'; +import type { ConfigRegistryControllerMessenger } from './ConfigRegistryController'; +import { + ConfigRegistryController, + DEFAULT_POLLING_INTERVAL, +} from './ConfigRegistryController'; +import { selectFeaturedNetworks, selectNetworks } from './selectors'; +import { createMockNetworkConfig } from '../tests/helpers'; + +const namespace = 'ConfigRegistryController' as const; + +type AllActions = MessengerActions; + +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +/** + * Constructs a messenger for ConfigRegistryController. + * + * @returns A controller messenger and root messenger. + */ +function getConfigRegistryControllerMessenger(): { + messenger: ConfigRegistryControllerMessenger; + rootMessenger: RootMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + + const configRegistryControllerMessenger: ConfigRegistryControllerMessenger = + new Messenger< + typeof namespace, + AllActions, + AllEvents, + typeof rootMessenger + >({ + namespace, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: configRegistryControllerMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'ConfigRegistryApiService:fetchConfig', + ], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + return { messenger: configRegistryControllerMessenger, rootMessenger }; +} + +const MOCK_FALLBACK_CONFIG: Record = { + 'fallback-key': createMockNetworkConfig({ + chainId: 'eip155:2', + 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(), + chains: [], + }, + }, + modified: true, + }; + }; + + return overrides?.fetchConfig ?? defaultFetchConfig; +} + +type WithControllerCallback = (args: { + controller: ConfigRegistryController; + rootMessenger: RootMessenger; + messenger: ConfigRegistryControllerMessenger; + mockApiServiceHandler: jest.Mock; + mockRemoteFeatureFlagGetState: 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]]; + + jest.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 controller = new ConfigRegistryController({ + messenger, + ...options, + }); + + try { + return await testFunction({ + controller, + rootMessenger, + messenger, + mockApiServiceHandler, + mockRemoteFeatureFlagGetState, + }); + } finally { + controller.stopAllPolling(); + jest.useRealTimers(); + mockApiServiceHandler.mockReset(); + } +} + +describe('ConfigRegistryController', () => { + describe('constructor', () => { + it('sets default state', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + configs: { networks: {} }, + version: null, + lastFetched: null, + etag: null, + }); + }); + }); + + it('sets initial state when provided', async () => { + const initialNetworks: Record = { + 'test-key': createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Test Network', + }), + }; + const initialState = { + configs: { networks: initialNetworks }, + version: 'v1.0.0', + lastFetched: 1234567890, + }; + + await withController( + { options: { state: initialState } }, + ({ controller }) => { + expect(controller.state.configs.networks).toStrictEqual( + initialNetworks, + ); + expect(controller.state.version).toBe('v1.0.0'); + expect(controller.state.lastFetched).toBe(1234567890); + }, + ); + }); + + it('sets custom polling interval', async () => { + const customInterval = 5000; + await withController( + { options: { pollingInterval: customInterval } }, + ({ controller }) => { + expect(controller.getIntervalLength()).toBe(customInterval); + }, + ); + }); + + it('sets fallback config', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + ({ controller }) => { + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + }); + + describe('polling', () => { + it('hits the config registry API when polling is started', async () => { + await withController(async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + + it('polls at specified interval', async () => { + const pollingInterval = 1000; + await withController( + { options: { pollingInterval } }, + async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + mockApiServiceHandler.mockClear(); + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not hit the config registry API periodically when polling is stopped', async () => { + await withController(async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + mockApiServiceHandler.mockClear(); + controller.stopAllPolling(); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + + it('uses fallback config when no configs exist', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('keeps existing configs when fetch fails and configs already exist', async () => { + const existingNetworks: Record = { + 'existing-key': createMockNetworkConfig({ + chainId: 'eip155:3', + name: 'Existing Network', + }), + }; + const existingConfigs = { networks: existingNetworks }; + + await withController( + { + options: { + state: { configs: existingConfigs }, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs.networks).toStrictEqual( + existingNetworks, + ); + }, + ); + }); + + it('handles errors during polling', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValueOnce({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + expect(mockRemoteFeatureFlagGetState).toHaveBeenCalled(); + }, + ); + }); + + it('handles unmodified response and updates lastFetched and etag', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + etag: '"test-etag"', + }); + + const beforeTimestamp = Date.now(); + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + const afterTimestamp = Date.now(); + + 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('handles unmodified response and preserves existing etag when not provided', async () => { + await withController( + { + options: { + state: { + etag: '"existing-etag"', + }, + }, + }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + }); + + const beforeTimestamp = Date.now(); + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + const afterTimestamp = Date.now(); + + 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('handles unmodified response and sets etag to null when explicitly null', async () => { + await withController( + { + options: { + state: { + 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(); + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + const afterTimestamp = Date.now(); + + expect(controller.state.etag).toBeNull(); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('handles validation error from service', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error from superstruct', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error from superstruct', + }), + ); + }, + ); + }); + + it('handles validation error when result.data is missing', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data is missing', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error: data is missing', + }), + ); + }, + ); + }); + + it('handles validation error when result.data.chains is not an array', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.chains is not an array', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error: data.chains is not an array', + }), + ); + }, + ); + }); + + it('handles validation error when result.data.version is not a string', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.version is not a string', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error: data.version is not a string', + }), + ); + }, + ); + }); + + it('proceeds 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: { + version: '1.0.0', + timestamp: Date.now(), + chains: [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ], + }, + }, + etag: '"test-etag"', + modified: true, + }); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.lastFetched).not.toBeNull(); + }, + ); + }); + + it('proceeds 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: { + version: '1.0.0', + timestamp: Date.now(), + chains: [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ], + }, + }, + etag: '"test-etag"', + modified: true, + }); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.lastFetched).not.toBe(oldTimestamp); + + jest.restoreAllMocks(); + }, + ); + }); + + it('handles non-Error exceptions', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue('String error'); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'String error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('handles 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 ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('works via messenger actions', async () => { + await withController(async ({ messenger, mockApiServiceHandler }) => { + const token = messenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + + messenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('state persistence', () => { + it('persists version', async () => { + await withController( + { options: { state: { version: 'v1.0.0' } } }, + ({ controller }) => { + expect(controller.state.version).toBe('v1.0.0'); + }, + ); + }); + + it('persists lastFetched', async () => { + const timestamp = Date.now(); + await withController( + { options: { state: { lastFetched: timestamp } } }, + ({ controller }) => { + expect(controller.state.lastFetched).toBe(timestamp); + }, + ); + }); + }); + + describe('startPolling', () => { + it('returns a polling token string', async () => { + await withController(({ controller }) => { + const token = controller.startPolling(null); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + }); + + it('returns 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); + }); + }); + + it('proceeds immediately when lastFetched is null', async () => { + await withController( + { + options: { + state: { + lastFetched: null, + }, + }, + }, + async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('proceeds 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, mockApiServiceHandler }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(1); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('proceeds 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, mockApiServiceHandler }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(1); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('clears 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, mockApiServiceHandler }) => { + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + const callsAfterFirst = mockApiServiceHandler.mock.calls.length; + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(callsAfterFirst); + }, + ); + }); + }); + + describe('feature flag', () => { + it('uses fallback config when feature flag is disabled', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: false, + }, + cacheTimestamp: Date.now(), + }); + + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('uses API when feature flag is enabled', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ]; + + const fetchConfigSpy = jest.fn().mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + mockApiServiceHandler.mockImplementation(fetchConfigSpy); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(fetchConfigSpy).toHaveBeenCalled(); + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.version).toBe('1.0.0'); + }, + ); + }); + + it('stores all networks in state; selectFeaturedNetworks filters for default list', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + config: { isTestnet: false, isFeatured: true, isActive: true }, + }), + createMockNetworkConfig({ + chainId: 'eip155:5', + name: 'Goerli', + config: { isTestnet: true, isFeatured: true, isActive: true }, + }), + createMockNetworkConfig({ + chainId: 'eip155:10', + name: 'Optimism', + config: { isTestnet: false, isFeatured: false, isActive: true }, + }), + createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + config: { isTestnet: false, isFeatured: true, isActive: false }, + }), + ]; + + const fetchConfigSpy = jest.fn().mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + mockApiServiceHandler.mockImplementation(fetchConfigSpy); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + // All networks stored in state + const allNetworks = selectNetworks(controller.state); + expect(allNetworks['eip155:1']).toBeDefined(); + expect(allNetworks['eip155:5']).toBeDefined(); + expect(allNetworks['eip155:10']).toBeDefined(); + expect(allNetworks['eip155:137']).toBeDefined(); + expect(Object.keys(allNetworks)).toHaveLength(4); + + // selectFeaturedNetworks returns only featured, active, non-testnet + const featuredNetworks = selectFeaturedNetworks(controller.state); + expect(featuredNetworks['eip155:1']).toBeDefined(); + expect(featuredNetworks['eip155:5']).toBeUndefined(); + expect(featuredNetworks['eip155:10']).toBeUndefined(); + expect(featuredNetworks['eip155:137']).toBeUndefined(); + expect(Object.keys(featuredNetworks)).toHaveLength(1); + }, + ); + }); + + it('handles duplicate chainIds by keeping highest priority network and logging warning', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + // Mock API response with duplicate chainIds (last occurrence wins) + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (Low Priority)', + config: { priority: 10 }, + rpcProviders: { + default: { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + }, + fallbacks: [], + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (High Priority)', + config: { priority: 0 }, + rpcProviders: { + default: { + url: 'https://mainnet.alchemy.io/v2/{alchemyApiKey}', + type: 'alchemy', + networkClientId: 'mainnet-alchemy', + }, + fallbacks: [], + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + }), + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + // Last occurrence overwrites (no grouping/priority) + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.configs.networks['eip155:1']?.name).toBe( + 'Ethereum Mainnet (High Priority)', + ); + expect( + controller.state.configs.networks['eip155:1']?.rpcProviders.default + .type, + ).toBe('alchemy'); + + expect(controller.state.configs.networks['eip155:137']).toBeDefined(); + }, + ); + }); + + it('handles duplicate chainIds by keeping last occurrence', async () => { + await withController( + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + // Mock API response with duplicate chainIds having same priority + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (First)', + config: { priority: 5 }, + }), + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (Second)', + config: { priority: 5 }, + rpcProviders: { + default: { + url: 'https://mainnet.alchemy.io/v2/{alchemyApiKey}', + type: 'alchemy', + networkClientId: 'mainnet-alchemy', + }, + fallbacks: [], + }, + }), + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + // Last occurrence overwrites + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.configs.networks['eip155:1']?.name).toBe( + 'Ethereum Mainnet (Second)', + ); + }, + ); + }); + + it('fetches when feature flag is enabled (default mock)', async () => { + await withController( + {}, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ]; + + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(mockRemoteFeatureFlagGetState).toHaveBeenCalled(); + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.version).toBe('1.0.0'); + }, + ); + }); + + it('skips fetch when feature flag is disabled', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }); + + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + + expect(mockRemoteFeatureFlagGetState).toHaveBeenCalled(); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('defaults to fallback when feature flag is not set', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }); + + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('defaults to fallback when RemoteFeatureFlagController is unavailable', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockImplementation(() => { + throw new Error('RemoteFeatureFlagController not available'); + }); + + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + }); + + describe('KeyringController event listeners', () => { + it('starts polling when KeyringController:unlock event is published', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.publish('KeyringController:unlock'); + + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + + it('stops polling when KeyringController:lock event is published', async () => { + await withController( + async ({ controller, rootMessenger, mockApiServiceHandler }) => { + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + const callsAfterUnlock = mockApiServiceHandler.mock.calls.length; + + rootMessenger.publish('KeyringController:lock'); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(callsAfterUnlock); + }, + ); + }); + + it('calls startPolling with default parameter when called without arguments', async () => { + await withController(async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('RemoteFeatureFlagController:stateChange', () => { + it('starts polling when flag becomes enabled and keyring is unlocked', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.publish('KeyringController:unlock'); + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }, + [], + ); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not start polling when keyring is locked and flag becomes enabled', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }, + [], + ); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }, + ); + }); + + it('stops polling when flag becomes disabled', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + const callsAfterStart = mockApiServiceHandler.mock.calls.length; + + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }, + [], + ); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(callsAfterStart); + }, + ); + }); + }); + + describe('stopAllPolling', () => { + it('clears pending delayed poll timeout when stopping', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + const callsAfterFirstAdvance = + mockApiServiceHandler.mock.calls.length; + + controller.stopAllPolling(); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes( + callsAfterFirstAdvance, + ); + }, + ); + }); + + it('handles clearing timeout when no timeout exists', async () => { + await withController(({ controller }) => { + // Should not throw when stopping without a pending timeout + expect(() => controller.stopAllPolling()).not.toThrow(); + }); + }); + + it('stops all polling when called without token (backward compatible)', async () => { + await withController(async ({ controller, mockApiServiceHandler }) => { + controller.startPolling(null); + controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + controller.stopAllPolling(); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + + it('works via messenger action with token', async () => { + await withController(async ({ messenger, mockApiServiceHandler }) => { + const token = messenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + messenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + + it('works via messenger action without token (backward compatible)', async () => { + await withController(async ({ messenger, mockApiServiceHandler }) => { + const token = messenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + messenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + }); + + describe('stopPollingByPollingToken', () => { + it('stops delayed poll using placeholder token', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, mockApiServiceHandler }) => { + const token = controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + const callsAfterFirstAdvance = + mockApiServiceHandler.mock.calls.length; + + controller.stopPollingByPollingToken(token); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes( + callsAfterFirstAdvance, + ); + }, + ); + }); + + it('stops delayed poll using placeholder token after timeout fires', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, mockApiServiceHandler }) => { + const token = controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(2); + mockApiServiceHandler.mockClear(); + + controller.stopPollingByPollingToken(token); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }, + ); + }); + + it('stops specific polling session when called with token', async () => { + await withController(async ({ controller, mockApiServiceHandler }) => { + const tokenA = controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + const tokenB = controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + mockApiServiceHandler.mockClear(); + + controller.stopPollingByPollingToken(tokenA); + controller.stopPollingByPollingToken(tokenB); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).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..f6638470e01 --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController.ts @@ -0,0 +1,319 @@ +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 { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { RemoteFeatureFlagControllerStateChangeEvent } from '@metamask/remote-feature-flag-controller'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import type { ConfigRegistryApiServiceFetchConfigAction } from './config-registry-api-service/config-registry-api-service-method-action-types'; +import type { RegistryNetworkConfig } from './config-registry-api-service/types'; +import { isConfigRegistryApiEnabled } from './utils/feature-flags'; + +const controllerName = 'ConfigRegistryController'; + +export const DEFAULT_POLLING_INTERVAL = inMilliseconds(1, Duration.Day); + +/** + * State for the ConfigRegistryController. + * + * Tracks network configurations fetched from the config registry API, + * along with metadata about the fetch status and caching. + */ +export type ConfigRegistryControllerState = { + /** + * Network configurations organized by chain ID. + * Stores the full API response including isFeatured, isTestnet, etc. + * Use selectors (e.g. selectFeaturedNetworks) to filter when needed. + */ + 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; + /** + * 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, + }, + etag: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +/** + * Default fallback configuration when no configs are available. + */ +const DEFAULT_FALLBACK_CONFIG: Record = {}; + +/** + * Published when the state of {@link ConfigRegistryController} changes. + */ +export type ConfigRegistryControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + ConfigRegistryControllerState + >; + +/** + * Retrieves the state of the {@link ConfigRegistryController}. + */ +export type ConfigRegistryControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ConfigRegistryControllerState +>; + +/** + * Starts polling the config registry API. Returns a polling token that can be + * used to stop this polling session. + */ +export type ConfigRegistryControllerStartPollingAction = { + type: `${typeof controllerName}:startPolling`; + handler: (input: null) => string; +}; + +/** + * Stops all config registry polling. + */ +export type ConfigRegistryControllerStopPollingAction = { + type: `${typeof controllerName}:stopPolling`; + handler: () => void; +}; + +/** + * Actions that {@link ConfigRegistryControllerMessenger} exposes to other consumers. + */ +export type ConfigRegistryControllerActions = + | ConfigRegistryControllerGetStateAction + | ConfigRegistryControllerStartPollingAction + | ConfigRegistryControllerStopPollingAction; + +/** + * Actions from other messengers that {@link ConfigRegistryControllerMessenger} + * calls. + */ +type AllowedActions = + | RemoteFeatureFlagControllerGetStateAction + | ConfigRegistryApiServiceFetchConfigAction; + +/** + * Events that {@link ConfigRegistryControllerMessenger} exposes to other consumers. + */ +export type ConfigRegistryControllerEvents = + ConfigRegistryControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ConfigRegistryControllerMessenger} + * subscribes to. + */ +type AllowedEvents = + | KeyringControllerUnlockEvent + | KeyringControllerLockEvent + | RemoteFeatureFlagControllerStateChangeEvent; + +/** + * The messenger restricted to actions and events accessed by + * {@link ConfigRegistryController}. + */ +export type ConfigRegistryControllerMessenger = Messenger< + typeof controllerName, + ConfigRegistryControllerActions | AllowedActions, + ConfigRegistryControllerEvents | AllowedEvents +>; + +export type ConfigRegistryControllerOptions = { + messenger: ConfigRegistryControllerMessenger; + state?: Partial; + pollingInterval?: number; + fallbackConfig?: Record; +}; + +export class ConfigRegistryController extends StaticIntervalPollingController()< + typeof controllerName, + ConfigRegistryControllerState, + ConfigRegistryControllerMessenger +> { + #keyringLocked = true; + + /** + * @param options - The controller options. + * @param options.messenger - The controller messenger. Must have + * `ConfigRegistryApiService:fetchConfig` action handler registered + * (e.g. by instantiating {@link ConfigRegistryApiService} with the same + * messenger). + * @param options.state - Initial state. + * @param options.pollingInterval - Polling interval in milliseconds. + * @param options.fallbackConfig - Fallback configuration. + */ + constructor({ + messenger, + state = {}, + pollingInterval = DEFAULT_POLLING_INTERVAL, + fallbackConfig = DEFAULT_FALLBACK_CONFIG, + }: ConfigRegistryControllerOptions) { + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { + configs: { + networks: state.configs?.networks ?? { ...fallbackConfig }, + }, + version: state.version ?? null, + lastFetched: state.lastFetched ?? null, + etag: state.etag ?? null, + }, + }); + + this.setIntervalLength(pollingInterval); + + this.messenger.registerActionHandler( + `${controllerName}:startPolling`, + this.startPolling.bind(this), + ); + + this.messenger.registerActionHandler( + `${controllerName}:stopPolling`, + this.stopAllPolling.bind(this), + ); + + this.messenger.subscribe('KeyringController:unlock', () => { + this.#keyringLocked = false; + try { + if ( + isConfigRegistryApiEnabled( + this.messenger.call('RemoteFeatureFlagController:getState'), + ) + ) { + this.startPolling(null); + } + } catch { + // RemoteFeatureFlagController unavailable; do not start polling. + } + }); + + this.messenger.subscribe('KeyringController:lock', () => { + this.#keyringLocked = true; + this.stopAllPolling(); + }); + + this.messenger.subscribe('RemoteFeatureFlagController:stateChange', () => { + let enabled = false; + try { + enabled = isConfigRegistryApiEnabled( + this.messenger.call('RemoteFeatureFlagController:getState'), + ); + } catch { + // RemoteFeatureFlagController unavailable; treat as disabled. + } + if (enabled) { + if (!this.#keyringLocked) { + this.stopAllPolling(); + this.startPolling(null); + } + } else { + this.stopAllPolling(); + } + }); + } + + async _executePoll(_input: null): Promise { + let isApiEnabled = false; + try { + isApiEnabled = isConfigRegistryApiEnabled( + this.messenger.call('RemoteFeatureFlagController:getState'), + ); + } catch { + // RemoteFeatureFlagController unavailable; skip fetch. + } + + // Skip fetch when API is disabled; client uses static config. + if (!isApiEnabled) { + return; + } + + try { + const result = await this.messenger.call( + 'ConfigRegistryApiService:fetchConfig', + { + etag: this.state.etag ?? undefined, + }, + ); + + if (!result.modified) { + this.update((state) => { + state.lastFetched = Date.now(); + if (result.etag !== undefined) { + state.etag = result.etag ?? null; + } + }); + return; + } + + const apiChains = result.data.data.chains; + const newConfigs: Record = {}; + // duplicate chainIds from API response are not expected + apiChains.forEach((chainConfig) => { + const { chainId } = chainConfig; + newConfigs[chainId] = chainConfig; + }); + + this.update((state) => { + state.configs.networks = newConfigs; + state.version = result.data.data.version; + state.lastFetched = Date.now(); + state.etag = result.etag ?? null; + }); + } catch (error) { + const errorInstance = + error instanceof Error ? error : new Error(String(error)); + + this.messenger.captureException?.(errorInstance); + } + } +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts new file mode 100644 index 00000000000..1ef2ae24d17 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts @@ -0,0 +1,18 @@ +import type { ConfigRegistryApiService } from './config-registry-api-service'; + +/** + * Fetches the latest config from the config registry API. + * + * @param args - The arguments to the function. + * @param args.options - Optional fetch options (e.g. etag for cache validation). + */ +export type ConfigRegistryApiServiceFetchConfigAction = { + type: 'ConfigRegistryApiService:fetchConfig'; + handler: ConfigRegistryApiService['fetchConfig']; +}; + +/** + * Union of all ConfigRegistryApiService action types. + */ +export type ConfigRegistryApiServiceMethodActions = + ConfigRegistryApiServiceFetchConfigAction; 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..a8894ecc41f --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts @@ -0,0 +1,408 @@ +import { SDK } from '@metamask/profile-sync-controller'; +import nock from 'nock'; + +import { ConfigRegistryApiService } from './config-registry-api-service'; +import type { + ConfigRegistryApiServiceMessenger, + ConfigRegistryApiServiceOptions, +} from './config-registry-api-service'; +import type { RegistryConfigApiResponse } from './types'; +import { createMockNetworkConfig } from '../../tests/helpers'; + +function createMockServiceMessenger(): ConfigRegistryApiServiceMessenger { + return { + registerMethodActionHandlers: jest.fn(), + } as unknown as ConfigRegistryApiServiceMessenger; +} + +function createService( + overrides: Partial> = {}, +): ConfigRegistryApiService { + return new ConfigRegistryApiService({ + ...overrides, + messenger: createMockServiceMessenger(), + }); +} + +const CONFIG_PATH = '/v1/config/networks'; +const UAT_ORIGIN = 'https://client-config.uat-api.cx.metamask.io'; +const DEV_ORIGIN = 'https://client-config.dev-api.cx.metamask.io'; +const PRD_ORIGIN = 'https://client-config.api.cx.metamask.io'; + +const MOCK_API_RESPONSE: RegistryConfigApiResponse = { + data: { + version: '"24952800ba9dafbc5e2c91f57f386d28"', + timestamp: 1761829548000, + chains: [createMockNetworkConfig()], + }, +}; + +describe('ConfigRegistryApiService', () => { + describe('fetchConfig', () => { + describe('URL by env', () => { + it('uses UAT URL when env is UAT', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ env: SDK.Env.UAT }); + await service.fetchConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('uses DEV URL when env is DEV', async () => { + const scope = nock(DEV_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ env: SDK.Env.DEV }); + await service.fetchConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('uses PRD URL when env is PRD', async () => { + const scope = nock(PRD_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ env: SDK.Env.PRD }); + await service.fetchConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('defaults to UAT environment', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + + await service.fetchConfig(); + + expect(scope.isDone()).toBe(true); + }); + }); + + it('fetches config from API successfully', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE, { + ETag: '"test-etag-123"', + }); + + const service = createService(); + const result = await service.fetchConfig(); + + expect(result).toMatchObject({ + modified: true, + etag: '"test-etag-123"', + data: MOCK_API_RESPONSE, + }); + expect(scope.isDone()).toBe(true); + }); + + it('fetches config from API without ETag header', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + const result = await service.fetchConfig(); + + expect(result).toMatchObject({ modified: true, data: MOCK_API_RESPONSE }); + expect(result.etag).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + it('handles 304 Not Modified response', async () => { + const etag = '"test-etag-123"'; + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(304); + + const service = createService(); + const result = await service.fetchConfig({ etag }); + + expect(result.modified).toBe(false); + expect(result.data).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + it('returns cached data when 304 is received and service has prior successful response', async () => { + const etag = '"test-etag-123"'; + const firstScope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE, { ETag: etag }); + + const service = createService(); + await service.fetchConfig(); + expect(firstScope.isDone()).toBe(true); + + const secondScope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(304); + + const result = await service.fetchConfig({ etag }); + + expect(result.modified).toBe(false); + expect(result.data).toStrictEqual(MOCK_API_RESPONSE); + expect(secondScope.isDone()).toBe(true); + }); + + it('handles 304 Not Modified response without ETag header', async () => { + const scope = nock(UAT_ORIGIN).get(CONFIG_PATH).reply(304); + + const service = createService(); + const result = await service.fetchConfig(); + + expect(result.modified).toBe(false); + expect(result.etag).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + it('includes If-None-Match header when etag is provided', async () => { + const etag = '"test-etag-123"'; + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + await service.fetchConfig({ etag }); + + expect(scope.isDone()).toBe(true); + }); + + it('does not include If-None-Match header when etag is undefined', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', (val) => val === undefined) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + await service.fetchConfig({ etag: undefined }); + + expect(scope.isDone()).toBe(true); + }); + + it('handles fetchConfig called with undefined options', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + await service.fetchConfig(undefined); + + expect(scope.isDone()).toBe(true); + }); + + it('throws error on invalid response structure', async () => { + const invalidResponse = { invalid: 'data' }; + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, invalidResponse); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error when response body is null', async () => { + const scope = nock(UAT_ORIGIN).get(CONFIG_PATH).reply(200, 'null'); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error when data is null', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, { data: null }); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error when data.chains is not an array', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, { + data: { version: '1', timestamp: 0, chains: 'not-an-array' }, + }); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error on HTTP error status', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(500, 'Internal Server Error'); + + const service = createService({ + policyOptions: { maxRetries: 0 }, + }); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ + message: 'Failed to fetch config: 500 Internal Server Error', + }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('handles network errors', async () => { + const customFetch = jest + .fn() + .mockRejectedValue(new Error('Network connection failed')); + + const service = createService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: 'Network connection failed' }), + ); + }); + + it('retries on failure', async () => { + nock(UAT_ORIGIN).get(CONFIG_PATH).replyWithError('Network error'); + nock(UAT_ORIGIN).get(CONFIG_PATH).replyWithError('Network error'); + const successScope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ + policyOptions: { maxRetries: 2 }, + }); + + const result = await service.fetchConfig(); + + expect(result).toMatchObject({ modified: true, data: MOCK_API_RESPONSE }); + expect(successScope.isDone()).toBe(true); + }); + }); + + describe('onRetry', () => { + it('registers and returns a disposable', () => { + const service = createService(); + const listener = jest.fn(); + const disposable = service.onRetry(listener); + expect(disposable).toHaveProperty('dispose'); + expect(typeof disposable.dispose).toBe('function'); + }); + }); + + describe('onBreak', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('registers and calls onBreak handler', async () => { + const maximumConsecutiveFailures = 3; + const retries = 0; + + for (let i = 0; i < maximumConsecutiveFailures; i++) { + nock(UAT_ORIGIN).get(CONFIG_PATH).replyWithError('Network error'); + } + + const onBreakHandler = jest.fn(); + const service = createService({ + policyOptions: { + maxRetries: retries, + maxConsecutiveFailures: maximumConsecutiveFailures, + circuitBreakDuration: 10000, + }, + }); + + service.onBreak(onBreakHandler); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + for (let i = 0; i < maximumConsecutiveFailures; i++) { + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + } + + const finalPromise = service.fetchConfig(); + await expect(finalPromise).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(onBreakHandler).toHaveBeenCalled(); + }); + }); + + describe('onDegraded', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onDegraded handler when service becomes degraded', async () => { + const degradedThreshold = 2000; // 2 seconds + nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, () => { + jest.advanceTimersByTime(degradedThreshold + 100); + return MOCK_API_RESPONSE; + }); + + const service = createService({ + policyOptions: { degradedThreshold, maxRetries: 0 }, + }); + const onDegradedHandler = jest.fn(); + service.onDegraded(onDegradedHandler); + + await service.fetchConfig(); + + expect(onDegradedHandler).toHaveBeenCalled(); + }); + }); + + describe('custom fetch function', () => { + it('uses custom fetch function when provided', async () => { + const customFetch = jest.fn().mockResolvedValue( + // eslint-disable-next-line no-restricted-globals + new Response(JSON.stringify(MOCK_API_RESPONSE), { + status: 200, + headers: { ETag: '"custom-etag"' }, + }), + ); + + const service = createService({ + fetch: customFetch, + }); + + const result = await service.fetchConfig(); + + expect(customFetch).toHaveBeenCalled(); + expect(result).toMatchObject({ modified: true, data: MOCK_API_RESPONSE }); + }); + }); +}); 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..b023e2bdb42 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts @@ -0,0 +1,237 @@ +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { SDK } from '@metamask/profile-sync-controller'; +import type { IDisposable } from 'cockatiel'; + +import type { ConfigRegistryApiServiceMethodActions } from './config-registry-api-service-method-action-types'; +import type { + FetchConfigOptions, + FetchConfigResult, + RegistryConfigApiResponse, +} from './types'; +import { validateRegistryConfigApiResponse } from './types'; + +const ENDPOINT_PATH = '/config/networks'; + +/** + * The name of the {@link ConfigRegistryApiService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'ConfigRegistryApiService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['fetchConfig'] as const; + +/** + * Actions that {@link ConfigRegistryApiService} exposes to other consumers. + */ +export type ConfigRegistryApiServiceActions = + ConfigRegistryApiServiceMethodActions; + +/** + * Actions from other messengers that {@link ConfigRegistryApiServiceMessenger} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link ConfigRegistryApiService} exposes to other consumers. + */ +export type ConfigRegistryApiServiceEvents = never; + +/** + * Events from other messengers that {@link ConfigRegistryApiService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ConfigRegistryApiService}. + */ +export type ConfigRegistryApiServiceMessenger = Messenger< + typeof serviceName, + ConfigRegistryApiServiceActions | AllowedActions, + ConfigRegistryApiServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * 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. + */ +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 = { + /** + * The messenger suited for this service. Required so the service can be used + * independently and register its actions. + */ + messenger: ConfigRegistryApiServiceMessenger; + env?: SDK.Env; + fetch?: typeof fetch; + /** + * Options to pass to `createServicePolicy`, which wraps each request. + * See {@link CreateServicePolicyOptions}. + */ + policyOptions?: CreateServicePolicyOptions; +}; + +export class ConfigRegistryApiService { + readonly name: typeof serviceName; + + readonly #messenger: ConfigRegistryApiServiceMessenger; + + readonly #policy: ServicePolicy; + + readonly #url: string; + + readonly #fetch: typeof fetch; + + /** Cached response from the last successful fetch. Used when server returns 304. */ + #cachedResponse: RegistryConfigApiResponse | null = null; + + /** + * Construct a Config Registry API Service. + * + * @param options - The options for constructing the service. + * @param options.messenger - The messenger suited for this 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.policyOptions - Options to pass to `createServicePolicy`, which wraps each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + env = SDK.Env.UAT, + fetch: customFetch = globalThis.fetch, + policyOptions = {}, + }: ConfigRegistryApiServiceOptions) { + this.name = serviceName; + this.#messenger = messenger; + this.#url = getConfigRegistryUrl(env); + this.#fetch = customFetch; + + this.#policy = createServicePolicy(policyOptions); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Registers a handler that will be called after a request returns a non-500 + * response, causing a retry. Primarily useful in tests where timers are being + * mocked. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]): IDisposable { + return this.#policy.onRetry(listener); + } + + /** + * Registers a handler that will be called after a set number of retry rounds + * prove that requests to the API endpoint consistently return a 5xx response. + * + * @param args - The arguments passed to the underlying policy's onBreak method + * (e.g. the listener to be called). + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + * @see {@link createServicePolicy} + */ + onBreak( + ...args: Parameters + ): ReturnType { + return this.#policy.onBreak(...args); + } + + /** + * Registers a handler that will be called under one of two circumstances: + * + * 1. After a set number of retries prove that requests to the API + * consistently result in one of the following failures: + * 1. A connection initiation error + * 2. A connection reset error + * 3. A timeout error + * 4. A non-JSON response + * 5. A 502, 503, or 504 response + * 2. After a successful request is made to the API, but the response takes + * longer than a set duration to return. + * + * @param args - The arguments passed to the underlying policy's onDegraded + * method (e.g. the listener to be called). + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + */ + 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 HttpError( + res.status, + `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, + ...(this.#cachedResponse !== null && { data: this.#cachedResponse }), + }; + } + + const etag = response.headers.get('ETag') ?? undefined; + const jsonData = await response.json(); + + validateRegistryConfigApiResponse(jsonData); + + this.#cachedResponse = jsonData; + + return { + data: jsonData, + etag, + modified: true, + }; + } +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/filters.test.ts b/packages/config-registry-controller/src/config-registry-api-service/filters.test.ts new file mode 100644 index 00000000000..ef4963496b1 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/filters.test.ts @@ -0,0 +1,97 @@ +import { filterNetworks } from './filters'; +import type { RegistryNetworkConfig } from './types'; +import { createMockNetworkConfig } from '../../tests/helpers'; + +describe('filters', () => { + describe('filterNetworks', () => { + const networks: RegistryNetworkConfig[] = [ + createMockNetworkConfig({ + config: { + isFeatured: true, + isTestnet: false, + isActive: true, + isDeprecated: false, + isDefault: true, + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:5', + config: { + isFeatured: false, + isTestnet: true, + isActive: true, + isDeprecated: false, + isDefault: false, + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:42', + config: { + isFeatured: true, + isTestnet: false, + isActive: false, + isDeprecated: true, + isDefault: false, + }, + }), + ]; + + it('returns all networks when no filters applied', () => { + const result = filterNetworks(networks); + + expect(result).toHaveLength(3); + }); + + it('filters by isFeatured', () => { + const result = filterNetworks(networks, { isFeatured: true }); + + expect(result).toHaveLength(2); + expect(result.every((network) => network.config.isFeatured)).toBe(true); + }); + + it('filters by isTestnet', () => { + const result = filterNetworks(networks, { isTestnet: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:5'); + }); + + it('filters by isActive', () => { + const result = filterNetworks(networks, { isActive: true }); + + expect(result).toHaveLength(2); + expect(result.every((network) => network.config.isActive)).toBe(true); + }); + + it('filters by isDeprecated', () => { + const result = filterNetworks(networks, { isDeprecated: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:42'); + }); + + it('filters by isDefault', () => { + const result = filterNetworks(networks, { isDefault: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:1'); + }); + + it('filters by multiple criteria (requiring all filters to match)', () => { + const result = filterNetworks(networks, { + isFeatured: true, + isActive: true, + isTestnet: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:1'); + }); + + it('returns empty array for empty input', () => { + const result = filterNetworks([]); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/config-registry-controller/src/config-registry-api-service/filters.ts b/packages/config-registry-controller/src/config-registry-api-service/filters.ts new file mode 100644 index 00000000000..0827d6ad65d --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/filters.ts @@ -0,0 +1,41 @@ +import type { RegistryNetworkConfig } from './types'; + +export type NetworkFilterOptions = { + isFeatured?: boolean; + isTestnet?: boolean; + isActive?: boolean; + isDeprecated?: boolean; + isDefault?: boolean; +}; + +const FILTER_KEYS = [ + 'isFeatured', + 'isTestnet', + 'isActive', + 'isDeprecated', + 'isDefault', +] as const satisfies (keyof NetworkFilterOptions)[]; + +/** + * @param networks - Array of chain configurations to filter. + * @param options - Filter options (matched against config.*). + * @returns Filtered array of chain configurations. + */ +export function filterNetworks( + networks: RegistryNetworkConfig[], + options: NetworkFilterOptions = {}, +): RegistryNetworkConfig[] { + return networks.filter((network) => { + const { config } = network; + for (const key of FILTER_KEYS) { + const optionValue = options[key]; + if ( + optionValue !== undefined && + config[key as keyof typeof config] !== optionValue + ) { + 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..dcf027847c3 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/types.ts @@ -0,0 +1,110 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + assert, + boolean, + number, + optional, + string, + type, +} from '@metamask/superstruct'; + +const AssetSchema = type({ + assetId: string(), + imageUrl: string(), + name: string(), + symbol: string(), + decimals: number(), + coingeckoCoinId: string(), +}); + +const AssetsSchema = type({ + listUrl: string(), + native: AssetSchema, + governance: optional(AssetSchema), +}); + +const RpcProviderSchema = type({ + url: string(), + type: string(), + networkClientId: string(), +}); + +const RpcProvidersSchema = type({ + default: RpcProviderSchema, + fallbacks: array(string()), +}); + +const BlockExplorerUrlsSchema = type({ + default: string(), + fallbacks: array(string()), +}); + +const ChainConfigSchema = type({ + isActive: boolean(), + isTestnet: boolean(), + isDefault: boolean(), + isFeatured: boolean(), + isDeprecated: boolean(), + isDeletable: boolean(), + priority: number(), +}); + +/** + * Schema for a single chain in the CAIP-2 config registry API response. + * chainId is in CAIP-2 format (e.g. "eip155:1", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"). + */ +export const RegistryNetworkConfigSchema = type({ + chainId: string(), + name: string(), + imageUrl: string(), + coingeckoPlatformId: string(), + geckoTerminalPlatformId: optional(string()), + assets: AssetsSchema, + rpcProviders: RpcProvidersSchema, + blockExplorerUrls: BlockExplorerUrlsSchema, + config: ChainConfigSchema, +}); + +/** + * Top-level API response shape. Uses `data.chains` (CAIP-2) and `data.version`. + */ +export const RegistryConfigApiResponseSchema = type({ + data: type({ + version: string(), + timestamp: number(), + chains: array(RegistryNetworkConfigSchema), + }), +}); + +export type RegistryNetworkConfig = Infer; + +export type RegistryConfigApiResponse = Infer< + typeof RegistryConfigApiResponseSchema +>; + +export function validateRegistryConfigApiResponse( + data: unknown, +): asserts data is RegistryConfigApiResponse { + assert(data, RegistryConfigApiResponseSchema); +} + +export type FetchConfigOptions = { + etag?: string; +}; + +export type FetchConfigResult = + | { + modified: false; + etag?: string; + /** + * Cached data from the service when available (e.g. after a previous + * successful fetch). Omitted when the service has no cache yet. + */ + data?: RegistryConfigApiResponse; + } + | { + 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..71bef8bf446 --- /dev/null +++ b/packages/config-registry-controller/src/index.ts @@ -0,0 +1,36 @@ +export type { + ConfigRegistryControllerState, + ConfigRegistryControllerOptions, + ConfigRegistryControllerActions, + ConfigRegistryControllerGetStateAction, + ConfigRegistryControllerStartPollingAction, + ConfigRegistryControllerStopPollingAction, + ConfigRegistryControllerEvents, + ConfigRegistryControllerStateChangeEvent, + ConfigRegistryControllerMessenger, +} from './ConfigRegistryController'; +export { + ConfigRegistryController, + DEFAULT_POLLING_INTERVAL, +} from './ConfigRegistryController'; +export { selectFeaturedNetworks, selectNetworks } from './selectors'; +export type { + FetchConfigOptions, + FetchConfigResult, + RegistryNetworkConfig, + RegistryConfigApiResponse, +} from './config-registry-api-service/types'; +export type { + ConfigRegistryApiServiceOptions, + ConfigRegistryApiServiceActions, + ConfigRegistryApiServiceEvents, + ConfigRegistryApiServiceMessenger, +} from './config-registry-api-service/config-registry-api-service'; +export type { + ConfigRegistryApiServiceFetchConfigAction, + ConfigRegistryApiServiceMethodActions, +} from './config-registry-api-service/config-registry-api-service-method-action-types'; +export type { NetworkFilterOptions } from './config-registry-api-service/filters'; +export { ConfigRegistryApiService } from './config-registry-api-service/config-registry-api-service'; +export { filterNetworks } from './config-registry-api-service/filters'; +export { isConfigRegistryApiEnabled } from './utils/feature-flags'; diff --git a/packages/config-registry-controller/src/selectors.test.ts b/packages/config-registry-controller/src/selectors.test.ts new file mode 100644 index 00000000000..e1fe6e7ddad --- /dev/null +++ b/packages/config-registry-controller/src/selectors.test.ts @@ -0,0 +1,82 @@ +import { selectFeaturedNetworks, selectNetworks } from './selectors'; +import { createMockNetworkConfig } from '../tests/helpers'; + +describe('selectors', () => { + describe('selectNetworks', () => { + it('returns all networks from state', () => { + const networks = { + 'eip155:1': createMockNetworkConfig({ chainId: 'eip155:1' }), + 'eip155:137': createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + }), + }; + const state = { + configs: { networks }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + expect(selectNetworks(state)).toBe(networks); + expect(selectNetworks(state)).toStrictEqual(networks); + }); + }); + + describe('selectFeaturedNetworks', () => { + it('returns only featured, active, non-testnet networks', () => { + const networks = { + 'eip155:1': createMockNetworkConfig({ + chainId: 'eip155:1', + config: { isFeatured: true, isActive: true, isTestnet: false }, + }), + 'eip155:5': createMockNetworkConfig({ + chainId: 'eip155:5', + name: 'Goerli', + config: { isFeatured: true, isActive: true, isTestnet: true }, + }), + 'eip155:10': createMockNetworkConfig({ + chainId: 'eip155:10', + name: 'Optimism', + config: { isFeatured: false, isActive: true, isTestnet: false }, + }), + 'eip155:137': createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + config: { isFeatured: true, isActive: false, isTestnet: false }, + }), + }; + const state = { + configs: { networks }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + const featured = selectFeaturedNetworks(state); + expect(Object.keys(featured)).toHaveLength(1); + expect(featured['eip155:1']).toBeDefined(); + expect(featured['eip155:5']).toBeUndefined(); + expect(featured['eip155:10']).toBeUndefined(); + expect(featured['eip155:137']).toBeUndefined(); + }); + + it('returns empty object when no networks match', () => { + const networks = { + 'eip155:5': createMockNetworkConfig({ + chainId: 'eip155:5', + config: { isTestnet: true }, + }), + }; + const state = { + configs: { networks }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + const featured = selectFeaturedNetworks(state); + expect(Object.keys(featured)).toHaveLength(0); + }); + }); +}); diff --git a/packages/config-registry-controller/src/selectors.ts b/packages/config-registry-controller/src/selectors.ts new file mode 100644 index 00000000000..7d44c486a59 --- /dev/null +++ b/packages/config-registry-controller/src/selectors.ts @@ -0,0 +1,39 @@ +import { createSelector } from 'reselect'; + +import { filterNetworks } from './config-registry-api-service/filters'; +import type { RegistryNetworkConfig } from './config-registry-api-service/types'; +import type { ConfigRegistryControllerState } from './ConfigRegistryController'; + +/** + * Base selector to get all networks from the controller state. + * + * @param state - The ConfigRegistryController state + * @returns All network configurations keyed by chain ID + */ +export const selectNetworks = ( + state: ConfigRegistryControllerState, +): Record => state.configs.networks; + +/** + * Selector to get featured, active, non-testnet networks. + * Use this for the default network list (e.g. main network picker). + * + * @param state - The ConfigRegistryController state + * @returns Filtered network configurations keyed by chain ID + */ +export const selectFeaturedNetworks = createSelector( + selectNetworks, + (networks): Record => { + const networkArray = Object.values(networks); + const filtered = filterNetworks(networkArray, { + isFeatured: true, + isActive: true, + isTestnet: false, + }); + const result: Record = {}; + filtered.forEach((config) => { + result[config.chainId] = config; + }); + return result; + }, +); diff --git a/packages/config-registry-controller/src/utils/feature-flags.test.ts b/packages/config-registry-controller/src/utils/feature-flags.test.ts new file mode 100644 index 00000000000..a81376fca22 --- /dev/null +++ b/packages/config-registry-controller/src/utils/feature-flags.test.ts @@ -0,0 +1,41 @@ +import { isConfigRegistryApiEnabled } from './feature-flags'; + +describe('isConfigRegistryApiEnabled', () => { + it('returns true when configRegistryApiEnabled is true', () => { + expect( + isConfigRegistryApiEnabled({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: 0, + }), + ).toBe(true); + }); + + it('returns false when configRegistryApiEnabled is false', () => { + expect( + isConfigRegistryApiEnabled({ + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: 0, + }), + ).toBe(false); + }); + + it('returns false when configRegistryApiEnabled is missing', () => { + expect( + isConfigRegistryApiEnabled({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }), + ).toBe(false); + }); + + it('returns false when flag value is not a boolean', () => { + expect( + isConfigRegistryApiEnabled({ + remoteFeatureFlags: { + configRegistryApiEnabled: 'true' as unknown as boolean, + }, + cacheTimestamp: 0, + }), + ).toBe(false); + }); +}); 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..a7d6fe0f17b --- /dev/null +++ b/packages/config-registry-controller/src/utils/feature-flags.ts @@ -0,0 +1,24 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +const FEATURE_FLAG_KEY = 'configRegistryApiEnabled'; +const DEFAULT_FEATURE_FLAG_VALUE = false; + +/** + * Selector: returns whether the config registry API feature flag is enabled + * from the given RemoteFeatureFlagController state. + * + * @param state - The RemoteFeatureFlagController state. + * @returns True if the feature flag is enabled, false otherwise. + */ +export function isConfigRegistryApiEnabled( + state: RemoteFeatureFlagControllerState, +): boolean { + const { remoteFeatureFlags } = state; + const flagValue = remoteFeatureFlags?.[FEATURE_FLAG_KEY]; + + if (typeof flagValue === 'boolean') { + return flagValue; + } + + return DEFAULT_FEATURE_FLAG_VALUE; +} diff --git a/packages/config-registry-controller/tests/helpers.ts b/packages/config-registry-controller/tests/helpers.ts new file mode 100644 index 00000000000..a096f0e9143 --- /dev/null +++ b/packages/config-registry-controller/tests/helpers.ts @@ -0,0 +1,70 @@ +import type { RegistryNetworkConfig } from '../src/config-registry-api-service/types'; + +/** + * Creates a mock RegistryNetworkConfig (CAIP-2 chain) for testing. + * + * @param overrides - Optional properties to override in the default config. + * @returns A mock RegistryNetworkConfig object. + */ +const DEFAULT_CHAIN_CONFIG = { + isActive: true, + isTestnet: false, + isDefault: true, + isFeatured: true, + isDeprecated: false, + isDeletable: false, + priority: 0, +} as const; + +/** Overrides for createMockNetworkConfig; config can be partial. */ +export type MockNetworkConfigOverrides = Partial< + Omit +> & { + config?: Partial; +}; + +export function createMockNetworkConfig( + overrides: MockNetworkConfigOverrides = {}, +): RegistryNetworkConfig { + const base: RegistryNetworkConfig = { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + imageUrl: + 'https://token.api.cx.metamask.io/assets/networkLogos/ethereum.svg', + coingeckoPlatformId: 'ethereum', + geckoTerminalPlatformId: 'eth', + assets: { + listUrl: 'https://tokens.api.cx.metamask.io/v3/chains/eip155:1/assets', + native: { + assetId: 'eip155:1/slip44:60', + imageUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + coingeckoCoinId: 'ethereum', + }, + }, + rpcProviders: { + default: { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + }, + fallbacks: [], + }, + blockExplorerUrls: { + default: 'https://etherscan.io', + fallbacks: [], + }, + config: { ...DEFAULT_CHAIN_CONFIG }, + }; + const { config: configOverride, ...rest } = overrides; + return { + ...base, + ...rest, + config: configOverride + ? { ...DEFAULT_CHAIN_CONFIG, ...configOverride } + : base.config, + }; +} diff --git a/packages/config-registry-controller/tsconfig.build.json b/packages/config-registry-controller/tsconfig.build.json new file mode 100644 index 00000000000..08b48b2b526 --- /dev/null +++ b/packages/config-registry-controller/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["**/*.test.ts"], + "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..05f86e783c9 --- /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", "./tests"] +} 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 e7e4cdc0c3c..43b757ae590 100644 --- a/teams.json +++ b/teams.json @@ -70,5 +70,6 @@ "metamask/analytics-controller": "team-extension-platform,team-mobile-platform", "metamask/analytics-data-regulation-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/tsconfig.build.json b/tsconfig.build.json index 9f30997d3fe..af4580726d4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -70,6 +70,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 5592e0e76f4..f6778480054 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -71,6 +71,9 @@ { "path": "./packages/composable-controller" }, + { + "path": "./packages/config-registry-controller" + }, { "path": "./packages/connectivity-controller" }, diff --git a/yarn.lock b/yarn.lock index f0228dfa93b..86a55159976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2946,6 +2946,34 @@ __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.19.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^27.1.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" + 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"