diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 709ef62807c..c13f90b38f5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,6 +13,7 @@ ## Assets Team /packages/assets-controllers @MetaMask/metamask-assets +/packages/network-order-controller @MetaMask/metamask-assets ## Confirmations Team /packages/address-book-controller @MetaMask/confirmations @@ -153,3 +154,5 @@ /packages/foundryup/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/wallet-framework-engineers /packages/seedless-onboarding-controller/package.json @MetaMask/web3auth @MetaMask/wallet-framework-engineers /packages/seedless-onboarding-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/wallet-framework-engineers +/packages/network-order-controller/package.json @MetaMask/metamask-assets @MetaMask/wallet-framework-engineers +/packages/network-order-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/wallet-framework-engineers diff --git a/README.md b/README.md index 0ad9ee8c9c5..8a170b0ffd2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/multichain-transactions-controller`](packages/multichain-transactions-controller) - [`@metamask/name-controller`](packages/name-controller) - [`@metamask/network-controller`](packages/network-controller) +- [`@metamask/network-order-controller`](packages/network-order-controller) - [`@metamask/notification-services-controller`](packages/notification-services-controller) - [`@metamask/permission-controller`](packages/permission-controller) - [`@metamask/permission-log-controller`](packages/permission-log-controller) @@ -109,6 +110,7 @@ linkStyle default opacity:0.5 multichain_transactions_controller(["@metamask/multichain-transactions-controller"]); name_controller(["@metamask/name-controller"]); network_controller(["@metamask/network-controller"]); + network_order_controller(["@metamask/network-order-controller"]); notification_services_controller(["@metamask/notification-services-controller"]); permission_controller(["@metamask/permission-controller"]); permission_log_controller(["@metamask/permission-log-controller"]); diff --git a/packages/network-order-controller/CHANGELOG.md b/packages/network-order-controller/CHANGELOG.md new file mode 100644 index 00000000000..babb9876eaf --- /dev/null +++ b/packages/network-order-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 ([#6022](https://github.com/MetaMask/core/pull/6022)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/network-order-controller/LICENSE b/packages/network-order-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/network-order-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/network-order-controller/README.md b/packages/network-order-controller/README.md new file mode 100644 index 00000000000..52fe97afa3c --- /dev/null +++ b/packages/network-order-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/network-order-controller` + +Provides an interface to the currently network order via a MetaMask-compatible provider object + +## Installation + +`yarn add @metamask/network-order-controller` + +or + +`npm install @metamask/network-order-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/network-order-controller/jest.config.js b/packages/network-order-controller/jest.config.js new file mode 100644 index 00000000000..621e01529f5 --- /dev/null +++ b/packages/network-order-controller/jest.config.js @@ -0,0 +1,30 @@ +/* + * 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: 88.47, + functions: 97.43, + lines: 94.47, + statements: 94.29, + }, + }, + + // Currently the tests for NetworkController have a race condition which + // causes intermittent failures. This seems to fix it. + testEnvironment: 'jsdom', +}); diff --git a/packages/network-order-controller/package.json b/packages/network-order-controller/package.json new file mode 100644 index 00000000000..aa73235b7be --- /dev/null +++ b/packages/network-order-controller/package.json @@ -0,0 +1,73 @@ +{ + "name": "@metamask/network-order-controller", + "version": "0.0.0", + "description": "Provides an interface to the currently network order via a MetaMask-compatible provider object", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/network-order-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:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/network-order-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/network-order-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" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "dependencies": { + "@metamask/base-controller": "^8.0.1", + "@metamask/keyring-api": "^18.0.0", + "@metamask/multichain-network-controller": "^0.9.0", + "@metamask/utils": "^11.2.0", + "@metamask/network-controller": "^24.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/network-order-controller/src/NetworkOrderController.ts b/packages/network-order-controller/src/NetworkOrderController.ts new file mode 100644 index 00000000000..35172f294cb --- /dev/null +++ b/packages/network-order-controller/src/NetworkOrderController.ts @@ -0,0 +1,168 @@ +import { BaseController } from '@metamask/base-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import type { + NetworkControllerStateChangeEvent, + NetworkState, +} from '@metamask/network-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import type { Patch } from 'immer'; + +import { TEST_CHAINS } from './constant'; + +// Unique name for the controller +const controllerName = 'NetworkOrderController'; + +/** + * Information about an ordered network. + */ +export type NetworksInfo = { + networkId: CaipChainId; // The network's chain id +}; + +// State shape for NetworkOrderController +export type NetworkOrderControllerState = { + orderedNetworkList: NetworksInfo[]; +}; + +// Describes the structure of a state change event +export type NetworkOrderStateChange = { + type: `${typeof controllerName}:stateChange`; + payload: [NetworkOrderControllerState, Patch[]]; +}; + +// Describes the action for updating the networks list +export type NetworkOrderControllerupdateNetworksListAction = { + type: `${typeof controllerName}:updateNetworksList`; + handler: NetworkOrderController['updateNetworksList']; +}; + +// Union of all possible actions for the messenger +export type NetworkOrderControllerMessengerActions = + NetworkOrderControllerupdateNetworksListAction; + +// Type for the messenger of NetworkOrderController +export type NetworkOrderControllerMessenger = RestrictedMessenger< + typeof controllerName, + NetworkOrderControllerMessengerActions, + NetworkOrderStateChange | NetworkControllerStateChangeEvent, + never, + NetworkOrderStateChange['type'] | NetworkControllerStateChangeEvent['type'] +>; + +// Default state for the controller +const defaultState: NetworkOrderControllerState = { + orderedNetworkList: [], +}; + +// Metadata for the controller state +const metadata = { + orderedNetworkList: { + persist: true, + anonymous: true, + }, +}; + +/** + * Controller that updates the order of the network list. + * This controller subscribes to network state changes and ensures + * that the network list is updated based on the latest network configurations. + */ + +export class NetworkOrderController extends BaseController< + typeof controllerName, + NetworkOrderControllerState, + NetworkOrderControllerMessenger +> { + /** + * Creates a NetworkOrderController instance. + * + * @param args - The arguments to this function. + * @param args.messenger - Messenger used to communicate with BaseV2 controller. + * @param args.state - Initial state to set on this controller. + */ + constructor({ + messenger, + state, + }: { + messenger: NetworkOrderControllerMessenger; + state?: NetworkOrderControllerState; + }) { + // Call the constructor of BaseControllerV2 + super({ + messenger, + metadata, + name: controllerName, + state: { ...defaultState, ...state }, + }); + + // Subscribe to network state changes + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + (networkControllerState: NetworkState) => { + this.onNetworkControllerStateChange(networkControllerState); + }, + ); + } + + /** + * Handles the state change of the network controller and updates the networks list. + * + * @param networkControllerState - The state of the network controller. + * @param networkControllerState.networkConfigurationsByChainId - The network configurations by chain id. + */ + onNetworkControllerStateChange({ + networkConfigurationsByChainId, + }: NetworkState) { + this.update((state: NetworkOrderControllerState) => { + // Filter out testnets, which are in the state but not orderable + const hexChainIds = Object.keys(networkConfigurationsByChainId).filter( + (chainId) => + !TEST_CHAINS.includes(chainId as (typeof TEST_CHAINS)[number]), + ) as Hex[]; + const chainIds: CaipChainId[] = hexChainIds.map(toEvmCaipChainId); + const nonEvmChainIds: CaipChainId[] = [ + BtcScope.Mainnet, + SolScope.Mainnet, + ]; + + const newNetworks = chainIds + .filter( + (chainId) => + !state.orderedNetworkList.some( + ({ networkId }: { networkId: CaipChainId }) => + networkId === chainId, + ), + ) + .map((chainId) => ({ networkId: chainId })); + + state.orderedNetworkList = state.orderedNetworkList + // Filter out deleted networks + .filter( + ({ networkId }: { networkId: CaipChainId }) => + chainIds.includes(networkId) || + // Since Bitcoin and Solana are not part of the @metamask/network-controller, we have + // to add a second check to make sure it is not filtered out. + // TODO: Update this logic to @metamask/multichain-network-controller once all networks are migrated. + nonEvmChainIds.includes(networkId), + ) + // Append new networks to the end + .concat(newNetworks); + }); + } + + /** + * Updates the networks list in the state with the provided list of networks. + * + * @param networkList - The list of networks to update in the state. + */ + + updateNetworksList(chainIds: CaipChainId[]) { + this.update((state: NetworkOrderControllerState) => { + state.orderedNetworkList = chainIds.map((chainId) => ({ + networkId: chainId, + })); + }); + } +} diff --git a/packages/network-order-controller/src/constant.ts b/packages/network-order-controller/src/constant.ts new file mode 100644 index 00000000000..07ab8c35bab --- /dev/null +++ b/packages/network-order-controller/src/constant.ts @@ -0,0 +1,9 @@ +import type { Hex } from '@metamask/utils'; + +export const TEST_CHAINS: Hex[] = [ + '0xaa36a7', + '0xe705', + '0x539', + '0x18c6', + '0x279f', +]; diff --git a/packages/network-order-controller/src/index.ts b/packages/network-order-controller/src/index.ts new file mode 100644 index 00000000000..128a5f0de97 --- /dev/null +++ b/packages/network-order-controller/src/index.ts @@ -0,0 +1,10 @@ +export { + NetworkOrderController, + type NetworkOrderControllerState, + type NetworkOrderControllerMessenger, + type NetworkOrderControllerMessengerActions, + type NetworkOrderStateChange, + type NetworksInfo, +} from './NetworkOrderController'; + +export { TEST_CHAINS } from './constant'; diff --git a/packages/network-order-controller/tests/NetworkOrderController.test.ts b/packages/network-order-controller/tests/NetworkOrderController.test.ts new file mode 100644 index 00000000000..f4782e1eeeb --- /dev/null +++ b/packages/network-order-controller/tests/NetworkOrderController.test.ts @@ -0,0 +1,430 @@ +import { Messenger } from '@metamask/base-controller'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import { + RpcEndpointType, + getDefaultNetworkControllerState, +} from '@metamask/network-controller'; +import type { + NetworkControllerStateChangeEvent, + NetworkConfiguration, + NetworkState, +} from '@metamask/network-controller'; +import type { CaipChainId } from '@metamask/utils'; + +import { NetworkOrderController } from '../src/NetworkOrderController'; +import type { NetworkOrderControllerMessengerActions } from '../src/NetworkOrderController'; + +const controllerName = 'NetworkOrderController'; + +// Test chain IDs that should be filtered out +const TEST_CHAIN_IDS = { + SEPOLIA: '0xaa36a7', + LINEA_SEPOLIA: '0xe705', + LOCALHOST: '0x539', + MEGAETH_TESTNET: '0x18c6', + SEI: '0x531', +} as const; + +// Helper function to build a network configuration +const buildNetworkConfig = ( + overrides: Partial = {}, +): NetworkConfiguration => { + const defaultConfig: NetworkConfiguration = { + blockExplorerUrls: [], + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + name: 'Test Network', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: 'test-client-id', + url: 'https://test.endpoint', + failoverUrls: [], + }, + ], + }; + + return { + ...defaultConfig, + ...overrides, + }; +}; + +describe('NetworkOrderController', () => { + let messenger: Messenger< + NetworkOrderControllerMessengerActions, + NetworkControllerStateChangeEvent + >; + let controller: NetworkOrderController; + + beforeEach(() => { + messenger = new Messenger(); + controller = new NetworkOrderController({ + messenger: messenger.getRestricted({ + name: controllerName, + allowedActions: [], + allowedEvents: ['NetworkController:stateChange'], + }), + }); + }); + + it('should instantiate with default state', () => { + const { state } = controller; + expect(state).toStrictEqual({ + orderedNetworkList: [], + }); + }); + + describe('updateNetworksList', () => { + it('should update the ordered network list', () => { + const chainIds: CaipChainId[] = [ + toEvmCaipChainId(TEST_CHAIN_IDS.SEPOLIA), + toEvmCaipChainId(TEST_CHAIN_IDS.LINEA_SEPOLIA), + ]; + + controller.updateNetworksList(chainIds); + + const { state } = controller; + expect(state.orderedNetworkList).toHaveLength(2); + chainIds.forEach((chainId, index) => { + expect(state.orderedNetworkList[index].networkId).toBe(chainId); + }); + }); + + it('should handle empty network list', () => { + // First add some networks + const initialChainIds: CaipChainId[] = [ + toEvmCaipChainId(TEST_CHAIN_IDS.SEPOLIA), + toEvmCaipChainId(TEST_CHAIN_IDS.LINEA_SEPOLIA), + ]; + controller.updateNetworksList(initialChainIds); + + // Then update with empty list + controller.updateNetworksList([]); + + const { state } = controller; + expect(state.orderedNetworkList).toHaveLength(0); + }); + }); + + describe('onNetworkControllerStateChange', () => { + it('should update orderedNetworkList when new networks are added', () => { + const mockNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Use a non-test network instead of Sepolia + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + }, + networksMetadata: {}, + }; + + messenger.publish('NetworkController:stateChange', mockNetworkState, []); + + const { state } = controller; + const expectedNetworks = [toEvmCaipChainId('0x1')]; + expect(state.orderedNetworkList).toHaveLength(1); + expectedNetworks.forEach((networkId) => { + expect( + state.orderedNetworkList.some( + (n: { networkId: CaipChainId }) => n.networkId === networkId, + ), + ).toBe(true); + }); + }); + + it('should filter out test networks', () => { + const mockNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Add all test networks + [TEST_CHAIN_IDS.SEPOLIA]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.SEPOLIA, + name: 'Sepolia Test Network', + }), + [TEST_CHAIN_IDS.LINEA_SEPOLIA]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.LINEA_SEPOLIA, + name: 'Linea Sepolia Test Network', + }), + [TEST_CHAIN_IDS.LOCALHOST]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.LOCALHOST, + name: 'Localhost Test Network', + }), + [TEST_CHAIN_IDS.MEGAETH_TESTNET]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.MEGAETH_TESTNET, + name: 'MegaETH Test Network', + }), + [TEST_CHAIN_IDS.SEI]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.SEI, + name: 'SEI Test Network', + }), + // Add a non-test network + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + }, + networksMetadata: {}, + }; + + // First add all networks to the ordered list + controller.updateNetworksList([ + toEvmCaipChainId(TEST_CHAIN_IDS.SEPOLIA), + toEvmCaipChainId(TEST_CHAIN_IDS.LINEA_SEPOLIA), + toEvmCaipChainId(TEST_CHAIN_IDS.LOCALHOST), + toEvmCaipChainId(TEST_CHAIN_IDS.MEGAETH_TESTNET), + toEvmCaipChainId(TEST_CHAIN_IDS.SEI), + toEvmCaipChainId('0x1'), + ]); + + // Update network state + messenger.publish('NetworkController:stateChange', mockNetworkState, []); + + const { state } = controller; + const chainIds = state.orderedNetworkList.map( + (n: { networkId: CaipChainId }) => n.networkId, + ); + + // Verify that all test networks are filtered out + expect(chainIds).not.toContain(toEvmCaipChainId(TEST_CHAIN_IDS.SEPOLIA)); + expect(chainIds).not.toContain( + toEvmCaipChainId(TEST_CHAIN_IDS.LINEA_SEPOLIA), + ); + expect(chainIds).not.toContain( + toEvmCaipChainId(TEST_CHAIN_IDS.LOCALHOST), + ); + expect(chainIds).not.toContain( + toEvmCaipChainId(TEST_CHAIN_IDS.MEGAETH_TESTNET), + ); + + // Verify that non-test networks are preserved + expect(chainIds).toContain(toEvmCaipChainId('0x1')); + }); + + it('should preserve non-EVM networks (BTC and SOL)', () => { + // First add some networks + const mockNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Use a non-test network + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + }, + networksMetadata: {}, + }; + + // Add BTC and SOL to the network list + controller.updateNetworksList([ + toEvmCaipChainId('0x1'), + BtcScope.Mainnet, + SolScope.Mainnet, + ]); + + // Update network state + messenger.publish('NetworkController:stateChange', mockNetworkState, []); + + const { state } = controller; + const expectedNetworks = [ + toEvmCaipChainId('0x1'), + BtcScope.Mainnet, + SolScope.Mainnet, + ]; + expect(state.orderedNetworkList).toHaveLength(3); + expectedNetworks.forEach((networkId) => { + expect( + state.orderedNetworkList.some( + (n: { networkId: CaipChainId }) => n.networkId === networkId, + ), + ).toBe(true); + }); + }); + + it('should remove networks that no longer exist', () => { + // First add some networks + const initialNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Use non-test networks + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + '0x89': buildNetworkConfig({ + chainId: '0x89', + name: 'Polygon Mainnet', + }), + }, + networksMetadata: {}, + }; + + messenger.publish( + 'NetworkController:stateChange', + initialNetworkState, + [], + ); + + // Then update with fewer networks + const updatedNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + }, + networksMetadata: {}, + }; + + messenger.publish( + 'NetworkController:stateChange', + updatedNetworkState, + [], + ); + + const { state } = controller; + expect(state.orderedNetworkList).toHaveLength(1); + expect(state.orderedNetworkList[0].networkId).toBe( + toEvmCaipChainId('0x1'), + ); + }); + + it('should handle empty network configurations', () => { + const mockNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }; + + messenger.publish('NetworkController:stateChange', mockNetworkState, []); + + const { state } = controller; + expect(state.orderedNetworkList).toHaveLength(0); + }); + + it('should filter out all test networks defined in TEST_CHAINS', () => { + const mockNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Add all test networks + [TEST_CHAIN_IDS.SEPOLIA]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.SEPOLIA, + name: 'Sepolia Test Network', + }), + [TEST_CHAIN_IDS.LINEA_SEPOLIA]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.LINEA_SEPOLIA, + name: 'Linea Sepolia Test Network', + }), + [TEST_CHAIN_IDS.LOCALHOST]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.LOCALHOST, + name: 'Localhost Test Network', + }), + [TEST_CHAIN_IDS.MEGAETH_TESTNET]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.MEGAETH_TESTNET, + name: 'MegaETH Test Network', + }), + [TEST_CHAIN_IDS.SEI]: buildNetworkConfig({ + chainId: TEST_CHAIN_IDS.SEI, + name: 'SEI Test Network', + }), + // Add a non-test network + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + }, + networksMetadata: {}, + }; + + // First add all networks to the ordered list + controller.updateNetworksList([ + toEvmCaipChainId(TEST_CHAIN_IDS.SEPOLIA), + toEvmCaipChainId(TEST_CHAIN_IDS.LINEA_SEPOLIA), + toEvmCaipChainId(TEST_CHAIN_IDS.LOCALHOST), + toEvmCaipChainId(TEST_CHAIN_IDS.MEGAETH_TESTNET), + toEvmCaipChainId(TEST_CHAIN_IDS.SEI), + toEvmCaipChainId('0x1'), + ]); + + // Update network state + messenger.publish('NetworkController:stateChange', mockNetworkState, []); + + const { state } = controller; + const chainIds = state.orderedNetworkList.map( + (n: { networkId: CaipChainId }) => n.networkId, + ); + + // Verify that all test networks are filtered out + expect(chainIds).not.toContain(toEvmCaipChainId(TEST_CHAIN_IDS.SEPOLIA)); + expect(chainIds).not.toContain( + toEvmCaipChainId(TEST_CHAIN_IDS.LINEA_SEPOLIA), + ); + expect(chainIds).not.toContain( + toEvmCaipChainId(TEST_CHAIN_IDS.LOCALHOST), + ); + expect(chainIds).not.toContain( + toEvmCaipChainId(TEST_CHAIN_IDS.MEGAETH_TESTNET), + ); + + // Verify that non-test networks are preserved + expect(chainIds).toContain(toEvmCaipChainId('0x1')); + }); + + it('should preserve non-EVM networks (BTC and SOL) even when they are not in networkConfigurationsByChainId', () => { + const mockNetworkState: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Only include EVM networks + '0x1': buildNetworkConfig({ + chainId: '0x1', + name: 'Ethereum Mainnet', + }), + }, + networksMetadata: {}, + }; + + // First add all networks including non-EVM networks + controller.updateNetworksList([ + toEvmCaipChainId('0x1'), + BtcScope.Mainnet, + SolScope.Mainnet, + ]); + + // Update network state + messenger.publish('NetworkController:stateChange', mockNetworkState, []); + + const { state } = controller; + const chainIds = state.orderedNetworkList.map( + (n: { networkId: CaipChainId }) => n.networkId, + ); + + // Verify that EVM network is preserved + expect(chainIds).toContain(toEvmCaipChainId('0x1')); + + // Verify that non-EVM networks are preserved even though they're not in networkConfigurationsByChainId + expect(chainIds).toContain(BtcScope.Mainnet); + expect(chainIds).toContain(SolScope.Mainnet); + + // Verify the exact order and length + expect(state.orderedNetworkList).toHaveLength(3); + expect(state.orderedNetworkList[0].networkId).toBe( + toEvmCaipChainId('0x1'), + ); + expect(state.orderedNetworkList[1].networkId).toBe(BtcScope.Mainnet); + expect(state.orderedNetworkList[2].networkId).toBe(SolScope.Mainnet); + }); + }); +}); diff --git a/packages/network-order-controller/tsconfig.build.json b/packages/network-order-controller/tsconfig.build.json new file mode 100644 index 00000000000..9333fcd502b --- /dev/null +++ b/packages/network-order-controller/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../../packages/base-controller/tsconfig.build.json" + }, + { + "path": "../../packages/network-controller/tsconfig.build.json" + }, + { + "path": "../../packages/multichain-network-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-order-controller/tsconfig.json b/packages/network-order-controller/tsconfig.json new file mode 100644 index 00000000000..f7cfe27df04 --- /dev/null +++ b/packages/network-order-controller/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../.." + }, + "references": [ + { + "path": "../../packages/base-controller" + }, + { + "path": "../../packages/network-controller" + }, + { + "path": "../../packages/multichain-network-controller" + } + ], + "include": ["../../types", "./src", "tests/NetworkOrderController.test.ts"] +} diff --git a/packages/network-order-controller/typedoc.json b/packages/network-order-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/network-order-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 10adc9abbdc..e4abb002494 100644 --- a/teams.json +++ b/teams.json @@ -47,5 +47,6 @@ "metamask/earn-controller": "team-earn", "metamask/error-reporting-service": "team-wallet-framework", "metamask/foundryup": "team-mobile-platform,team-extension-platform", - "metamask/seedless-onboarding-controller": "team-web3auth" + "metamask/seedless-onboarding-controller": "team-web3auth", + "metamask/network-order-controller": "team-assets" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 18ee0a66222..988d3d5d479 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,6 +34,7 @@ }, { "path": "./packages/name-controller/tsconfig.build.json" }, { "path": "./packages/network-controller/tsconfig.build.json" }, + { "path": "./packages/network-order-controller/tsconfig.build.json" }, { "path": "./packages/notification-services-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 1830b6b33ca..5562e3e6654 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ { "path": "./packages/multichain-transactions-controller" }, { "path": "./packages/name-controller" }, { "path": "./packages/network-controller" }, + { "path": "./packages/network-order-controller" }, { "path": "./packages/notification-services-controller" }, { "path": "./packages/permission-controller" }, { "path": "./packages/permission-log-controller" }, diff --git a/yarn.lock b/yarn.lock index e49f0e15dd6..fcfd5dc8dd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3929,6 +3929,26 @@ __metadata: languageName: unknown linkType: soft +"@metamask/network-order-controller@workspace:packages/network-order-controller": + version: 0.0.0-use.local + resolution: "@metamask/network-order-controller@workspace:packages/network-order-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.1" + "@metamask/keyring-api": "npm:^18.0.0" + "@metamask/multichain-network-controller": "npm:^0.9.0" + "@metamask/network-controller": "npm:^24.0.0" + "@metamask/utils": "npm:^11.2.0" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + languageName: unknown + linkType: soft + "@metamask/nonce-tracker@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/nonce-tracker@npm:6.0.0"