From 442a30075434b07c995cf09f3831041e93046b3d Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 3 Sep 2024 09:40:16 -0600 Subject: [PATCH] Group network configurations by chain (#4286) ## Explanation Currently, in the client, it is possible to have multiple networks with different RPC endpoint URLs representing the same chain. This creates a problem because if all we have is a chain ID, we don't know which URL to use for requests. To solve this, we plan on consolidating the UX on the client side such that each network corresponds to exactly one chain. Users can then select which default RPC URL they'd like to use for requests. This commit implements the controller changes necessary to support this UX. Here are some more details on the changes here: - The concept of a network configuration has been repurposed such that instead of representing an RPC endpoint, it now represents a whole chain. - A network configuration may have multiple RPC endpoints, and one of them must be designated as the default. - Some RPC endpoints are special in that they represent Infura API URLs; these have the same object shape as "non-Infura" (custom) RPC endpoints, but the Infura project ID is hidden and injected into the RPC URL when creating the network client. - There is no longer a 1-to-1 relationship between network configuration and network client; rather, the 1-to-1 relationship exists between RPC endpoint and network client. This means that the ID of the network client which is created for an RPC endpoint is stored on that RPC endpoint instead of the whole network configuration. - The `networkConfigurations` state property has been replaced with `networkConfigurationsByChainId`. This continues to be an object, but the data inside is organized such that network configurations are identified by chain ID instead of network client ID as they were previously. - The methods `upsertNetworkConfiguration` and `removeNetworkConfiguration` have been removed. These methods always did more than simply add or remove a network configuration; they also updated the registry of network clients. Instead, these methods have been replaced with `addNetwork`, `updateNetwork`, and `removeNetwork`. - `addNetwork` creates new network clients for each RPC endpoint in the given network configuration. - `updateNetwork` takes a chain ID referring to a network configuration and a draft version of that network configuration, and adds or removes network clients for added or removed RPC endpoints. - `removeNetwork` takes a chain ID referring to a network configuration and removes the network clients for each of its RPC endpoints. - In addition, due to the changes to network configuration itself, there are new restrictions on `networkConfigurationsByChainId`, which are validated on initialization and on update. These are: - The network controller cannot be initialized with an empty collection of network configurations. This is because there must be a selected network client so that consumers have a provider to use out of the gate. - Consequently, the last network configuration cannot be removed. - The network configuration that contains a reference to the currently selected network client cannot be removed. - The chain ID of a network configuration must match the same chain that it's filed under in `networkConfigurationsByChainId`. - No two network configurations can have the same chain ID. - A RPC endpoint in a network configuration must have a well-formed URL. - A network configuration cannot have duplicate RPC endpoints. - No two RPC endpoints (regardless of network configuration) can have the same URL. Equality is currently determined by normalizing URLs as per RFC 3986 and may include data like request headers in the future. - If a network configuration has an Infura RPC endpoint, its chain ID must match the set chain ID of the network configuration. - Changing the chain ID of a network configuration is possible, but any existing Infura RPC endpoint must be replaced with the one that matches the new chain ID. - No two RPC endpoints (regardless of network configuration) can have the same network client ID. - Finally, the `trackMetaMetricsEvent` option has been removed from the constructor. This was previously used in `upsertNetworkConfiguration` to create a MetaMetrics event when a new network added, but I've added a new event `NetworkController:networkAdded` to allow the client to do this on its own accord. ## References Fixes #4189. Fixes #3793. ## Changelog (Updated in the PR.) ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Jongsun Suh Co-authored-by: Brian Bergeron Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Co-authored-by: Michele Esposito --- .../src/AccountTrackerController.test.ts | 4 +- .../src/AssetsContractController.test.ts | 1 - .../src/NftController.test.ts | 4 +- .../src/NftDetectionController.test.ts | 4 +- .../src/TokenDetectionController.test.ts | 56 +- .../src/TokenListController.test.ts | 10 +- .../src/TokenRatesController.test.ts | 44 +- .../src/TokensController.test.ts | 4 +- packages/controller-utils/CHANGELOG.md | 7 + packages/controller-utils/src/types.ts | 32 + packages/controller-utils/src/util.test.ts | 4 + packages/controller-utils/src/util.ts | 9 +- .../ens-controller/src/EnsController.test.ts | 30 +- .../src/GasFeeController.test.ts | 61 +- packages/network-controller/CHANGELOG.md | 47 + packages/network-controller/jest.config.js | 8 +- packages/network-controller/package.json | 2 + .../src/NetworkController.ts | 1885 ++- packages/network-controller/src/index.ts | 40 +- .../tests/NetworkController.test.ts | 13348 ++++++++++++---- packages/network-controller/tests/helpers.ts | 279 +- .../src/QueuedRequestController.test.ts | 23 +- .../src/SelectedNetworkController.ts | 32 +- .../tests/SelectedNetworkController.test.ts | 54 +- .../src/TransactionController.test.ts | 12 +- .../TransactionControllerIntegration.test.ts | 396 +- packages/transaction-controller/tsconfig.json | 2 +- tests/helpers.ts | 47 + yarn.lock | 9 + 29 files changed, 12760 insertions(+), 3694 deletions(-) diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index b68b5e0eb2..707b7a906e 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -4,7 +4,7 @@ import type { InternalAccount } from '@metamask/keyring-api'; import { type NetworkClientId, type NetworkClientConfiguration, - defaultState as defaultnetworkControllerState, + getDefaultNetworkControllerState, } from '@metamask/network-controller'; import { getDefaultPreferencesState } from '@metamask/preferences-controller'; import * as sinon from 'sinon'; @@ -618,7 +618,7 @@ async function withController( ); const mockNetworkState = jest.fn().mockReturnValue({ - ...defaultnetworkControllerState, + ...getDefaultNetworkControllerState(), chainId: initialChainId, }); messenger.registerActionHandler( diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index 277405bbec..bcfdb6ba14 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -91,7 +91,6 @@ async function setupAssetContractControllers({ allowedActions: [], allowedEvents: [], }), - trackMetaMetricsEvent: jest.fn(), }); if (useNetworkControllerProvider) { await networkController.initializeProvider(); diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index f92d6026b4..55adcc3a3e 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -25,7 +25,7 @@ import type { NetworkClientConfiguration, NetworkClientId, } from '@metamask/network-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; import { getDefaultPreferencesState, type PreferencesState, @@ -352,7 +352,7 @@ function setupController({ selectedNetworkClientId: NetworkClientId; }) => { messenger.publish('NetworkController:networkDidChange', { - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId, }); }; diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 8d55061812..85c6588b8a 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -6,8 +6,8 @@ import { InfuraNetworkType, } from '@metamask/controller-utils'; import { + getDefaultNetworkControllerState, NetworkClientType, - defaultState as defaultNetworkState, } from '@metamask/network-controller'; import type { NetworkClient, @@ -1655,7 +1655,7 @@ async function withController( messenger.registerActionHandler( 'NetworkController:getState', jest.fn().mockReturnValue({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), ...mockNetworkState, }), ); diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 1ad1e569bf..21ae2029dd 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4,11 +4,11 @@ import { ChainId, NetworkType, convertHexToDecimal, - BUILT_IN_NETWORKS, + InfuraNetworkType, } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-api'; import type { KeyringControllerState } from '@metamask/keyring-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; import type { NetworkState, NetworkConfiguration, @@ -28,6 +28,10 @@ import * as sinon from 'sinon'; import { advanceTime } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; +import { + buildCustomRpcEndpoint, + buildInfuraNetworkConfiguration, +} from '../../network-controller/tests/helpers'; import { formatAggregatorNames } from './assetsUtil'; import { TOKEN_END_POINT_API } from './token-service'; import type { @@ -110,22 +114,24 @@ const sampleTokenB = { }; const mockNetworkConfigurations: Record = { - [NetworkType.mainnet]: { - ...BUILT_IN_NETWORKS[NetworkType.mainnet], - rpcUrl: 'https://mainnet.infura.io/v3/fakekey', - }, - [NetworkType.goerli]: { - ...BUILT_IN_NETWORKS[NetworkType.goerli], - rpcUrl: 'https://goerli.infura.io/v3/fakekey', - }, + [InfuraNetworkType.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + [InfuraNetworkType.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), polygon: { + blockExplorerUrls: ['https://polygonscan.com/'], chainId: '0x89', - nickname: 'Polygon Mainnet', - rpcUrl: `https://polygon-mainnet.infura.io/v3/fakekey`, - ticker: 'MATIC', - rpcPrefs: { - blockExplorerUrl: 'https://polygonscan.com/', - }, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://polygon-mainnet.infura.io/v3/fakekey', + }), + ], }, }; @@ -306,7 +312,7 @@ describe('TokenDetectionController', () => { }, async ({ controller, mockNetworkState }) => { mockNetworkState({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: NetworkType.goerli, }); await controller.start(); @@ -393,7 +399,7 @@ describe('TokenDetectionController', () => { callActionSpy, }) => { mockNetworkState({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'polygon', }); mockGetNetworkClientById( @@ -1400,7 +1406,7 @@ describe('TokenDetectionController', () => { }); triggerNetworkDidChange({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'polygon', }); await advanceTime({ clock, duration: 1 }); @@ -1461,7 +1467,7 @@ describe('TokenDetectionController', () => { }); triggerNetworkDidChange({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'goerli', }); await advanceTime({ clock, duration: 1 }); @@ -1512,7 +1518,7 @@ describe('TokenDetectionController', () => { }); triggerNetworkDidChange({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'mainnet', }); await advanceTime({ clock, duration: 1 }); @@ -1565,7 +1571,7 @@ describe('TokenDetectionController', () => { }); triggerNetworkDidChange({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'polygon', }); await advanceTime({ clock, duration: 1 }); @@ -1619,7 +1625,7 @@ describe('TokenDetectionController', () => { }); triggerNetworkDidChange({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'polygon', }); await advanceTime({ clock, duration: 1 }); @@ -1955,7 +1961,7 @@ describe('TokenDetectionController', () => { callActionSpy, }) => { mockNetworkState({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: NetworkType.goerli, }); triggerPreferencesStateChange({ @@ -2372,7 +2378,7 @@ async function withController( const mockNetworkState = jest.fn(); controllerMessenger.registerActionHandler( 'NetworkController:getState', - mockNetworkState.mockReturnValue({ ...defaultNetworkState }), + mockNetworkState.mockReturnValue({ ...getDefaultNetworkControllerState() }), ); const mockTokensState = jest.fn(); controllerMessenger.registerActionHandler( diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index d6f04de76d..cb02140a80 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -658,7 +658,7 @@ describe('TokenListController', () => { ); onNetworkStateChangeCallback({ selectedNetworkClientId, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, networksMetadata: {}, // @ts-expect-error This property isn't used and will get removed later. providerConfig: {}, @@ -996,7 +996,7 @@ describe('TokenListController', () => { 'NetworkController:stateChange', { selectedNetworkClientId: InfuraNetworkType.goerli, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, networksMetadata: {}, // @ts-expect-error This property isn't used and will get removed later. providerConfig: {}, @@ -1017,7 +1017,7 @@ describe('TokenListController', () => { 'NetworkController:stateChange', { selectedNetworkClientId: selectedCustomNetworkClientId, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, networksMetadata: {}, // @ts-expect-error This property isn't used and will get removed later. providerConfig: {}, @@ -1097,7 +1097,7 @@ describe('TokenListController', () => { 'NetworkController:stateChange', { selectedNetworkClientId: InfuraNetworkType.mainnet, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, networksMetadata: {}, // @ts-expect-error This property isn't used and will get removed later. providerConfig: {}, @@ -1147,7 +1147,7 @@ describe('TokenListController', () => { 'NetworkController:stateChange', { selectedNetworkClientId: selectedCustomNetworkClientId, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, networksMetadata: {}, // @ts-expect-error This property isn't used and will get removed later. providerConfig: {}, diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index f30a09d042..a5678d364e 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -13,7 +13,7 @@ import type { NetworkClientId, NetworkState, } from '@metamask/network-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { add0x } from '@metamask/utils'; import assert from 'assert'; @@ -48,8 +48,6 @@ const defaultSelectedAccount = createMockInternalAccount({ }); const mockTokenAddress = '0x0000000000000000000000000000000000000010'; -const defaultSelectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - type MainControllerMessenger = ControllerMessenger< AllowedActions | AddApprovalRequest, AllowedEvents @@ -638,8 +636,8 @@ describe('TokenRatesController', () => { .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); @@ -666,8 +664,8 @@ describe('TokenRatesController', () => { .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); @@ -720,8 +718,8 @@ describe('TokenRatesController', () => { await controller.start(); jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(controller.state.marketData).toStrictEqual({}); @@ -774,8 +772,8 @@ describe('TokenRatesController', () => { await controller.start(); jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(controller.state.marketData).toStrictEqual({}); @@ -802,8 +800,8 @@ describe('TokenRatesController', () => { .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); @@ -831,8 +829,8 @@ describe('TokenRatesController', () => { .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); @@ -858,8 +856,8 @@ describe('TokenRatesController', () => { .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); @@ -911,8 +909,8 @@ describe('TokenRatesController', () => { async ({ controller, triggerNetworkStateChange }) => { jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(controller.state.marketData).toStrictEqual({}); @@ -964,8 +962,8 @@ describe('TokenRatesController', () => { async ({ controller, triggerNetworkStateChange }) => { jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); triggerNetworkStateChange({ - ...defaultNetworkState, - selectedNetworkClientId: defaultSelectedNetworkClientId, + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }); expect(controller.state.marketData).toStrictEqual({}); @@ -2327,7 +2325,7 @@ async function withController( controllerMessenger.registerActionHandler( 'NetworkController:getState', networkStateMock.mockReturnValue({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), ...mockNetworkState, }), ); @@ -2445,7 +2443,7 @@ async function callUpdateExchangeRatesMethod({ // As with many BaseControllerV1-based controllers, runtime config // modification is allowed by the API but not supported in practice. triggerNetworkStateChange({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId, }); } diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 3d30994470..a0b645d1a9 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -18,7 +18,7 @@ import type { NetworkClientConfiguration, NetworkClientId, } from '@metamask/network-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; import nock from 'nock'; import * as sinon from 'sinon'; import { v1 as uuidV1 } from 'uuid'; @@ -2441,7 +2441,7 @@ async function withController( selectedNetworkClientId: NetworkClientId; }) => { messenger.publish('NetworkController:networkDidChange', { - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId, }); }; diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 04d803fa84..a25d9b7c2d 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `BlockExplorerUrl` object and type for looking up the block explorer URL of any Infura network ([#4268](https://github.com/MetaMask/core/pull/4286)) +- Add `NetworkNickname` object and type for looking up the common nickname for any Infura network ([#4268](https://github.com/MetaMask/core/pull/4286)) +- Add `Partialize` type for making select keys in an object type optional ([#4268](https://github.com/MetaMask/core/pull/4286)) +- `toHex` now supports converting a `bigint` into a hex string ([#4268](https://github.com/MetaMask/core/pull/4286)) + ## [11.1.0] ### Added diff --git a/packages/controller-utils/src/types.ts b/packages/controller-utils/src/types.ts index ec89800f8b..b71139791b 100644 --- a/packages/controller-utils/src/types.ts +++ b/packages/controller-utils/src/types.ts @@ -95,3 +95,35 @@ export enum NetworksTicker { // eslint-disable-next-line @typescript-eslint/naming-convention rpc = '', } + +export const BlockExplorerUrl = { + [BuiltInNetworkName.Mainnet]: 'https://etherscan.io', + [BuiltInNetworkName.Goerli]: 'https://goerli.etherscan.io', + [BuiltInNetworkName.Sepolia]: 'https://sepolia.etherscan.io', + [BuiltInNetworkName.LineaGoerli]: 'https://goerli.lineascan.build', + [BuiltInNetworkName.LineaSepolia]: 'https://sepolia.lineascan.build', + [BuiltInNetworkName.LineaMainnet]: 'https://lineascan.build', +} as const satisfies Record; +export type BlockExplorerUrl = + (typeof BlockExplorerUrl)[keyof typeof BlockExplorerUrl]; + +export const NetworkNickname = { + [BuiltInNetworkName.Mainnet]: 'Mainnet', + [BuiltInNetworkName.Goerli]: 'Goerli', + [BuiltInNetworkName.Sepolia]: 'Sepolia', + [BuiltInNetworkName.LineaGoerli]: 'Linea Goerli', + [BuiltInNetworkName.LineaSepolia]: 'Linea Sepolia', + [BuiltInNetworkName.LineaMainnet]: 'Linea Mainnet', +} as const satisfies Record; +export type NetworkNickname = + (typeof NetworkNickname)[keyof typeof NetworkNickname]; + +/** + * Makes a selection of keys in a Record optional. + * + * @template Type - The Record that you want to operate on. + * @template Key - The union of keys you want to make optional. + */ +// TODO: Move to @metamask/utils +export type Partialize = Omit & + Partial>; diff --git a/packages/controller-utils/src/util.test.ts b/packages/controller-utils/src/util.test.ts index 40a3e8b366..71dd33e90d 100644 --- a/packages/controller-utils/src/util.test.ts +++ b/packages/controller-utils/src/util.test.ts @@ -74,6 +74,10 @@ describe('util', () => { expect(util.toHex(new BN(4919))).toBe('0x1337'); }); + it('converts a bigint to a string prepended with "0x"', () => { + expect(util.toHex(4919n)).toBe('0x1337'); + }); + it('parses a string as a number in decimal format and converts it to a hex string prepended with "0x"', () => { expect(util.toHex('4919')).toBe('0x1337'); }); diff --git a/packages/controller-utils/src/util.ts b/packages/controller-utils/src/util.ts index 2024be2e2c..4d14f71e6f 100644 --- a/packages/controller-utils/src/util.ts +++ b/packages/controller-utils/src/util.ts @@ -209,13 +209,14 @@ export function fromHex(value: string | BN): BN { * @param value - An integer, an integer encoded as a base-10 string, or a BN. * @returns The integer encoded as a hex string. */ -export function toHex(value: number | string | BN): Hex { +export function toHex(value: number | bigint | string | BN): Hex { if (typeof value === 'string' && isStrictHexString(value)) { return value; } - const hexString = BN.isBN(value) - ? value.toString(16) - : new BN(value.toString(), 10).toString(16); + const hexString = + BN.isBN(value) || typeof value === 'bigint' + ? value.toString(16) + : new BN(value.toString(), 10).toString(16); return `0x${hexString}`; } diff --git a/packages/ens-controller/src/EnsController.test.ts b/packages/ens-controller/src/EnsController.test.ts index b29d483950..00f384ddfe 100644 --- a/packages/ens-controller/src/EnsController.test.ts +++ b/packages/ens-controller/src/EnsController.test.ts @@ -5,11 +5,11 @@ import { toHex, InfuraNetworkType, } from '@metamask/controller-utils'; -import type { - NetworkController, - NetworkState, +import { + type NetworkController, + type NetworkState, + getDefaultNetworkControllerState, } from '@metamask/network-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; import type { ExtractAvailableAction, @@ -98,7 +98,7 @@ function getRestrictedMessenger( getNetworkClientByIdMock?: NetworkController['getNetworkClientById'], ) { const mockNetworkState = jest.fn().mockReturnValue({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); @@ -213,7 +213,7 @@ describe('EnsController', () => { }, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -520,7 +520,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -543,7 +543,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', }); }, @@ -565,7 +565,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -585,7 +585,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -604,7 +604,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -626,7 +626,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -648,7 +648,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -672,7 +672,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, @@ -695,7 +695,7 @@ describe('EnsController', () => { messenger: ensControllerMessenger, onNetworkDidChange: (listener) => { listener({ - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: InfuraNetworkType.mainnet, }); }, diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index c4a29de35b..b8b0917714 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -16,6 +16,10 @@ import type { import type { Hex } from '@metamask/utils'; import * as sinon from 'sinon'; +import { + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, +} from '../../network-controller/tests/helpers'; import determineGasFeeCalculations from './determineGasFeeCalculations'; import { fetchGasEstimates, @@ -76,7 +80,6 @@ const setupNetworkController = async ({ messenger: restrictedMessenger, state, infuraProjectId: '123', - trackMetaMetricsEvent: jest.fn(), }); if (initializeProvider) { @@ -338,13 +341,15 @@ describe('GasFeeController', () => { legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - networkConfigurations: { - 'AAAA-BBBB-CCCC-DDDD': { - id: 'AAAA-BBBB-CCCC-DDDD', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', - }, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, @@ -404,13 +409,15 @@ describe('GasFeeController', () => { legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - networkConfigurations: { - 'AAAA-BBBB-CCCC-DDDD': { - id: 'AAAA-BBBB-CCCC-DDDD', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', - }, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, @@ -759,13 +766,15 @@ describe('GasFeeController', () => { legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - networkConfigurations: { - 'AAAA-BBBB-CCCC-DDDD': { - id: 'AAAA-BBBB-CCCC-DDDD', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', - }, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, @@ -917,13 +926,15 @@ describe('GasFeeController', () => { legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - networkConfigurations: { - 'AAAA-BBBB-CCCC-DDDD': { - id: 'AAAA-BBBB-CCCC-DDDD', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', - }, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index de6938fea3..cfbcd87e9b 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Add `networkConfigurationsByChainId` to `NetworkState` (type: `Record`) ([#4268](https://github.com/MetaMask/core/pull/4286)) + - This property replaces `networkConfigurations`, and, as its name implies, organizes network configurations by chain ID rather than network client ID. + - If no initial state or this property is not included in initial state, the default value of this property will now include configurations for known Infura networks (Mainnet, Goerli, Sepolia, Linea Goerli, Linea Sepolia, and Linea Mainnet) by default. +- Add `getNetworkConfigurationByChainId` method, `NetworkController:getNetworkConfigurationByChainId` messenger action, and `NetworkControllerGetNetworkConfigurationByNetworkClientId` type ([#4268](https://github.com/MetaMask/core/pull/4286)) +- Add `addNetwork`, which replaces one half of `upsertNetworkConfiguration` and can be used to add new network clients for a chain ([#4268](https://github.com/MetaMask/core/pull/4286)) + - It's worth noting that this method now publishes a `NetworkController:networkAdded` event instead of calling a `trackMetaMetricsEvent` callback. It is expected that you will subscribe to this event and create a MetaMetrics event yourself. +- Add `updateNetwork`, which replaces one half of `upsertNetworkConfiguration` and can be used to recreate the network clients for an existing chain based on an updated configuration ([#4268](https://github.com/MetaMask/core/pull/4286)) + - Note that it is not possible to remove the RPC endpoint from a network configuration that is currently represented by the globally selected network client. To prevent an error, you'll need to detect when such a removal is occurring and pass the `replacementSelectedRpcEndpointIndex` to `updateNetwork`. It will then switch to the designated RPC endpoint's network client on your behalf. +- Add `removeNetwork`, which replaces `removeNetworkConfiguration` and can be used to remove existing network clients for a chain ([#4268](https://github.com/MetaMask/core/pull/4286)) +- Add `getDefaultNetworkControllerState` function, which replaces `defaultState` and matches patterns in other controllers ([#4268](https://github.com/MetaMask/core/pull/4286)) +- Add `RpcEndpointType`, `AddNetworkFields`, and `UpdateNetworkFields` types ([#4268](https://github.com/MetaMask/core/pull/4286)) +- Add `getNetworkConfigurations`, `getAvailableNetworkClientIds` and `selectAvailableNetworkClientIds` selectors ([#4268](https://github.com/MetaMask/core/pull/4286)) + - These new selectors can be applied to messenger event subscriptions + +### Changed + +- **BREAKING:** Replace `NetworkConfiguration` type with a new definition ([#4268](https://github.com/MetaMask/core/pull/4286)) + - A network configuration no longer represents a single RPC endpoint but rather a collection of RPC endpoints that can all be used to interface with a single chain. + - The only property that has brought over to this type unchanged is `chainId`. + - `ticker` has been renamed to `nativeCurrency`. + - `nickname` has been renamed to `name`. + - `rpcEndpoints` has been added. This is an an array of objects, where each object has properties `name` (optional), `networkClientId` (optional), `type`, and `url`. + - `defaultRpcEndpointIndex` has been added. This must point to an entry in `rpcEndpoints`. + - The block explorer URL is no longer located in `rpcPrefs` and is no longer restricted to one: `blockExplorerUrls` has been added along with a corresponding property `defaultBlockExplorerUrlIndex`, which must point to an entry in `blockExplorerUrls`. + - `id` has been removed. Previously, this represented the ID of the network client associated with the network configuration. Since network clients are now created from RPC endpoints, the equivalent to this is the `networkClientId` property on an `RpcEndpoint`. +- **BREAKING:** The network controller messenger must now allow the action `NetworkController:getNetworkConfigurationByChainId` ([#4268](https://github.com/MetaMask/core/pull/4286)) +- **BREAKING:** The network controller messenger must now allow the event `NetworkController:networkAdded` ([#4268](https://github.com/MetaMask/core/pull/4286)) +- **BREAKING:** The `NetworkController` constructor will now throw if the initial state provided is invalid ([#4268](https://github.com/MetaMask/core/pull/4286)) + - `networkConfigurationsByChainId` cannot be empty. + - The `chainId` of a network configuration in `networkConfigurationsByChainId` must match the chain ID it is filed under. + - The `defaultRpcEndpointIndex` of a network configuration in `networkConfigurationsByChainId` must point to an entry in its `rpcEndpoints`. + - The `defaultBlockExplorerUrlIndex` of a network configuration in `networkConfigurationsByChainId` must point to an entry in its `blockExplorerUrls`. + - `selectedNetworkClientId` must match the `networkClientId` of an RPC endpoint in `networkConfigurationsByChainId`. +- **BREAKING:** Update `getNetworkConfigurationByNetworkClientId` so that when given an Infura network name (that is, a value from `InfuraNetworkType`), it will return a masked version of the RPC endpoint URL for the associated Infura network ([#4268](https://github.com/MetaMask/core/pull/4286)) + - If you want the unmasked version, you'll need the `url` property from the network _client_ configuration, which you can get by calling `getNetworkClientById` and then accessing the `configuration` property off of the network client. +- **BREAKING:** Update `loadBackup` to take and update `networkConfigurationsByChainId` instead of `networkConfigurations` ([#4268](https://github.com/MetaMask/core/pull/4286)) + +### Removed + +- **BREAKING:** Remove `networkConfigurations` from `NetworkState`, which has been replaced with `networkConfigurationsByChainId` ([#4268](https://github.com/MetaMask/core/pull/4286)) +- **BREAKING:** Remove `upsertNetworkConfiguration` and `removeNetworkConfiguration`, which have been replaced with `addNetwork`, `updateNetwork`, and `removeNetwork` ([#4268](https://github.com/MetaMask/core/pull/4286)) +- **BREAKING:** Remove `defaultState` variable, which has been replaced with a `getDefaultNetworkControllerState` function ([#4268](https://github.com/MetaMask/core/pull/4286)) +- **BREAKING:** Remove `trackMetaMetricsEvent` option from the NetworkController constructor ([#4268](https://github.com/MetaMask/core/pull/4286)) + - Previously, this was used in `upsertNetworkConfiguration` to create a MetaMetrics event when a new network was added. This can now be achieved by subscribing to the `NetworkController:networkAdded` event and creating the event inside of the event handler. + ## [20.2.0] ### Changed diff --git a/packages/network-controller/jest.config.js b/packages/network-controller/jest.config.js index 49230ba055..621e01529f 100644 --- a/packages/network-controller/jest.config.js +++ b/packages/network-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 76.57, - functions: 94.93, - lines: 91.78, - statements: 91.36, + branches: 88.47, + functions: 97.43, + lines: 94.47, + statements: 94.29, }, }, diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index debd2631ca..ecf434eeb9 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -56,6 +56,8 @@ "async-mutex": "^0.5.0", "immer": "^9.0.6", "loglevel": "^1.8.1", + "reselect": "^5.1.1", + "uri-js": "^4.4.1", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 871ee53ecf..6b42d86d27 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -4,26 +4,29 @@ import type { RestrictedControllerMessenger, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { Partialize } from '@metamask/controller-utils'; import { - BUILT_IN_NETWORKS, InfuraNetworkType, NetworkType, isSafeChainId, isInfuraNetworkType, + ChainId, + NetworksTicker, + NetworkNickname, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; import { errorCodes } from '@metamask/rpc-errors'; import { createEventEmitterProxy } from '@metamask/swappable-obj-proxy'; import type { SwappableProxy } from '@metamask/swappable-obj-proxy'; import type { Hex } from '@metamask/utils'; -import { - assertIsStrictHexString, - hasProperty, - isPlainObject, -} from '@metamask/utils'; +import { isStrictHexString, hasProperty, isPlainObject } from '@metamask/utils'; import { strict as assert } from 'assert'; +import type { Draft } from 'immer'; import type { Logger } from 'loglevel'; -import { v4 as random } from 'uuid'; +import { createSelector } from 'reselect'; +import * as URI from 'uri-js'; +import { inspect } from 'util'; +import { v4 as uuidV4 } from 'uuid'; import { INFURA_BLOCKED_KEY, NetworkStatus } from './constants'; import type { @@ -43,6 +46,9 @@ import type { const debugLog = createModuleLogger(projectLogger, 'NetworkController'); +const INFURA_URL_REGEX = + /^https:\/\/(?[^.]+)\.infura\.io\/v\d+\/(?.+)$/u; + export type Block = { baseFeePerGas?: string; }; @@ -66,32 +72,188 @@ export type NetworkMetadata = { }; /** - * Custom RPC network information + * The type of an RPC endpoint. + * + * @see {@link CustomRpcEndpoint} + * @see {@link InfuraRpcEndpoint} + */ +export enum RpcEndpointType { + Custom = 'custom', + Infura = 'infura', +} + +/** + * An Infura RPC endpoint is a reference to a specific network that Infura + * supports as well as an Infura account we own that we allow users to make use + * of for free. We need to disambiguate these endpoints from custom RPC + * endpoints, because while the types for these kinds of object both have the + * same interface, the URL for an Infura endpoint contains the Infura project + * ID, and we don't want this to be present in state. We therefore hide it by + * representing it in the URL as `{infuraProjectId}`, which we replace this when + * create network clients. But we need to know somehow that we only need to do + * this replacement for Infura endpoints and not custom endpoints — hence the + * separate type. + */ +export type InfuraRpcEndpoint = { + /** + * The optional user-facing nickname of the endpoint. + */ + name?: string; + /** + * The identifier for the network client that has been created for this RPC + * endpoint. This is also used to uniquely identify the RPC endpoint in a + * set of RPC endpoints as well: once assigned, it is used to determine + * whether the `name`, `type`, or `url` of the RPC endpoint has changed. + */ + networkClientId: BuiltInNetworkClientId; + /** + * The type of this endpoint, always "default". + */ + type: RpcEndpointType.Infura; + /** + * The URL of the endpoint. Expected to be a template with the string + * `{infuraProjectId}`, which will get replaced with the Infura project ID + * when the network client is created. + */ + url: `https://${InfuraNetworkType}.infura.io/v3/{infuraProjectId}`; +}; + +/** + * A custom RPC endpoint is a reference to a user-defined server which fronts an + * EVM chain. It may refer to an Infura network, but only by coincidence. + */ +export type CustomRpcEndpoint = { + /** + * The optional user-facing nickname of the endpoint. + */ + name?: string; + /** + * The identifier for the network client that has been created for this RPC + * endpoint. This is also used to uniquely identify the RPC endpoint in a + * set of RPC endpoints as well: once assigned, it is used to determine + * whether the `name`, `type`, or `url` of the RPC endpoint has changed. + */ + networkClientId: CustomNetworkClientId; + /** + * The type of this endpoint, always "custom". + */ + type: RpcEndpointType.Custom; + /** + * The URL of the endpoint. + */ + url: string; +}; + +/** + * An RPC endpoint is a reference to a server which fronts an EVM chain. There + * are two varieties of RPC endpoints: Infura and custom. + * + * @see {@link CustomRpcEndpoint} + * @see {@link InfuraRpcEndpoint} + */ +export type RpcEndpoint = InfuraRpcEndpoint | CustomRpcEndpoint; + +/** + * From a user perspective, a network configuration holds information about a + * network that a user can select through the client. A "network" in this sense + * can explicitly refer to an EVM chain that the user explicitly adds or doesn't + * need to add (because it comes shipped with the client). The properties here + * therefore directly map to fields that a user sees and can edit for a network + * within the client. * - * @property rpcUrl - RPC target URL. - * @property chainId - Network ID as per EIP-155 - * @property nickname - Personalized network name. - * @property ticker - Currency ticker. - * @property rpcPrefs - Personalized preferences. + * Internally, a network configuration represents a single conceptual EVM chain, + * which is represented tangibly via multiple RPC endpoints. A "network" is then + * something for which a network client object is created automatically or + * created on demand when it is added to the client. */ export type NetworkConfiguration = { - rpcUrl: string; + /** + * A set of URLs that allows the user to view activity that has occurred on + * the chain. + */ + blockExplorerUrls: string[]; + /** + * The ID of the chain. Represented in hexadecimal format with a leading "0x" + * instead of decimal format so that when viewed out of context it can be + * unambiguously interpreted. + */ chainId: Hex; - ticker: string; - nickname?: string; - rpcPrefs?: { - blockExplorerUrl: string; - }; + /** + * A reference to a URL that the client will use by default to allow the user + * to view activity that has occurred on the chain. This index must refer to + * an item in `blockExplorerUrls`. + */ + defaultBlockExplorerUrlIndex?: number; + /** + * A reference to an RPC endpoint that all requests will use by default in order to + * interact with the chain. This index must refer to an item in + * `rpcEndpoints`. + */ + defaultRpcEndpointIndex: number; + /** + * The user-facing nickname assigned to the chain. + */ + name: string; + /** + * The name of the currency to use for the chain. + */ + nativeCurrency: string; + /** + * The collection of possible RPC endpoints that the client can use to + * interact with the chain. + */ + rpcEndpoints: RpcEndpoint[]; +}; + +/** + * A custom RPC endpoint in a new network configuration, meant to be used in + * conjunction with `AddNetworkFields`. + * + * Custom RPC endpoints do not need a `networkClientId` property because it is + * assumed that they have not already been added and therefore network clients + * do not exist for them yet (and hence IDs need to be generated). + */ +export type AddNetworkCustomRpcEndpointFields = Omit< + CustomRpcEndpoint, + 'networkClientId' +>; + +/** + * A new network configuration that `addNetwork` takes. + * + * Custom RPC endpoints do not need a `networkClientId` property because it is + * assumed that they have not already been added and are not represented by + * network clients yet. + */ +export type AddNetworkFields = Omit & { + rpcEndpoints: (InfuraRpcEndpoint | AddNetworkCustomRpcEndpointFields)[]; }; /** - * The collection of network configurations in state. + * A custom RPC endpoint in an updated representation of a network + * configuration, meant to be used in conjunction with `UpdateNetworkFields`. + * + * Custom RPC endpoints do not need a `networkClientId` property because it is + * assumed that they have not already been added and therefore network clients + * do not exist for them yet (and hence IDs need to be generated). */ -type NetworkConfigurations = Record< - NetworkConfigurationId, - NetworkConfiguration & { id: NetworkConfigurationId } +export type UpdateNetworkCustomRpcEndpointFields = Partialize< + CustomRpcEndpoint, + 'networkClientId' >; +/** + * An updated representation of an existing network configuration that + * `updateNetwork` takes. + * + * Custom RPC endpoints may or may not have a `networkClientId` property; if + * they do, then it is assumed that they already exist, and if not, then it is + * assumed that they are new and are not represented by network clients yet. + */ +export type UpdateNetworkFields = Omit & { + rpcEndpoints: (InfuraRpcEndpoint | UpdateNetworkCustomRpcEndpointFields)[]; +}; + /** * `Object.keys()` is intentionally generic: it returns the keys of an object, * but it cannot make guarantees about the contents of that object, so the type @@ -115,53 +277,6 @@ export function knownKeysOf( return Object.keys(object) as K[]; } -/** - * Asserts that the given value is of the given type if the given validation - * function returns a truthy result. - * - * @param value - The value to validate. - * @param validate - A function used to validate that the value is of the given - * type. Takes the `value` as an argument and is expected to return true or - * false. - * @param message - The message to throw if the function does not return a - * truthy result. - * @throws if the function does not return a truthy result. - */ -function assertOfType( - value: unknown, - validate: (value: unknown) => boolean, - message: string, -): asserts value is Type { - assert.ok(validate(value), message); -} - -/** - * Returns a portion of the given object with only the given keys. - * - * @param object - An object. - * @param keys - The keys to pick from the object. - * @returns the portion of the object. - */ -// TODO: Replace `any` with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function pick, Keys extends keyof Obj>( - object: Obj, - keys: Keys[], -): Pick { - const pickedObject = keys.reduce>>( - (finalObject, key) => { - return { ...finalObject, [key]: object[key] }; - }, - {}, - ); - assertOfType>( - pickedObject, - () => keys.every((key) => key in pickedObject), - 'The reduce did not produce an object with all of the desired keys.', - ); - return pickedObject; -} - /** * Type guard for determining whether the given value is an error object with a * `code` property, such as an instance of Error. @@ -191,26 +306,35 @@ export type CustomNetworkClientId = string; export type NetworkClientId = BuiltInNetworkClientId | CustomNetworkClientId; /** - * Information about networks not held by any other part of state. + * Extra information about each network, such as whether it is accessible or + * blocked and whether it supports EIP-1559, keyed by network client ID. */ -export type NetworksMetadata = { - [networkClientId: NetworkClientId]: NetworkMetadata; -}; +export type NetworksMetadata = Record; /** - * @type NetworkState - * - * Network controller state - * @property properties - an additional set of network properties for the currently connected network - * @property networkConfigurations - the full list of configured networks either preloaded or added by the user. + * The state that NetworkController stores. */ export type NetworkState = { + /** + * The ID of the network client that the proxies returned by + * `getSelectedNetworkClient` currently point to. + */ selectedNetworkClientId: NetworkClientId; - networkConfigurations: NetworkConfigurations; + /** + * The registry of networks and corresponding RPC endpoints that the + * controller can use to make requests for various chains. + * + * @see {@link NetworkConfiguration} + */ + networkConfigurationsByChainId: Record; + /** + * Extra information about each network, such as whether it is accessible or + * blocked and whether it supports EIP-1559, keyed by network client ID. + */ networksMetadata: NetworksMetadata; }; -const name = 'NetworkController'; +const controllerName = 'NetworkController'; /** * Represents the block tracker for the currently selected network. (Note that @@ -233,7 +357,7 @@ export type BlockTrackerProxy = SwappableProxy< export type ProviderProxy = SwappableProxy>; export type NetworkControllerStateChangeEvent = ControllerStateChangeEvent< - typeof name, + typeof controllerName, NetworkState >; @@ -276,15 +400,25 @@ export type NetworkControllerInfuraIsUnblockedEvent = { payload: []; }; +/** + * `networkAdded` is published after a network configuration is added to the + * network configuration registry and network clients are created for it. + */ +export type NetworkControllerNetworkAddedEvent = { + type: 'NetworkController:networkAdded'; + payload: [networkConfiguration: NetworkConfiguration]; +}; + export type NetworkControllerEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkWillChangeEvent | NetworkControllerNetworkDidChangeEvent | NetworkControllerInfuraIsBlockedEvent - | NetworkControllerInfuraIsUnblockedEvent; + | NetworkControllerInfuraIsUnblockedEvent + | NetworkControllerNetworkAddedEvent; export type NetworkControllerGetStateAction = ControllerGetStateAction< - typeof name, + typeof controllerName, NetworkState >; @@ -329,6 +463,11 @@ export type NetworkControllerSetActiveNetworkAction = { handler: NetworkController['setActiveNetwork']; }; +export type NetworkControllerGetNetworkConfigurationByChainId = { + type: `NetworkController:getNetworkConfigurationByChainId`; + handler: NetworkController['getNetworkConfigurationByChainId']; +}; + export type NetworkControllerGetNetworkConfigurationByNetworkClientId = { type: `NetworkController:getNetworkConfigurationByNetworkClientId`; handler: NetworkController['getNetworkConfigurationByNetworkClientId']; @@ -343,10 +482,11 @@ export type NetworkControllerActions = | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerSetActiveNetworkAction | NetworkControllerSetProviderTypeAction + | NetworkControllerGetNetworkConfigurationByChainId | NetworkControllerGetNetworkConfigurationByNetworkClientId; export type NetworkControllerMessenger = RestrictedControllerMessenger< - typeof name, + typeof controllerName, NetworkControllerActions, NetworkControllerEvents, never, @@ -355,37 +495,104 @@ export type NetworkControllerMessenger = RestrictedControllerMessenger< export type NetworkControllerOptions = { messenger: NetworkControllerMessenger; - trackMetaMetricsEvent: () => void; infuraProjectId: string; state?: Partial; log?: Logger; }; -export const defaultState: NetworkState = { - selectedNetworkClientId: NetworkType.mainnet, - networksMetadata: {}, - networkConfigurations: {}, -}; +/** + * Constructs a value for the state property `networkConfigurationsByChainId` + * which will be used if it has not been provided to the constructor. + * + * @returns The default value for `networkConfigurationsByChainId`. + */ +function getDefaultNetworkConfigurationsByChainId(): Record< + Hex, + NetworkConfiguration +> { + return Object.values(InfuraNetworkType).reduce< + Record + >((obj, infuraNetworkType) => { + const chainId = ChainId[infuraNetworkType]; + const rpcEndpointUrl = + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const; + + const networkConfiguration: NetworkConfiguration = { + blockExplorerUrls: [], + chainId, + defaultRpcEndpointIndex: 0, + name: NetworkNickname[infuraNetworkType], + nativeCurrency: NetworksTicker[infuraNetworkType], + rpcEndpoints: [ + { + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura, + url: rpcEndpointUrl, + }, + ], + }; -type MetaMetricsEventPayload = { - event: string; - category: string; - referrer?: { url: string }; - actionId?: number; - environmentType?: string; - properties?: unknown; - sensitiveProperties?: unknown; - revenue?: number; - currency?: string; - value?: number; -}; + return { ...obj, [chainId]: networkConfiguration }; + }, {}); +} + +/** + * Constructs properties for the NetworkController state whose values will be + * used if not provided to the constructor. + * + * @returns The default NetworkController state. + */ +export function getDefaultNetworkControllerState(): NetworkState { + const networksMetadata = {}; + const networkConfigurationsByChainId = + getDefaultNetworkConfigurationsByChainId(); + + return { + selectedNetworkClientId: InfuraNetworkType.mainnet, + networksMetadata, + networkConfigurationsByChainId, + }; +} + +/** + * Get a list of all network configurations. + * + * @param state - NetworkController state + * @returns A list of all available network configurations + */ +export function getNetworkConfigurations( + state: NetworkState, +): NetworkConfiguration[] { + return Object.values(state.networkConfigurationsByChainId); +} + +/** + * Get a list of all available client IDs from a list of + * network configurations + * @param networkConfigurations - The array of network configurations + * @returns A list of all available client IDs + */ +export function getAvailableNetworkClientIds( + networkConfigurations: NetworkConfiguration[], +): string[] { + return networkConfigurations.flatMap((networkConfiguration) => + networkConfiguration.rpcEndpoints.map( + (rpcEndpoint) => rpcEndpoint.networkClientId, + ), + ); +} -type NetworkConfigurationId = string; +export const selectAvailableNetworkClientIds = createSelector( + [getNetworkConfigurations], + getAvailableNetworkClientIds, +); /** * The collection of auto-managed network clients that map to Infura networks. */ -type AutoManagedBuiltInNetworkClientRegistry = Record< +export type AutoManagedBuiltInNetworkClientRegistry = Record< BuiltInNetworkClientId, AutoManagedNetworkClient >; @@ -393,7 +600,7 @@ type AutoManagedBuiltInNetworkClientRegistry = Record< /** * The collection of auto-managed network clients that map to Infura networks. */ -type AutoManagedCustomNetworkClientRegistry = Record< +export type AutoManagedCustomNetworkClientRegistry = Record< CustomNetworkClientId, AutoManagedNetworkClient >; @@ -402,16 +609,219 @@ type AutoManagedCustomNetworkClientRegistry = Record< * The collection of auto-managed network clients that map to Infura networks * as well as custom networks that users have added. */ -type AutoManagedNetworkClientRegistry = { +export type AutoManagedNetworkClientRegistry = { [NetworkClientType.Infura]: AutoManagedBuiltInNetworkClientRegistry; [NetworkClientType.Custom]: AutoManagedCustomNetworkClientRegistry; }; +/** + * Instructs `addNetwork` and `updateNetwork` to create a network client for an + * RPC endpoint. + * + * @see {@link NetworkClientOperation} + */ +type AddNetworkClientOperation = { + type: 'add'; + rpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `updateNetwork` and `removeNetwork` to remove a network client for + * an RPC endpoint. + * + * @see {@link NetworkClientOperation} + */ +type RemoveNetworkClientOperation = { + type: 'remove'; + rpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `addNetwork` and `updateNetwork` to replace the network client for + * an RPC endpoint. + * + * @see {@link NetworkClientOperation} + */ +type ReplaceNetworkClientOperation = { + type: 'replace'; + oldRpcEndpoint: RpcEndpoint; + newRpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `addNetwork` and `updateNetwork` not to do anything with an RPC + * endpoint, as far as the network client registry is concerned. + * + * @see {@link NetworkClientOperation} + */ +type NoopNetworkClientOperation = { + type: 'noop'; + rpcEndpoint: RpcEndpoint; +}; + +/* eslint-disable jsdoc/check-indentation */ +/** + * Instructs `addNetwork`, `updateNetwork`, and `removeNetwork` how to + * update the network client registry. + * + * - When `addNetwork` is called, represents a network client that should be + * created for a new RPC endpoint. + * - When `removeNetwork` is called, represents a network client that should be + * destroyed for a previously existing RPC endpoint. + * - When `updateNetwork` is called, represents either: + * - a network client that should be added for a new RPC endpoint + * - a network client that should be removed for a previously existing RPC + * endpoint + * - a network client that should be replaced for an RPC endpoint that was + * changed in a non-major way, or + * - a network client that should be unchanged for an RPC endpoint that was + * also unchanged. + */ +/* eslint-enable jsdoc/check-indentation */ +type NetworkClientOperation = + | AddNetworkClientOperation + | RemoveNetworkClientOperation + | ReplaceNetworkClientOperation + | NoopNetworkClientOperation; + +/** + * Determines whether the given URL is valid by attempting to parse it. + * + * @param url - The URL to test. + * @returns True if the URL is valid, false otherwise. + */ +function isValidUrl(url: string) { + const uri = URI.parse(url); + return ( + uri.error === undefined && (uri.scheme === 'http' || uri.scheme === 'https') + ); +} + +/** + * Given an Infura API URL, extracts the subdomain that identifies the Infura + * network. + * + * @param rpcEndpointUrl - The URL to operate on. + * @returns The Infura network name that the URL references. + * @throws if the URL is not an Infura API URL, or if an Infura network is not + * present in the URL. + */ +function deriveInfuraNetworkNameFromRpcEndpointUrl( + rpcEndpointUrl: string, +): InfuraNetworkType { + const match = INFURA_URL_REGEX.exec(rpcEndpointUrl); + + if (match?.groups) { + if (isInfuraNetworkType(match.groups.networkName)) { + return match.groups.networkName; + } + + throw new Error(`Unknown Infura network '${match.groups.networkName}'`); + } + + throw new Error('Could not derive Infura network from RPC endpoint URL'); +} + +/** + * Performs a series of checks that the given NetworkController state is + * internally consistent — that all parts of state that are supposed to match in + * fact do — so that working with the state later on doesn't cause unexpected + * errors. + * + * In the case of NetworkController, there are several parts of state that need + * to match. For instance, `defaultRpcEndpointIndex` needs to match an entry + * within `rpcEndpoints`, and `selectedNetworkClientId` needs to point to an RPC + * endpoint within a network configuration. + * + * @param state - The NetworkController state to verify. + * @throws if the state is invalid in some way. + */ +function validateNetworkControllerState(state: NetworkState) { + const networkConfigurationEntries = Object.entries( + state.networkConfigurationsByChainId, + ); + const networkClientIds = selectAvailableNetworkClientIds(state); + + if (networkConfigurationEntries.length === 0) { + throw new Error( + 'NetworkController state is invalid: `networkConfigurationsByChainId` cannot be empty', + ); + } + + for (const [chainId, networkConfiguration] of networkConfigurationEntries) { + if (chainId !== networkConfiguration.chainId) { + throw new Error( + `NetworkController state has invalid \`networkConfigurationsByChainId\`: Network configuration '${networkConfiguration.name}' is filed under '${chainId}' which does not match its \`chainId\` of '${networkConfiguration.chainId}'`, + ); + } + + const isInvalidDefaultBlockExplorerUrlIndex = + networkConfiguration.blockExplorerUrls.length > 0 + ? networkConfiguration.defaultBlockExplorerUrlIndex === undefined || + networkConfiguration.blockExplorerUrls[ + networkConfiguration.defaultBlockExplorerUrlIndex + ] === undefined + : networkConfiguration.defaultBlockExplorerUrlIndex !== undefined; + + if (isInvalidDefaultBlockExplorerUrlIndex) { + throw new Error( + `NetworkController state has invalid \`networkConfigurationsByChainId\`: Network configuration '${networkConfiguration.name}' has a \`defaultBlockExplorerUrlIndex\` that does not refer to an entry in \`blockExplorerUrls\``, + ); + } + + if ( + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ] === undefined + ) { + throw new Error( + `NetworkController state has invalid \`networkConfigurationsByChainId\`: Network configuration '${networkConfiguration.name}' has a \`defaultRpcEndpointIndex\` that does not refer to an entry in \`rpcEndpoints\``, + ); + } + } + + if ([...new Set(networkClientIds)].length < networkClientIds.length) { + throw new Error( + 'NetworkController state has invalid `networkConfigurationsByChainId`: Every RPC endpoint across all network configurations must have a unique `networkClientId`', + ); + } + + if (!networkClientIds.includes(state.selectedNetworkClientId)) { + throw new Error( + `NetworkController state is invalid: \`selectedNetworkClientId\` ${inspect( + state.selectedNetworkClientId, + )} does not refer to an RPC endpoint within a network configuration`, + ); + } +} + +/** + * Transforms a map of chain ID to network configuration to a map of network + * client ID to network configuration. + * + * @param networkConfigurationsByChainId - The network configurations, keyed by + * chain ID. + * @returns The network configurations, keyed by network client ID. + */ +function buildNetworkConfigurationsByNetworkClientId( + networkConfigurationsByChainId: Record, +): Map { + return new Map( + Object.values(networkConfigurationsByChainId).flatMap( + (networkConfiguration) => { + return networkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + return [rpcEndpoint.networkClientId, networkConfiguration]; + }); + }, + ), + ); +} + /** * Controller that creates and manages an Ethereum network provider. */ export class NetworkController extends BaseController< - typeof name, + typeof controllerName, NetworkState, NetworkControllerMessenger > { @@ -419,8 +829,6 @@ export class NetworkController extends BaseController< #infuraProjectId: string; - #trackMetaMetricsEvent: (event: MetaMetricsEventPayload) => void; - #previouslySelectedNetworkClientId: string; #providerProxy: ProviderProxy | undefined; @@ -435,15 +843,25 @@ export class NetworkController extends BaseController< #log: Logger | undefined; + #networkConfigurationsByNetworkClientId: Map< + NetworkClientId, + NetworkConfiguration + >; + constructor({ messenger, state, infuraProjectId, - trackMetaMetricsEvent, log, }: NetworkControllerOptions) { + const initialState = { ...getDefaultNetworkControllerState(), ...state }; + validateNetworkControllerState(initialState); + if (!infuraProjectId || typeof infuraProjectId !== 'string') { + throw new Error('Invalid Infura project ID'); + } + super({ - name, + name: controllerName, metadata: { selectedNetworkClientId: { persist: true, @@ -453,19 +871,24 @@ export class NetworkController extends BaseController< persist: true, anonymous: false, }, - networkConfigurations: { + networkConfigurationsByChainId: { persist: true, anonymous: false, }, }, messenger, - state: { ...defaultState, ...state }, + state: initialState, }); - if (!infuraProjectId || typeof infuraProjectId !== 'string') { - throw new Error('Invalid Infura project ID'); - } + this.#infuraProjectId = infuraProjectId; - this.#trackMetaMetricsEvent = trackMetaMetricsEvent; + this.#log = log; + + this.#previouslySelectedNetworkClientId = + this.state.selectedNetworkClientId; + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + this.state.networkConfigurationsByChainId, + ); this.messagingSystem.registerActionHandler( // TODO: Either fix this lint violation or explain why it's necessary to ignore. @@ -514,6 +937,13 @@ export class NetworkController extends BaseController< this.messagingSystem.registerActionHandler( // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${this.name}:getNetworkConfigurationByChainId`, + this.getNetworkConfigurationByChainId.bind(this), + ); + + this.messagingSystem.registerActionHandler( + // ESLint is mistaken here; `name` is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `${this.name}:getNetworkConfigurationByNetworkClientId`, this.getNetworkConfigurationByNetworkClientId.bind(this), ); @@ -524,11 +954,6 @@ export class NetworkController extends BaseController< `${this.name}:getSelectedNetworkClient`, this.getSelectedNetworkClient.bind(this), ); - - this.#previouslySelectedNetworkClientId = - this.state.selectedNetworkClientId; - - this.#log = log; } /** @@ -569,12 +994,13 @@ export class NetworkController extends BaseController< } /** - * Returns all of the network clients that have been created so far, keyed by - * their identifier in the network client registry. This collection represents - * not only built-in networks but also any custom networks that consumers have - * added. + * Internally, the Infura and custom network clients are categorized by type + * so that when accessing either kind of network client, TypeScript knows + * which type to assign to the network client. For some cases it's more useful + * to be able to access network clients by ID instead of by type and then ID, + * so this function makes that possible. * - * @returns The list of known network clients. + * @returns The network clients registered so far, keyed by ID. */ getNetworkClientRegistry(): AutoManagedBuiltInNetworkClientRegistry & AutoManagedCustomNetworkClientRegistry { @@ -666,13 +1092,20 @@ export class NetworkController extends BaseController< * @param networkClientId - The ID of a network client that requests will be * routed through (either the name of an Infura network or the ID of a custom * network configuration). + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. */ - async #refreshNetwork(networkClientId: string) { + async #refreshNetwork( + networkClientId: string, + options: { + updateState?: (state: Draft) => void; + } = {}, + ) { this.messagingSystem.publish( 'NetworkController:networkWillChange', this.state, ); - this.#applyNetworkSelection(networkClientId); + this.#applyNetworkSelection(networkClientId, options); this.messagingSystem.publish( 'NetworkController:networkDidChange', this.state, @@ -681,8 +1114,9 @@ export class NetworkController extends BaseController< } /** - * Creates network clients for built-in and custom networks, then establishes - * the currently selected network client based on state. + * Ensures that network clients for Infura and custom RPC endpoints have been + * created. Then, consulting state, initializes and establishes the currently + * selected network client. */ async initializeProvider() { this.#applyNetworkSelection(this.state.selectedNetworkClientId); @@ -940,17 +1374,23 @@ export class NetworkController extends BaseController< /** * Changes the selected network. * - * @param networkClientId - The ID of a network client that requests will be - * routed through (either the name of an Infura network or the ID of a custom - * network configuration). + * @param networkClientId - The ID of a network client that will be used to + * make requests. + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. * @throws if no network client is associated with the given - * `networkClientId`. + * network client ID. */ - async setActiveNetwork(networkClientId: string) { + async setActiveNetwork( + networkClientId: string, + options: { + updateState?: (state: Draft) => void; + } = {}, + ) { this.#previouslySelectedNetworkClientId = this.state.selectedNetworkClientId; - await this.#refreshNetwork(networkClientId); + await this.#refreshNetwork(networkClientId, options); } /** @@ -1063,238 +1503,442 @@ export class NetworkController extends BaseController< } /** - * Returns a configuration object for the network identified by the given - * network client ID. If given an Infura network type, constructs one based on - * what we know about the network; otherwise attempts locates a network - * configuration in state that corresponds to the network client ID. + * Returns the network configuration that has been filed under the given chain + * ID. + * + * @param chainId - The chain ID to use as a key. + * @returns The network configuration if one exists, or undefined. + */ + getNetworkConfigurationByChainId( + chainId: Hex, + ): NetworkConfiguration | undefined { + return this.state.networkConfigurationsByChainId[chainId]; + } + + /** + * Returns the network configuration that contains an RPC endpoint with the + * given network client ID. * - * @param networkClientId - The network client ID. - * @returns The configuration for the referenced network if one exists, or - * undefined otherwise. + * @param networkClientId - The network client ID to use as a key. + * @returns The network configuration if one exists, or undefined. */ getNetworkConfigurationByNetworkClientId( networkClientId: NetworkClientId, ): NetworkConfiguration | undefined { - if (isInfuraNetworkType(networkClientId)) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const rpcUrl = `https://${networkClientId}.infura.io/v3/${ - this.#infuraProjectId - }`; - return { - rpcUrl, - ...BUILT_IN_NETWORKS[networkClientId], - }; - } - - return this.state.networkConfigurations[networkClientId]; + return this.#networkConfigurationsByNetworkClientId.get(networkClientId); } /** - * Adds a new custom network or updates the information for an existing - * network. + * Creates and registers network clients for the collection of Infura and + * custom RPC endpoints that can be used to make requests for a particular + * chain, storing the given configuration object in state for later reference. * - * This may involve updating the `networkConfigurations` property in - * state as well and/or adding a new network client to the network client - * registry. The `rpcUrl` and `chainId` of the given object are used to - * determine which action to take: - * - * - If the `rpcUrl` corresponds to an existing network configuration - * (case-insensitively), then it is overwritten with the object. Furthermore, - * if the `chainId` is different from the existing network configuration, then - * the existing network client is replaced with a new one. - * - If the `rpcUrl` does not correspond to an existing network configuration - * (case-insensitively), then the object is used to add a new network - * configuration along with a new network client. - * - * @param networkConfiguration - The network configuration to add or update. - * @param options - Additional configuration options. - * @param options.referrer - Used to create a metrics event; the site from which the call originated, or 'metamask' for internal calls. - * @param options.source - Used to create a metrics event; where the event originated (i.e. from a dapp or from the network form). - * @param options.setActive - If true, switches to the network upon adding or updating it (default: false). - * @returns The ID for the added or updated network configuration. + * @param fields - The object that describes the new network/chain and lists + * the RPC endpoints which front that chain. + * @returns The newly added network configuration. + * @throws if any part of `fields` would produce invalid state. + * @see {@link NetworkConfiguration} */ - async upsertNetworkConfiguration( - networkConfiguration: NetworkConfiguration & { - id?: NetworkConfigurationId; - }, - { - referrer, - source, - setActive = false, - }: { - referrer: string; - source: string; - setActive?: boolean; - }, - ): Promise { - const sanitizedNetworkConfiguration: NetworkConfiguration & { - id?: NetworkConfigurationId; - } = pick(networkConfiguration, [ - 'rpcUrl', - 'chainId', - 'ticker', - 'nickname', - 'rpcPrefs', - 'id', - ]); - const { rpcUrl, chainId, ticker, id } = sanitizedNetworkConfiguration; - - assertIsStrictHexString(chainId); - if (!isSafeChainId(chainId)) { - throw new Error( - `Invalid chain ID "${chainId}": numerical value greater than max safe value.`, - ); - } - if (!rpcUrl) { - throw new Error( - 'An rpcUrl is required to add or update network configuration', - ); - } - if (!referrer || !source) { - throw new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ); - } - try { - new URL(rpcUrl); - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - if (e.message.includes('Invalid URL')) { - throw new Error('rpcUrl must be a valid URL'); - } - } - if (!ticker) { + addNetwork(fields: AddNetworkFields): NetworkConfiguration { + const { rpcEndpoints: setOfRpcEndpointFields } = fields; + + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + + this.#validateNetworkFields({ + mode: 'add', + networkFields: fields, + autoManagedNetworkClientRegistry, + }); + + const networkClientOperations = setOfRpcEndpointFields.map( + (defaultOrCustomRpcEndpointFields) => { + const rpcEndpoint = + defaultOrCustomRpcEndpointFields.type === RpcEndpointType.Custom + ? { + ...defaultOrCustomRpcEndpointFields, + networkClientId: uuidV4(), + } + : defaultOrCustomRpcEndpointFields; + return { + type: 'add' as const, + rpcEndpoint, + }; + }, + ); + + const newNetworkConfiguration = + this.#determineNetworkConfigurationToPersist({ + networkFields: fields, + networkClientOperations, + }); + this.#registerNetworkClientsAsNeeded({ + networkFields: fields, + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + this.update((state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'add', + networkFields: fields, + networkConfigurationToPersist: newNetworkConfiguration, + }); + }); + + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + this.state.networkConfigurationsByChainId, + ); + + this.messagingSystem.publish( + `${controllerName}:networkAdded`, + newNetworkConfiguration, + ); + + return newNetworkConfiguration; + } + + /** + * Updates the configuration for a previously stored network filed under the + * given chain ID, creating + registering new network clients to represent RPC + * endpoints that have been added and destroying + unregistering existing + * network clients for RPC endpoints that have been removed. + * + * Note that if `chainId` is changed, then all network clients associated with + * that chain will be removed and re-added, even if none of the RPC endpoints + * have changed. + * + * @param chainId - The chain ID associated with an existing network. + * @param fields - The object that describes the updates to the network/chain, + * including the new set of RPC endpoints which should front that chain. + * @param options - Options to provide. + * @param options.replacementSelectedRpcEndpointIndex - Usually you cannot + * remove an RPC endpoint that is being represented by the currently selected + * network client. This option allows you to specify another RPC endpoint + * (either an existing one or a new one) that should be used to select a new + * network instead. + * @returns The updated network configuration. + * @throws if `chainId` does not refer to an existing network configuration, + * if any part of `fields` would produce invalid state, etc. + * @see {@link NetworkConfiguration} + */ + async updateNetwork( + chainId: Hex, + fields: UpdateNetworkFields, + { + replacementSelectedRpcEndpointIndex, + }: { replacementSelectedRpcEndpointIndex?: number } = {}, + ): Promise { + const existingNetworkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + + if (existingNetworkConfiguration === undefined) { throw new Error( - 'A ticker is required to add or update networkConfiguration', + `Could not update network: Cannot find network configuration for chain ${inspect( + chainId, + )}`, ); } + const existingChainId = chainId; + const { chainId: newChainId, rpcEndpoints: setOfNewRpcEndpointFields } = + fields; + const autoManagedNetworkClientRegistry = this.#ensureAutoManagedNetworkClientRegistryPopulated(); - const existingNetworkConfigurationWithId = Object.values( - this.state.networkConfigurations, - ).find((networkConfig) => networkConfig.id === id); - if (id && !existingNetworkConfigurationWithId) { - throw new Error('No network configuration matches the provided id'); + this.#validateNetworkFields({ + mode: 'update', + networkFields: fields, + existingNetworkConfiguration, + autoManagedNetworkClientRegistry, + }); + + const networkClientOperations: NetworkClientOperation[] = []; + + for (const newRpcEndpointFields of setOfNewRpcEndpointFields) { + const existingRpcEndpointForNoop = + existingNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return ( + rpcEndpoint.type === newRpcEndpointFields.type && + rpcEndpoint.url === newRpcEndpointFields.url && + (rpcEndpoint.networkClientId === + newRpcEndpointFields.networkClientId || + newRpcEndpointFields.networkClientId === undefined) + ); + }); + const existingRpcEndpointForReplaceWhenChainChanged = + existingNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return ( + (rpcEndpoint.type === RpcEndpointType.Infura && + newRpcEndpointFields.type === RpcEndpointType.Infura) || + (rpcEndpoint.type === newRpcEndpointFields.type && + rpcEndpoint.networkClientId === + newRpcEndpointFields.networkClientId && + rpcEndpoint.url === newRpcEndpointFields.url) + ); + }); + const existingRpcEndpointForReplaceWhenChainNotChanged = + existingNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return ( + rpcEndpoint.type === newRpcEndpointFields.type && + (rpcEndpoint.url === newRpcEndpointFields.url || + rpcEndpoint.networkClientId === + newRpcEndpointFields.networkClientId) + ); + }); + + if ( + newChainId !== existingChainId && + existingRpcEndpointForReplaceWhenChainChanged !== undefined + ) { + const newRpcEndpoint = + newRpcEndpointFields.type === RpcEndpointType.Infura + ? newRpcEndpointFields + : { ...newRpcEndpointFields, networkClientId: uuidV4() }; + + networkClientOperations.push({ + type: 'replace' as const, + oldRpcEndpoint: existingRpcEndpointForReplaceWhenChainChanged, + newRpcEndpoint, + }); + } else if (existingRpcEndpointForNoop !== undefined) { + let newRpcEndpoint; + if (existingRpcEndpointForNoop.type === RpcEndpointType.Infura) { + newRpcEndpoint = existingRpcEndpointForNoop; + } else { + // `networkClientId` shouldn't be missing at this point; if it is, + // that's a mistake, so fill it back in + newRpcEndpoint = Object.assign({}, newRpcEndpointFields, { + networkClientId: existingRpcEndpointForNoop.networkClientId, + }); + } + networkClientOperations.push({ + type: 'noop' as const, + rpcEndpoint: newRpcEndpoint, + }); + } else if ( + existingRpcEndpointForReplaceWhenChainNotChanged !== undefined + ) { + let newRpcEndpoint; + /* istanbul ignore if */ + if (newRpcEndpointFields.type === RpcEndpointType.Infura) { + // This case can't actually happen. If we're here, it means that some + // part of the RPC endpoint changed. But there is no part of an Infura + // RPC endpoint that can be changed (as it would immediately make that + // RPC endpoint self-inconsistent). This is just here to appease + // TypeScript. + newRpcEndpoint = newRpcEndpointFields; + } else { + newRpcEndpoint = { + ...newRpcEndpointFields, + networkClientId: uuidV4(), + }; + } + + networkClientOperations.push({ + type: 'replace' as const, + oldRpcEndpoint: existingRpcEndpointForReplaceWhenChainNotChanged, + newRpcEndpoint, + }); + } else { + const newRpcEndpoint = + newRpcEndpointFields.type === RpcEndpointType.Infura + ? newRpcEndpointFields + : { ...newRpcEndpointFields, networkClientId: uuidV4() }; + const networkClientOperation = { + type: 'add' as const, + rpcEndpoint: newRpcEndpoint, + }; + networkClientOperations.push(networkClientOperation); + } + } + + for (const existingRpcEndpoint of existingNetworkConfiguration.rpcEndpoints) { + if ( + !networkClientOperations.some((networkClientOperation) => { + const otherRpcEndpoint = + networkClientOperation.type === 'replace' + ? networkClientOperation.oldRpcEndpoint + : networkClientOperation.rpcEndpoint; + return ( + otherRpcEndpoint.type === existingRpcEndpoint.type && + otherRpcEndpoint.networkClientId === + existingRpcEndpoint.networkClientId && + otherRpcEndpoint.url === existingRpcEndpoint.url + ); + }) + ) { + const networkClientOperation = { + type: 'remove' as const, + rpcEndpoint: existingRpcEndpoint, + }; + networkClientOperations.push(networkClientOperation); + } } - const existingNetworkConfigurationWithRpcUrl = Object.values( - this.state.networkConfigurations, - ).find( - (networkConfig) => - networkConfig.rpcUrl.toLowerCase() === rpcUrl.toLowerCase(), - ); + const updatedNetworkConfiguration = + this.#determineNetworkConfigurationToPersist({ + networkFields: fields, + networkClientOperations, + }); + if ( - id && - existingNetworkConfigurationWithRpcUrl && - existingNetworkConfigurationWithRpcUrl.id !== id + replacementSelectedRpcEndpointIndex === undefined && + networkClientOperations.some((networkClientOperation) => { + return ( + networkClientOperation.type === 'remove' && + networkClientOperation.rpcEndpoint.networkClientId === + this.state.selectedNetworkClientId + ); + }) && + !networkClientOperations.some((networkClientOperation) => { + return ( + networkClientOperation.type === 'replace' && + networkClientOperation.oldRpcEndpoint.networkClientId === + this.state.selectedNetworkClientId + ); + }) ) { throw new Error( - 'A different network configuration already exists with the provided rpcUrl', + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network: Cannot update RPC endpoints in such a way that the selected network '${this.state.selectedNetworkClientId}' would be removed without a replacement. Choose a different RPC endpoint as the selected network via the \`replacementSelectedRpcEndpointIndex\` option.`, ); } - const existingNetworkConfiguration = - existingNetworkConfigurationWithId ?? - existingNetworkConfigurationWithRpcUrl; - - const upsertedNetworkConfigurationId = existingNetworkConfiguration - ? existingNetworkConfiguration.id - : random(); - const networkClientId = upsertedNetworkConfigurationId; - - const customNetworkClientRegistry = - autoManagedNetworkClientRegistry[NetworkClientType.Custom]; - const existingAutoManagedNetworkClient = - customNetworkClientRegistry[networkClientId]; - const shouldDestroyExistingNetworkClient = - existingAutoManagedNetworkClient && - existingAutoManagedNetworkClient.configuration.chainId !== chainId; - if (shouldDestroyExistingNetworkClient) { - existingAutoManagedNetworkClient.destroy(); + this.#registerNetworkClientsAsNeeded({ + networkFields: fields, + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + + const replacementSelectedRpcEndpointWithIndex = networkClientOperations + .map( + (networkClientOperation, index) => + [networkClientOperation, index] as const, + ) + .find(([networkClientOperation, _index]) => { + return ( + networkClientOperation.type === 'replace' && + networkClientOperation.oldRpcEndpoint.networkClientId === + this.state.selectedNetworkClientId + ); + }); + const correctedReplacementSelectedRpcEndpointIndex = + replacementSelectedRpcEndpointIndex ?? + replacementSelectedRpcEndpointWithIndex?.[1]; + + let rpcEndpointToSelect: RpcEndpoint | undefined; + if (correctedReplacementSelectedRpcEndpointIndex !== undefined) { + rpcEndpointToSelect = + updatedNetworkConfiguration.rpcEndpoints[ + correctedReplacementSelectedRpcEndpointIndex + ]; + + if (rpcEndpointToSelect === undefined) { + throw new Error( + `Could not update network: \`replacementSelectedRpcEndpointIndex\` ${correctedReplacementSelectedRpcEndpointIndex} does not refer to an entry in \`rpcEndpoints\``, + ); + } } + if ( - !existingAutoManagedNetworkClient || - shouldDestroyExistingNetworkClient + rpcEndpointToSelect && + rpcEndpointToSelect.networkClientId !== this.state.selectedNetworkClientId ) { - customNetworkClientRegistry[networkClientId] = - createAutoManagedNetworkClient({ - type: NetworkClientType.Custom, - chainId, - rpcUrl, - ticker, - }); - } - - this.update((state) => { - state.networkConfigurations[upsertedNetworkConfigurationId] = { - ...sanitizedNetworkConfiguration, - id: upsertedNetworkConfigurationId, - }; - }); - - if (!existingNetworkConfiguration) { - this.#trackMetaMetricsEvent({ - event: 'Custom Network Added', - category: 'Network', - referrer: { - url: referrer, - }, - properties: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: chainId, - symbol: ticker, - source, + await this.setActiveNetwork(rpcEndpointToSelect.networkClientId, { + updateState: (state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'update', + networkFields: fields, + networkConfigurationToPersist: updatedNetworkConfiguration, + existingNetworkConfiguration, + }); }, }); + } else { + this.update((state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'update', + networkFields: fields, + networkConfigurationToPersist: updatedNetworkConfiguration, + existingNetworkConfiguration, + }); + }); } - if (setActive) { - await this.setActiveNetwork(upsertedNetworkConfigurationId); - } + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + this.state.networkConfigurationsByChainId, + ); - return upsertedNetworkConfigurationId; + this.#unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + + return updatedNetworkConfiguration; } /** - * Removes a custom network from state. + * Destroys and unregisters the network identified by the given chain ID, also + * removing the associated network configuration from state. * - * This involves updating the `networkConfigurations` property in state as - * well and removing the network client that corresponds to the network from - * the client registry. - * - * @param networkConfigurationId - The ID of an existing network - * configuration. + * @param chainId - The chain ID associated with an existing network. + * @throws if `chainId` does not refer to an existing network configuration, + * or if the currently selected network is being removed. + * @see {@link NetworkConfiguration} */ - removeNetworkConfiguration(networkConfigurationId: string) { - if (!this.state.networkConfigurations[networkConfigurationId]) { + removeNetwork(chainId: Hex) { + const existingNetworkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + + if (existingNetworkConfiguration === undefined) { throw new Error( - `networkConfigurationId ${networkConfigurationId} does not match a configured networkConfiguration`, + `Cannot find network configuration for chain ${inspect(chainId)}`, ); } - if (networkConfigurationId === this.state.selectedNetworkClientId) { - throw new Error(`The selected network configuration cannot be removed`); + if ( + existingNetworkConfiguration.rpcEndpoints.some( + (rpcEndpoint) => + rpcEndpoint.networkClientId === this.state.selectedNetworkClientId, + ) + ) { + throw new Error(`Cannot remove the currently selected network`); } const autoManagedNetworkClientRegistry = this.#ensureAutoManagedNetworkClientRegistryPopulated(); - const networkClientId = networkConfigurationId; + const networkClientOperations = + existingNetworkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + return { + type: 'remove' as const, + rpcEndpoint, + }; + }); + + this.#unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, + }); this.update((state) => { - delete state.networkConfigurations[networkConfigurationId]; + this.#updateNetworkConfigurations({ + state, + mode: 'remove', + existingNetworkConfiguration, + }); }); - const customNetworkClientRegistry = - autoManagedNetworkClientRegistry[NetworkClientType.Custom]; - const existingAutoManagedNetworkClient = - customNetworkClientRegistry[networkClientId]; - existingAutoManagedNetworkClient.destroy(); - delete customNetworkClientRegistry[networkClientId]; + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + this.state.networkConfigurationsByChainId, + ); } /** @@ -1318,20 +1962,19 @@ export class NetworkController extends BaseController< } /** - * Updates the controller using the given backup data. + * Merges the given backup data into controller state. * * @param backup - The data that has been backed up. - * @param backup.networkConfigurations - Network configurations in the backup. + * @param backup.networkConfigurationsByChainId - Network configurations, + * keyed by chain ID. */ loadBackup({ - networkConfigurations, - }: { - networkConfigurations: NetworkState['networkConfigurations']; - }): void { + networkConfigurationsByChainId, + }: Pick): void { this.update((state) => { - state.networkConfigurations = { - ...state.networkConfigurations, - ...networkConfigurations, + state.networkConfigurationsByChainId = { + ...state.networkConfigurationsByChainId, + ...networkConfigurationsByChainId, }; }); } @@ -1353,6 +1996,453 @@ export class NetworkController extends BaseController< return networkClientEntry[0]; } + /** + * Ensure that the given fields which will be used to either add or update a + * network are valid. + * + * @param args - The arguments. + */ + #validateNetworkFields( + args: { + autoManagedNetworkClientRegistry: AutoManagedNetworkClientRegistry; + } & ( + | { + mode: 'add'; + networkFields: AddNetworkFields; + } + | { + mode: 'update'; + existingNetworkConfiguration: NetworkConfiguration; + networkFields: UpdateNetworkFields; + } + ), + ) { + const { mode, networkFields, autoManagedNetworkClientRegistry } = args; + const existingNetworkConfiguration = + 'existingNetworkConfiguration' in args + ? args.existingNetworkConfiguration + : null; + + const errorMessagePrefix = + mode === 'update' ? 'Could not update network' : 'Could not add network'; + + if ( + !isStrictHexString(networkFields.chainId) || + !isSafeChainId(networkFields.chainId) + ) { + throw new Error( + `${errorMessagePrefix}: Invalid \`chainId\` ${inspect( + networkFields.chainId, + )} (must start with "0x" and not exceed the maximum)`, + ); + } + + if ( + existingNetworkConfiguration === null || + networkFields.chainId !== existingNetworkConfiguration.chainId + ) { + const existingNetworkConfigurationViaChainId = + this.state.networkConfigurationsByChainId[networkFields.chainId]; + if (existingNetworkConfigurationViaChainId !== undefined) { + if (existingNetworkConfiguration === null) { + throw new Error( + // False negative - these are strings. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not add network for chain ${args.networkFields.chainId} as another network for that chain already exists ('${existingNetworkConfigurationViaChainId.name}')`, + ); + } else { + throw new Error( + // False negative - these are strings. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot move network from chain ${existingNetworkConfiguration.chainId} to ${networkFields.chainId} as another network for that chain already exists ('${existingNetworkConfigurationViaChainId.name}')`, + ); + } + } + } + + const isInvalidDefaultBlockExplorerUrlIndex = + networkFields.blockExplorerUrls.length > 0 + ? networkFields.defaultBlockExplorerUrlIndex === undefined || + networkFields.blockExplorerUrls[ + networkFields.defaultBlockExplorerUrlIndex + ] === undefined + : networkFields.defaultBlockExplorerUrlIndex !== undefined; + + if (isInvalidDefaultBlockExplorerUrlIndex) { + throw new Error( + `${errorMessagePrefix}: \`defaultBlockExplorerUrlIndex\` must refer to an entry in \`blockExplorerUrls\``, + ); + } + + if (networkFields.rpcEndpoints.length === 0) { + throw new Error( + `${errorMessagePrefix}: \`rpcEndpoints\` must be a non-empty array`, + ); + } + for (const rpcEndpointFields of networkFields.rpcEndpoints) { + if (!isValidUrl(rpcEndpointFields.url)) { + throw new Error( + `${errorMessagePrefix}: An entry in \`rpcEndpoints\` has invalid URL ${inspect( + rpcEndpointFields.url, + )}`, + ); + } + const networkClientId = + 'networkClientId' in rpcEndpointFields + ? rpcEndpointFields.networkClientId + : undefined; + + if ( + rpcEndpointFields.type === RpcEndpointType.Custom && + networkClientId !== undefined && + isInfuraNetworkType(networkClientId) + ) { + throw new Error( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${errorMessagePrefix}: Custom RPC endpoint '${rpcEndpointFields.url}' has invalid network client ID '${networkClientId}'`, + ); + } + + if ( + mode === 'update' && + networkClientId !== undefined && + rpcEndpointFields.type === RpcEndpointType.Custom && + !Object.values(autoManagedNetworkClientRegistry).some( + (networkClientsById) => networkClientId in networkClientsById, + ) + ) { + throw new Error( + `${errorMessagePrefix}: RPC endpoint '${ + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + rpcEndpointFields.url + }' refers to network client ${inspect( + networkClientId, + )} that does not exist`, + ); + } + + if ( + networkFields.rpcEndpoints.some( + (otherRpcEndpointFields) => + otherRpcEndpointFields !== rpcEndpointFields && + URI.equal(otherRpcEndpointFields.url, rpcEndpointFields.url), + ) + ) { + throw new Error( + `${errorMessagePrefix}: Each entry in rpcEndpoints must have a unique URL`, + ); + } + + const networkConfigurationsForOtherChains = Object.values( + this.state.networkConfigurationsByChainId, + ).filter((networkConfiguration) => + existingNetworkConfiguration + ? networkConfiguration.chainId !== + existingNetworkConfiguration.chainId + : true, + ); + for (const networkConfiguration of networkConfigurationsForOtherChains) { + const rpcEndpoint = networkConfiguration.rpcEndpoints.find( + (existingRpcEndpoint) => + URI.equal(rpcEndpointFields.url, existingRpcEndpoint.url), + ); + if (rpcEndpoint) { + throw new Error( + mode === 'update' + ? // False negative - these are strings. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network to point to same RPC endpoint as existing network for chain ${networkConfiguration.chainId} ('${networkConfiguration.name}')` + : // False negative - these are strings. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not add network that points to same RPC endpoint as existing network for chain ${networkConfiguration.chainId} ('${networkConfiguration.name}')`, + ); + } + } + } + + if ( + [...new Set(networkFields.rpcEndpoints)].length < + networkFields.rpcEndpoints.length + ) { + throw new Error( + `${errorMessagePrefix}: Each entry in rpcEndpoints must be unique`, + ); + } + + const networkClientIds = networkFields.rpcEndpoints + .map((rpcEndpoint) => + 'networkClientId' in rpcEndpoint + ? rpcEndpoint.networkClientId + : undefined, + ) + .filter( + (networkClientId): networkClientId is NetworkClientId => + networkClientId !== undefined, + ); + if ([...new Set(networkClientIds)].length < networkClientIds.length) { + throw new Error( + `${errorMessagePrefix}: Each entry in rpcEndpoints must have a unique networkClientId`, + ); + } + + const infuraRpcEndpoints = networkFields.rpcEndpoints.filter( + (rpcEndpointFields): rpcEndpointFields is InfuraRpcEndpoint => + rpcEndpointFields.type === RpcEndpointType.Infura, + ); + if (infuraRpcEndpoints.length > 1) { + throw new Error( + `${errorMessagePrefix}: There cannot be more than one Infura RPC endpoint`, + ); + } + + const soleInfuraRpcEndpoint = infuraRpcEndpoints[0]; + if (soleInfuraRpcEndpoint) { + const infuraNetworkName = deriveInfuraNetworkNameFromRpcEndpointUrl( + soleInfuraRpcEndpoint.url, + ); + const infuraNetworkNickname = NetworkNickname[infuraNetworkName]; + const infuraChainId = ChainId[infuraNetworkName]; + if (networkFields.chainId !== infuraChainId) { + throw new Error( + mode === 'add' + ? // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not add network with chain ID ${networkFields.chainId} and Infura RPC endpoint for '${infuraNetworkNickname}' which represents ${infuraChainId}, as the two conflict` + : // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network with chain ID ${networkFields.chainId} and Infura RPC endpoint for '${infuraNetworkNickname}' which represents ${infuraChainId}, as the two conflict`, + ); + } + } + + if ( + networkFields.rpcEndpoints[networkFields.defaultRpcEndpointIndex] === + undefined + ) { + throw new Error( + `${errorMessagePrefix}: \`defaultRpcEndpointIndex\` must refer to an entry in \`rpcEndpoints\``, + ); + } + } + + /** + * Constructs a network configuration that will be persisted to state when + * adding or updating a network. + * + * @param args - The arguments to this function. + * @param args.networkFields - The fields used to add or update a network. + * @param args.networkClientOperations - Operations which were calculated for + * updating the network client registry but which also map back to RPC + * endpoints (and so can be used to save those RPC endpoints). + * @returns The network configuration to persist. + */ + #determineNetworkConfigurationToPersist({ + networkFields, + networkClientOperations, + }: { + networkFields: AddNetworkFields | UpdateNetworkFields; + networkClientOperations: NetworkClientOperation[]; + }): NetworkConfiguration { + const rpcEndpointsToPersist = networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is + | AddNetworkClientOperation + | NoopNetworkClientOperation => { + return ( + networkClientOperation.type === 'add' || + networkClientOperation.type === 'noop' + ); + }, + ) + .map((networkClientOperation) => networkClientOperation.rpcEndpoint) + .concat( + networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is ReplaceNetworkClientOperation => { + return networkClientOperation.type === 'replace'; + }, + ) + .map( + (networkClientOperation) => networkClientOperation.newRpcEndpoint, + ), + ); + + return { ...networkFields, rpcEndpoints: rpcEndpointsToPersist }; + } + + /** + * Creates and registers network clients using the given operations calculated + * as a part of adding or updating a network. + * + * @param args - The arguments to this function. + * @param args.networkFields - The fields used to add or update a network. + * @param args.networkClientOperations - Dictate which network clients need to + * be created. + * @param args.autoManagedNetworkClientRegistry - The network client registry + * to update. + */ + #registerNetworkClientsAsNeeded({ + networkFields, + networkClientOperations, + autoManagedNetworkClientRegistry, + }: { + networkFields: AddNetworkFields | UpdateNetworkFields; + networkClientOperations: NetworkClientOperation[]; + autoManagedNetworkClientRegistry: AutoManagedNetworkClientRegistry; + }) { + const addedRpcEndpoints = networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is AddNetworkClientOperation => { + return networkClientOperation.type === 'add'; + }, + ) + .map((networkClientOperation) => networkClientOperation.rpcEndpoint) + .concat( + networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is ReplaceNetworkClientOperation => { + return networkClientOperation.type === 'replace'; + }, + ) + .map( + (networkClientOperation) => networkClientOperation.newRpcEndpoint, + ), + ); + + for (const addedRpcEndpoint of addedRpcEndpoints) { + if (addedRpcEndpoint.type === RpcEndpointType.Infura) { + autoManagedNetworkClientRegistry[NetworkClientType.Infura][ + addedRpcEndpoint.networkClientId + ] = createAutoManagedNetworkClient({ + type: NetworkClientType.Infura, + chainId: networkFields.chainId, + network: addedRpcEndpoint.networkClientId, + infuraProjectId: this.#infuraProjectId, + ticker: networkFields.nativeCurrency, + }); + } else { + autoManagedNetworkClientRegistry[NetworkClientType.Custom][ + addedRpcEndpoint.networkClientId + ] = createAutoManagedNetworkClient({ + type: NetworkClientType.Custom, + chainId: networkFields.chainId, + rpcUrl: addedRpcEndpoint.url, + ticker: networkFields.nativeCurrency, + }); + } + } + } + + /** + * Destroys and removes network clients using the given operations calculated + * as a part of updating or removing a network. + * + * @param args - The arguments to this function. + * @param args.networkClientOperations - Dictate which network clients to + * remove. + * @param args.autoManagedNetworkClientRegistry - The network client registry + * to update. + */ + #unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, + }: { + networkClientOperations: NetworkClientOperation[]; + autoManagedNetworkClientRegistry: AutoManagedNetworkClientRegistry; + }) { + const removedRpcEndpoints = networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is RemoveNetworkClientOperation => { + return networkClientOperation.type === 'remove'; + }, + ) + .map((networkClientOperation) => networkClientOperation.rpcEndpoint) + .concat( + networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is ReplaceNetworkClientOperation => { + return networkClientOperation.type === 'replace'; + }, + ) + .map( + (networkClientOperation) => networkClientOperation.oldRpcEndpoint, + ), + ); + + for (const rpcEndpoint of removedRpcEndpoints) { + const networkClient = this.getNetworkClientById( + rpcEndpoint.networkClientId, + ); + networkClient.destroy(); + delete autoManagedNetworkClientRegistry[networkClient.configuration.type][ + rpcEndpoint.networkClientId + ]; + } + } + + /** + * Updates `networkConfigurationsByChainId` in state depending on whether a + * network is being added, updated, or removed. + * + * - The existing network configuration will be removed when a network is + * being filed under a different chain or removed. + * - A network configuration will be stored when a network is being added or + * when a network is being updated. + * + * @param args - The arguments to this function. + */ + #updateNetworkConfigurations( + args: { state: Draft } & ( + | { + mode: 'add'; + networkFields: AddNetworkFields; + networkConfigurationToPersist: NetworkConfiguration; + } + | { + mode: 'update'; + networkFields: UpdateNetworkFields; + networkConfigurationToPersist: NetworkConfiguration; + existingNetworkConfiguration: NetworkConfiguration; + } + | { + mode: 'remove'; + existingNetworkConfiguration: NetworkConfiguration; + } + ), + ) { + const { state, mode } = args; + + if ( + mode === 'remove' || + (mode === 'update' && + args.networkFields.chainId !== + args.existingNetworkConfiguration.chainId) + ) { + delete state.networkConfigurationsByChainId[ + args.existingNetworkConfiguration.chainId + ]; + } + + if (mode === 'add' || mode === 'update') { + state.networkConfigurationsByChainId[args.networkFields.chainId] = + args.networkConfigurationToPersist; + } + } + /** * Before accessing or switching the network, the registry of network clients * needs to be populated. Otherwise, `#applyNetworkSelection` and @@ -1362,99 +2452,72 @@ export class NetworkController extends BaseController< * @returns The populated network client registry. */ #ensureAutoManagedNetworkClientRegistryPopulated(): AutoManagedNetworkClientRegistry { - const autoManagedNetworkClientRegistry = - this.#autoManagedNetworkClientRegistry ?? - this.#createAutoManagedNetworkClientRegistry(); - this.#autoManagedNetworkClientRegistry = autoManagedNetworkClientRegistry; - return autoManagedNetworkClientRegistry; + return (this.#autoManagedNetworkClientRegistry ??= + this.#createAutoManagedNetworkClientRegistry()); } /** - * Constructs the registry of network clients based on the set of built-in - * networks as well as the custom networks in state. + * Constructs the registry of network clients based on the set of default + * and custom networks in state. * * @returns The network clients keyed by ID. */ #createAutoManagedNetworkClientRegistry(): AutoManagedNetworkClientRegistry { - return [ - ...this.#buildIdentifiedInfuraNetworkClientConfigurations(), - ...this.#buildIdentifiedCustomNetworkClientConfigurations(), - ].reduce( + const chainIds = knownKeysOf(this.state.networkConfigurationsByChainId); + const networkClientsWithIds = chainIds.flatMap((chainId) => { + const networkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + return networkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + if (rpcEndpoint.type === RpcEndpointType.Infura) { + const infuraNetworkName = deriveInfuraNetworkNameFromRpcEndpointUrl( + rpcEndpoint.url, + ); + return [ + rpcEndpoint.networkClientId, + createAutoManagedNetworkClient({ + type: NetworkClientType.Infura, + network: infuraNetworkName, + infuraProjectId: this.#infuraProjectId, + chainId: networkConfiguration.chainId, + ticker: networkConfiguration.nativeCurrency, + }), + ] as const; + } + return [ + rpcEndpoint.networkClientId, + createAutoManagedNetworkClient({ + type: NetworkClientType.Custom, + chainId: networkConfiguration.chainId, + rpcUrl: rpcEndpoint.url, + ticker: networkConfiguration.nativeCurrency, + }), + ] as const; + }); + }); + + return networkClientsWithIds.reduce( ( - registry, - [networkClientType, networkClientId, networkClientConfiguration], + obj: { + [NetworkClientType.Custom]: Partial; + [NetworkClientType.Infura]: Partial; + }, + [networkClientId, networkClient], ) => { - const autoManagedNetworkClient = createAutoManagedNetworkClient( - networkClientConfiguration, - ); return { - ...registry, - [networkClientType]: { - ...registry[networkClientType], - [networkClientId]: autoManagedNetworkClient, + ...obj, + [networkClient.configuration.type]: { + ...obj[networkClient.configuration.type], + [networkClientId]: networkClient, }, }; }, { - [NetworkClientType.Infura]: {}, [NetworkClientType.Custom]: {}, + [NetworkClientType.Infura]: {}, }, ) as AutoManagedNetworkClientRegistry; } - /** - * Constructs the list of network clients for built-in networks (that is, - * the subset of the networks we know Infura supports that consumers do not - * need to explicitly add). - * - * @returns The network clients. - */ - #buildIdentifiedInfuraNetworkClientConfigurations(): [ - NetworkClientType.Infura, - BuiltInNetworkClientId, - InfuraNetworkClientConfiguration, - ][] { - return knownKeysOf(InfuraNetworkType).map((network) => { - const networkClientConfiguration: InfuraNetworkClientConfiguration = { - type: NetworkClientType.Infura, - network, - infuraProjectId: this.#infuraProjectId, - chainId: BUILT_IN_NETWORKS[network].chainId, - ticker: BUILT_IN_NETWORKS[network].ticker, - }; - return [NetworkClientType.Infura, network, networkClientConfiguration]; - }); - } - - /** - * Constructs the list of network clients for custom networks (that is, those - * which consumers have added via `networkConfigurations`). - * - * @returns The network clients. - */ - #buildIdentifiedCustomNetworkClientConfigurations(): [ - NetworkClientType.Custom, - CustomNetworkClientId, - CustomNetworkClientConfiguration, - ][] { - return Object.entries(this.state.networkConfigurations).map( - ([networkConfigurationId, networkConfiguration]) => { - const networkClientId = networkConfigurationId; - const networkClientConfiguration: CustomNetworkClientConfiguration = { - type: NetworkClientType.Custom, - chainId: networkConfiguration.chainId, - rpcUrl: networkConfiguration.rpcUrl, - ticker: networkConfiguration.ticker, - }; - return [ - NetworkClientType.Custom, - networkClientId, - networkClientConfiguration, - ]; - }, - ); - } - /** * Updates the global provider and block tracker proxies (accessible via * {@link getSelectedNetworkClient}) to point to the same ones within the @@ -1469,9 +2532,18 @@ export class NetworkController extends BaseController< * @param networkClientId - The ID of a network client that requests will be * routed through (either the name of an Infura network or the ID of a custom * network configuration). + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. * @throws if no network client could be found matching the given ID. */ - #applyNetworkSelection(networkClientId: string) { + #applyNetworkSelection( + networkClientId: string, + { + updateState, + }: { + updateState?: (state: Draft) => void; + } = {}, + ) { const autoManagedNetworkClientRegistry = this.#ensureAutoManagedNetworkClientRegistryPopulated(); @@ -1489,7 +2561,7 @@ export class NetworkController extends BaseController< /* istanbul ignore if */ if (!possibleAutoManagedNetworkClient) { throw new Error( - `Infura network client not found with ID '${networkClientId}'`, + `No Infura network client found with ID ${inspect(networkClientId)}`, ); } @@ -1502,7 +2574,7 @@ export class NetworkController extends BaseController< if (!possibleAutoManagedNetworkClient) { throw new Error( - `Custom network client not found with ID '${networkClientId}'`, + `No network client found with ID ${inspect(networkClientId)}`, ); } @@ -1519,6 +2591,7 @@ export class NetworkController extends BaseController< EIPS: {}, }; } + updateState?.(state); }); if (this.#providerProxy) { diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 30611b9209..e05622fc65 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -1,5 +1,43 @@ export type { AutoManagedNetworkClient } from './create-auto-managed-network-client'; -export * from './NetworkController'; +export type { + Block, + NetworkMetadata, + NetworkConfiguration, + BuiltInNetworkClientId, + CustomNetworkClientId, + NetworkClientId, + NetworksMetadata, + NetworkState, + BlockTrackerProxy, + ProviderProxy, + AddNetworkFields, + UpdateNetworkFields, + NetworkControllerStateChangeEvent, + NetworkControllerNetworkWillChangeEvent, + NetworkControllerNetworkDidChangeEvent, + NetworkControllerInfuraIsBlockedEvent, + NetworkControllerInfuraIsUnblockedEvent, + NetworkControllerEvents, + NetworkControllerGetStateAction, + NetworkControllerGetEthQueryAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetSelectedNetworkClientAction, + NetworkControllerGetEIP1559CompatibilityAction, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerSetProviderTypeAction, + NetworkControllerSetActiveNetworkAction, + NetworkControllerGetNetworkConfigurationByNetworkClientId, + NetworkControllerActions, + NetworkControllerMessenger, + NetworkControllerOptions, +} from './NetworkController'; +export { + getDefaultNetworkControllerState, + selectAvailableNetworkClientIds, + knownKeysOf, + NetworkController, + RpcEndpointType, +} from './NetworkController'; export * from './constants'; export type { BlockTracker, Provider } from './types'; export type { diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 739ee72cee..8ada6adaa0 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1,27 +1,33 @@ import { ControllerMessenger } from '@metamask/base-controller'; import { BUILT_IN_NETWORKS, + ChainId, InfuraNetworkType, isInfuraNetworkType, MAX_SAFE_CHAIN_ID, + NetworkNickname, + NetworksTicker, NetworkType, toHex, } from '@metamask/controller-utils'; import { rpcErrors } from '@metamask/rpc-errors'; -import { getKnownPropertyNames } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import assert from 'assert'; import type { Patch } from 'immer'; import { when, resetAllWhenMocks } from 'jest-when'; import { inspect, isDeepStrictEqual, promisify } from 'util'; -import { v4 } from 'uuid'; +import { v4 as uuidV4 } from 'uuid'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import type { FakeProviderStub } from '../../../tests/fake-provider'; import { FakeProvider } from '../../../tests/fake-provider'; import { NetworkStatus } from '../src/constants'; +import * as createAutoManagedNetworkClientModule from '../src/create-auto-managed-network-client'; import type { NetworkClient } from '../src/create-network-client'; import { createNetworkClient } from '../src/create-network-client'; import type { + AutoManagedBuiltInNetworkClientRegistry, + AutoManagedCustomNetworkClientRegistry, NetworkClientId, NetworkConfiguration, NetworkControllerActions, @@ -30,12 +36,27 @@ import type { NetworkControllerStateChangeEvent, NetworkState, } from '../src/NetworkController'; -import { NetworkController } from '../src/NetworkController'; +import { + getAvailableNetworkClientIds, + getDefaultNetworkControllerState, + getNetworkConfigurations, + NetworkController, + RpcEndpointType, + selectAvailableNetworkClientIds, +} from '../src/NetworkController'; import type { NetworkClientConfiguration, Provider } from '../src/types'; import { NetworkClientType } from '../src/types'; import { + buildAddNetworkCustomRpcEndpointFields, + buildAddNetworkFields, buildCustomNetworkClientConfiguration, + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, buildInfuraNetworkClientConfiguration, + buildInfuraNetworkConfiguration, + buildInfuraRpcEndpoint, + buildNetworkConfiguration, + buildUpdateNetworkCustomRpcEndpointFields, } from './helpers'; jest.mock('../src/create-network-client'); @@ -45,7 +66,7 @@ jest.mock('uuid', () => { return { ...actual, - v4: jest.fn().mockReturnValue('UUID'), + v4: jest.fn(), }; }); @@ -61,7 +82,7 @@ type Block = { }; const createNetworkClientMock = jest.mocked(createNetworkClient); -const uuidV4Mock = jest.mocked(v4); +const uuidV4Mock = jest.mocked(uuidV4); /** * A dummy block that matches the pre-EIP-1559 format (i.e. it doesn't have the @@ -86,49 +107,6 @@ const POST_1559_BLOCK: Block = { */ const BLOCK: Block = POST_1559_BLOCK; -/** - * The networks that NetworkController recognizes as built-in Infura networks, - * along with information we expect to be true for those networks. - */ -const INFURA_NETWORKS = [ - { - networkType: NetworkType['linea-goerli'], - chainId: toHex(59140), - ticker: 'LineaETH', - blockExplorerUrl: 'https://goerli.lineascan.build', - }, - { - networkType: NetworkType['linea-sepolia'], - chainId: toHex(59141), - ticker: 'LineaETH', - blockExplorerUrl: 'https://sepolia.lineascan.build', - }, - { - networkType: NetworkType['linea-mainnet'], - chainId: toHex(59144), - ticker: 'ETH', - blockExplorerUrl: 'https://lineascan.build', - }, - { - networkType: NetworkType.mainnet, - chainId: toHex(1), - ticker: 'ETH', - blockExplorerUrl: 'https://etherscan.io', - }, - { - networkType: NetworkType.goerli, - chainId: toHex(5), - ticker: 'GoerliETH', - blockExplorerUrl: 'https://goerli.etherscan.io', - }, - { - networkType: NetworkType.sepolia, - chainId: toHex(11155111), - ticker: 'SepoliaETH', - blockExplorerUrl: 'https://sepolia.etherscan.io', - }, -]; - /** * A response object for a successful request to `eth_getBlockByNumber`. It is * assumed that the block number here is insignificant to the test. @@ -153,11 +131,205 @@ const GENERIC_JSON_RPC_ERROR = rpcErrors.internal( ); describe('NetworkController', () => { + let uuidCounter = 0; + + beforeEach(() => { + uuidV4Mock.mockImplementation(() => { + const uuid = `UUID-${uuidCounter}`; + uuidCounter += 1; + return uuid; + }); + }); + afterEach(() => { resetAllWhenMocks(); }); describe('constructor', () => { + it('throws given an empty networkConfigurationsByChainId collection', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + networkConfigurationsByChainId: {}, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + 'NetworkController state is invalid: `networkConfigurationsByChainId` cannot be empty', + ); + }); + + it('throws if the key under which a network configuration is filed does not match the chain ID of that network configuration', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1338', + name: 'Test Network', + }), + }, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' is filed under '0x1337' which does not match its `chainId` of '0x1338'", + ); + }); + + it('throws if a network configuration has a defaultBlockExplorerUrlIndex that does not refer to an entry in blockExplorerUrls', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 99999, + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://some.endpoint', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultBlockExplorerUrlIndex` that does not refer to an entry in `blockExplorerUrls`", + ); + }); + + it('throws if a network configuration has a non-empty blockExplorerUrls but an absent defaultBlockExplorerUrlIndex', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://some.endpoint', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultBlockExplorerUrlIndex` that does not refer to an entry in `blockExplorerUrls`", + ); + }); + + it('throws if a network configuration has an invalid defaultRpcEndpointIndex', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + defaultRpcEndpointIndex: 99999, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://some.endpoint', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultRpcEndpointIndex` that does not refer to an entry in `rpcEndpoints`", + ); + }); + + it('throws if more than one RPC endpoint across network configurations has the same networkClientId', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network 1', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + name: 'Test Network 2', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/2', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + 'NetworkController state has invalid `networkConfigurationsByChainId`: Every RPC endpoint across all network configurations must have a unique `networkClientId`', + ); + }); + + it('throws if selectedNetworkClientId does not match the networkClientId of an RPC endpoint in networkConfigurationsByChainId', () => { + const messenger = buildMessenger(); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: restrictedMessenger, + state: { + selectedNetworkClientId: 'nonexistent', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + }), + }, + }, + infuraProjectId: 'infura-project-id', + }), + ).toThrow( + "NetworkController state is invalid: `selectedNetworkClientId` 'nonexistent' does not refer to an RPC endpoint within a network configuration", + ); + }); + const invalidInfuraProjectIds = [undefined, null, {}, 1]; invalidInfuraProjectIds.forEach((invalidProjectId) => { it(`throws given an invalid Infura ID of "${inspect( @@ -169,6 +341,7 @@ describe('NetworkController', () => { () => new NetworkController({ messenger: restrictedMessenger, + state: {}, // @ts-expect-error We are intentionally passing bad input. infuraProjectId: invalidProjectId, }), @@ -180,7 +353,92 @@ describe('NetworkController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { - "networkConfigurations": Object {}, + "networkConfigurationsByChainId": Object { + "0x1": Object { + "blockExplorerUrls": Array [], + "chainId": "0x1", + "defaultRpcEndpointIndex": 0, + "name": "Mainnet", + "nativeCurrency": "ETH", + "rpcEndpoints": Array [ + Object { + "networkClientId": "mainnet", + "type": "infura", + "url": "https://mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x5": Object { + "blockExplorerUrls": Array [], + "chainId": "0x5", + "defaultRpcEndpointIndex": 0, + "name": "Goerli", + "nativeCurrency": "GoerliETH", + "rpcEndpoints": Array [ + Object { + "networkClientId": "goerli", + "type": "infura", + "url": "https://goerli.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xaa36a7": Object { + "blockExplorerUrls": Array [], + "chainId": "0xaa36a7", + "defaultRpcEndpointIndex": 0, + "name": "Sepolia", + "nativeCurrency": "SepoliaETH", + "rpcEndpoints": Array [ + Object { + "networkClientId": "sepolia", + "type": "infura", + "url": "https://sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe704": Object { + "blockExplorerUrls": Array [], + "chainId": "0xe704", + "defaultRpcEndpointIndex": 0, + "name": "Linea Goerli", + "nativeCurrency": "LineaETH", + "rpcEndpoints": Array [ + Object { + "networkClientId": "linea-goerli", + "type": "infura", + "url": "https://linea-goerli.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe705": Object { + "blockExplorerUrls": Array [], + "chainId": "0xe705", + "defaultRpcEndpointIndex": 0, + "name": "Linea Sepolia", + "nativeCurrency": "LineaETH", + "rpcEndpoints": Array [ + Object { + "networkClientId": "linea-sepolia", + "type": "infura", + "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe708": Object { + "blockExplorerUrls": Array [], + "chainId": "0xe708", + "defaultRpcEndpointIndex": 0, + "name": "Linea Mainnet", + "nativeCurrency": "ETH", + "rpcEndpoints": Array [ + Object { + "networkClientId": "linea-mainnet", + "type": "infura", + "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, "networksMetadata": Object {}, "selectedNetworkClientId": "mainnet", } @@ -192,6 +450,25 @@ describe('NetworkController', () => { await withController( { state: { + selectedNetworkClientId: InfuraNetworkType.goerli, + networkConfigurationsByChainId: { + [ChainId.goerli]: { + blockExplorerUrls: ['https://block.explorer'], + chainId: ChainId.goerli, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Goerli', + nativeCurrency: 'GoerliETH', + rpcEndpoints: [ + { + name: 'Goerli', + networkClientId: InfuraNetworkType.goerli, + type: RpcEndpointType.Infura, + url: 'https://goerli.infura.io/v3/{infuraProjectId}', + }, + ], + }, + }, networksMetadata: { mainnet: { EIPS: { 1559: true }, @@ -203,7 +480,26 @@ describe('NetworkController', () => { ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { - "networkConfigurations": Object {}, + "networkConfigurationsByChainId": Object { + "0x5": Object { + "blockExplorerUrls": Array [ + "https://block.explorer", + ], + "chainId": "0x5", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Goerli", + "nativeCurrency": "GoerliETH", + "rpcEndpoints": Array [ + Object { + "name": "Goerli", + "networkClientId": "goerli", + "type": "infura", + "url": "https://goerli.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, "networksMetadata": Object { "mainnet": Object { "EIPS": Object { @@ -212,7 +508,7 @@ describe('NetworkController', () => { "status": "unknown", }, }, - "selectedNetworkClientId": "mainnet", + "selectedNetworkClientId": "goerli", } `); }, @@ -249,45 +545,27 @@ describe('NetworkController', () => { }); describe('initializeProvider', () => { - for (const { networkType } of INFURA_NETWORKS) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`when selectedNetworkClientId in state is the Infura network "${networkType}"`, () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - it(`does not create another network client for the "${networkType}" network, since it is built in`, async () => { - await withController( - { - state: { - selectedNetworkClientId: networkType, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + // TODO: Update these names + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; - await controller.initializeProvider(); - - expect(createNetworkClientMock).toHaveBeenCalledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - }, - ); - }); + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + it('sets the globally selected provider to the one from the corresponding network client', async () => { + const infuraProjectId = 'some-infura-project-id'; - it('captures the resulting provider of the matching network client', async () => { await withController( { state: { - selectedNetworkClientId: networkType, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProvider = buildFakeProvider([ @@ -302,13 +580,21 @@ describe('NetworkController', () => { }, ]); const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }) + .mockReturnValue(fakeNetworkClient); await controller.initializeProvider(); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const result = await provider.request({ + const networkClient = controller.getSelectedNetworkClient(); + assert(networkClient, 'Network client not set'); + const result = await networkClient.provider.request({ id: 1, jsonrpc: '2.0', method: 'test_method', @@ -320,10 +606,9 @@ describe('NetworkController', () => { }); lookupNetworkTests({ - expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(networkType), + expectedNetworkClientType: NetworkClientType.Infura, initialState: { - selectedNetworkClientId: networkType, + selectedNetworkClientId: infuraNetworkType, }, operation: async (controller: NetworkController) => { await controller.initializeProvider(); @@ -332,62 +617,23 @@ describe('NetworkController', () => { }); } - describe('when selectedNetworkClientId in state is the ID of a network configuration', () => { - it('creates a network client using the network configuration', async () => { - await withController( - { - state: { - selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1337), - ticker: 'TEST', - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); - - expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: toHex(1337), - rpcUrl: 'https://test.network.1', - type: NetworkClientType.Custom, - ticker: 'TEST', - }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - }, - ); - }); - - it('captures the resulting provider of the new network client', async () => { + describe('when the selected network client represents a custom RPC endpoint', () => { + it('sets the globally selected provider to the one from the corresponding network client', async () => { await withController( { state: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, }, @@ -404,13 +650,22 @@ describe('NetworkController', () => { }, ]); const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClient); await controller.initializeProvider(); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const result = await provider.request({ + const networkClient = controller.getSelectedNetworkClient(); + assert(networkClient, 'Network client not set'); + const { result } = await promisify( + networkClient.provider.sendAsync, + ).call(networkClient.provider, { id: 1, jsonrpc: '2.0', method: 'test_method', @@ -420,6 +675,28 @@ describe('NetworkController', () => { }, ); }); + + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Custom, + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + operation: async (controller: NetworkController) => { + await controller.initializeProvider(); + }, + }); }); }); @@ -449,34 +726,46 @@ describe('NetworkController', () => { }); }); - for (const { networkType } of INFURA_NETWORKS) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; + + // False negative - this is a string. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`when the selectedNetworkClientId is changed to "${networkType}"`, () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + describe(`when the selectedNetworkClientId is changed to represent the Infura network "${infuraNetworkType}"`, () => { + // False negative - this is a string. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - it(`returns a provider object that was pointed to another network before the switch and is pointed to "${networkType}" afterward`, async () => { + it(`returns a provider object that was pointed to another network before the switch and is now pointed to ${infuraNetworkNickname} afterward`, async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [ buildFakeProvider([ { request: { - method: 'test', + method: 'test_method', }, response: { result: 'test response 1', @@ -486,7 +775,7 @@ describe('NetworkController', () => { buildFakeProvider([ { request: { - method: 'test', + method: 'test_method', }, response: { result: 'test response 2', @@ -500,36 +789,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); + assert(provider, 'Provider not set'); const result1 = await provider.request({ id: '1', jsonrpc: '2.0', - method: 'test', + method: 'test_method', }); expect(result1).toBe('test response 1'); - await controller.setProviderType(networkType); + await controller.setActiveNetwork(infuraNetworkType); const result2 = await provider.request({ id: '2', jsonrpc: '2.0', - method: 'test', + method: 'test_method', }); expect(result2).toBe('test response 2'); }, @@ -538,29 +827,38 @@ describe('NetworkController', () => { }); } - describe(`when the selectedNetworkClientId is changed to a network configuration ID`, () => { - it('returns a provider object that was pointed to another network before the switch and is pointed to the new network', async () => { + describe('when the selectedNetworkClientId is changed to represent a custom RPC endpoint', () => { + it('returns a provider object that was pointed to another network before the switch and is now pointed to the new network', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'goerli', - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', - id: 'testNetworkConfigurationId', - }, + selectedNetworkClientId: InfuraNetworkType.goerli, + networkConfigurationsByChainId: { + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [ buildFakeProvider([ { request: { - method: 'test', + method: 'test_method', }, response: { result: 'test response 1', @@ -570,7 +868,7 @@ describe('NetworkController', () => { buildFakeProvider([ { request: { - method: 'test', + method: 'test_method', }, response: { result: 'test response 2', @@ -584,36 +882,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], + type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', type: NetworkClientType.Custom, - ticker: 'ABC', }) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); + assert(provider, 'Provider not set'); const result1 = await provider.request({ id: '1', jsonrpc: '2.0', - method: 'test', + method: 'test_method', }); expect(result1).toBe('test response 1'); - await controller.setActiveNetwork('testNetworkConfigurationId'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); const result2 = await provider.request({ id: '2', jsonrpc: '2.0', - method: 'test', + method: 'test_method', }); expect(result2).toBe('test response 2'); }, @@ -668,342 +966,268 @@ describe('NetworkController', () => { }); describe('getNetworkClientById', () => { - describe('If passed an existing networkClientId', () => { - it('returns a valid built-in Infura NetworkClient', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + describe('if passed an Infura network client ID', () => { + describe('if the ID refers to an existing Infura network client', () => { + it('returns the network client', async () => { + const infuraProjectId = 'some-infura-project-id'; - const networkClientRegistry = controller.getNetworkClientRegistry(); - const networkClient = controller.getNetworkClientById( - NetworkType.mainnet, - ); + await withController( + { + infuraProjectId, + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + NetworkType.mainnet, + ); - expect(networkClient).toBe( - networkClientRegistry[NetworkType.mainnet], - ); - }, - ); + expect(networkClient.configuration).toStrictEqual({ + chainId: ChainId[InfuraNetworkType.mainnet], + infuraProjectId, + network: InfuraNetworkType.mainnet, + ticker: NetworksTicker[InfuraNetworkType.mainnet], + type: NetworkClientType.Infura, + }); + }, + ); + }); }); - it('returns a valid built-in Infura NetworkClient with a chainId in configuration', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClientRegistry = controller.getNetworkClientRegistry(); - const networkClient = controller.getNetworkClientById( - NetworkType.mainnet, - ); - - expect(networkClient.configuration.chainId).toBe('0x1'); - expect(networkClientRegistry.mainnet.configuration.chainId).toBe( - '0x1', - ); - }, - ); - }); + describe('if the ID does not refer to an existing Infura network client', () => { + it('throws', async () => { + const infuraProjectId = 'some-infura-project-id'; - it('returns a valid custom NetworkClient', async () => { - await withController( - { - state: { - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: '0x1337', - ticker: 'ABC', - id: 'testNetworkConfigurationId', - }, - }, + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration(), + }, + }), + infuraProjectId, }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClientRegistry = controller.getNetworkClientRegistry(); - const networkClient = controller.getNetworkClientById( - 'testNetworkConfigurationId', - ); - - expect(networkClient).toBe( - networkClientRegistry.testNetworkConfigurationId, - ); - }, - ); + async ({ controller }) => { + expect(() => + controller.getNetworkClientById(NetworkType.mainnet), + ).toThrow( + 'No Infura network client was found with the ID "mainnet".', + ); + }, + ); + }); }); }); - describe('If passed a networkClientId that does not match a NetworkClient in the registry', () => { - it('throws an error', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + describe('if passed a custom network client ID', () => { + describe('if the ID refers to an existing custom network client', () => { + it('returns the network client', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }), + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); - expect(() => - controller.getNetworkClientById('non-existent-network-id'), - ).toThrow( - 'No custom network client was found with the ID "non-existent-network-id', - ); - }, - ); + expect(networkClient.configuration).toStrictEqual({ + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }); + }, + ); + }); }); - }); - - describe('If not passed a networkClientId', () => { - it('throws an error', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - expect(() => - // @ts-expect-error Intentionally passing invalid type - controller.getNetworkClientById(), - ).toThrow('No network client ID was provided.'); - }, - ); + describe('if the ID does not refer to an existing custom network client', () => { + it('throws', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + }), + }, + }), + }, + async ({ controller }) => { + expect(() => controller.getNetworkClientById('0x1337')).toThrow( + 'No custom network client was found with the ID "0x1337".', + ); + }, + ); + }); }); }); }); describe('getNetworkClientRegistry', () => { - describe('if no network configurations are present in state', () => { - it('returns the built-in Infura networks by default', async () => { + describe('if no network configurations were specified at initialization', () => { + it('returns network clients for Infura RPC endpoints, keyed by network client ID', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( - { infuraProjectId: 'some-infura-project-id' }, + { + infuraProjectId, + }, async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); - }, - ); + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, + expect(controller.getNetworkClientRegistry()).toStrictEqual({ + goerli: { + blockTracker: expect.anything(), + configuration: { + chainId: '0x5', + infuraProjectId, network: InfuraNetworkType.goerli, + ticker: 'GoerliETH', + type: NetworkClientType.Infura, }, - ], - [ - 'linea-goerli', - { + provider: expect.anything(), + destroy: expect.any(Function), + }, + 'linea-goerli': { + blockTracker: expect.anything(), + configuration: { type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-goerli']].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType['linea-goerli']].ticker, + infuraProjectId, + chainId: '0xe704', + ticker: 'LineaETH', network: InfuraNetworkType['linea-goerli'], }, - ], - [ - 'linea-mainnet', - { + provider: expect.anything(), + destroy: expect.any(Function), + }, + 'linea-mainnet': { + blockTracker: expect.anything(), + configuration: { type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].chainId, - ticker: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].ticker, + infuraProjectId, + chainId: '0xe708', + ticker: 'ETH', network: InfuraNetworkType['linea-mainnet'], }, - ], - [ - 'linea-sepolia', - { + provider: expect.anything(), + destroy: expect.any(Function), + }, + 'linea-sepolia': { + blockTracker: expect.anything(), + configuration: { type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-sepolia']].chainId, - ticker: - BUILT_IN_NETWORKS[NetworkType['linea-sepolia']].ticker, + infuraProjectId, + chainId: '0xe705', + ticker: 'LineaETH', network: InfuraNetworkType['linea-sepolia'], }, - ], - [ - 'mainnet', - { + provider: expect.anything(), + destroy: expect.any(Function), + }, + mainnet: { + blockTracker: expect.anything(), + configuration: { type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.mainnet].ticker, + infuraProjectId, + chainId: '0x1', + ticker: 'ETH', network: InfuraNetworkType.mainnet, }, - ], - [ - 'sepolia', - { + provider: expect.anything(), + destroy: expect.any(Function), + }, + sepolia: { + blockTracker: expect.anything(), + configuration: { type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.sepolia].ticker, + infuraProjectId, + chainId: '0xaa36a7', + ticker: 'SepoliaETH', network: InfuraNetworkType.sepolia, }, - ], - ]); + provider: expect.anything(), + destroy: expect.any(Function), + }, + }); }, ); }); }); - describe('if network configurations are present in state', () => { - it('incorporates them into the list of network clients, using the network configuration ID for identification', async () => { + describe('if some network configurations were specified at initialization', () => { + it('returns network clients for all RPC endpoints within any defined network configurations, keyed by network client ID, and does not include Infura-supported chains by default', async () => { await withController( { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1), - ticker: 'TEST1', - }, - 'BBBB-BBBB-BBBB-BBBB': { - id: 'BBBB-BBBB-BBBB-BBBB', - rpcUrl: 'https://test.network.2', - chainId: toHex(2), - ticker: 'TEST2', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + ], + }), + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }), }, - }, - }, - infuraProjectId: 'some-infura-project-id', + }), }, async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); - }, - ); + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'AAAA-AAAA-AAAA-AAAA', - { + expect(controller.getNetworkClientRegistry()).toStrictEqual({ + 'AAAA-AAAA-AAAA-AAAA': { + blockTracker: expect.anything(), + configuration: { + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN1', type: NetworkClientType.Custom, - ticker: 'TEST1', - chainId: toHex(1), - rpcUrl: 'https://test.network.1', }, - ], - [ - 'BBBB-BBBB-BBBB-BBBB', - { + provider: expect.anything(), + destroy: expect.any(Function), + }, + 'BBBB-BBBB-BBBB-BBBB': { + blockTracker: expect.anything(), + configuration: { + chainId: '0x2448', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN2', type: NetworkClientType.Custom, - ticker: 'TEST2', - chainId: toHex(2), - rpcUrl: 'https://test.network.2', - }, - ], - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, - }, - ], - [ - 'linea-goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-goerli']].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType['linea-goerli']].ticker, - network: InfuraNetworkType['linea-goerli'], - }, - ], - [ - 'linea-mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].chainId, - ticker: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].ticker, - network: InfuraNetworkType['linea-mainnet'], - }, - ], - [ - 'linea-sepolia', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-sepolia']].chainId, - ticker: - BUILT_IN_NETWORKS[NetworkType['linea-sepolia']].ticker, - network: InfuraNetworkType['linea-sepolia'], - }, - ], - [ - 'mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.mainnet].ticker, - network: InfuraNetworkType.mainnet, - }, - ], - [ - 'sepolia', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, }, - ], - ]); - for (const networkClient of Object.values(networkClients)) { - expect(networkClient.provider).toHaveProperty('request'); - expect(networkClient.blockTracker).toHaveProperty( - 'checkForLatestBlock', - ); - } + provider: expect.anything(), + destroy: expect.any(Function), + }, + }); }, ); }); @@ -1026,6 +1250,7 @@ describe('NetworkController', () => { }, ); }); + it('throws an error if the network is not found', async () => { await withController( { infuraProjectId: 'some-infura-project-id' }, @@ -1040,351 +1265,353 @@ describe('NetworkController', () => { }); }); - [NetworkType.mainnet, NetworkType.goerli, NetworkType.sepolia].forEach( - (networkType) => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`when selectedNetworkClientId in state is "${networkType}"`, () => { - describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { - it('stores the network status of the second network, not the first', async () => { - await withController( - { - state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfigurationId: { - id: 'testNetworkConfigurationId', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', - }, - }, + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { + it('stores the network status of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, - infuraProjectId: 'some-infura-project-id', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - beforeCompleting: () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - controller.setActiveNetwork( - 'testNetworkConfigurationId', - ); - }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - error: GENERIC_JSON_RPC_ERROR, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + beforeCompleting: () => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - expect( - controller.state.networksMetadata[networkType].status, - ).toBe('available'); - - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'testNetworkConfigurationId', - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); }, - }); - - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('unknown'); - }, - ); - }); - - it('stores the EIP-1559 support of the second network, not the first', async () => { - await withController( - { - state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfigurationId: { - id: 'testNetworkConfigurationId', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', + ]), + buildFakeProvider([ + // Called when switching networks + { + request: { + method: 'eth_getBlockByNumber', }, + error: GENERIC_JSON_RPC_ERROR, }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect( + controller.state.networksMetadata[infuraNetworkType].status, + ).toBe('available'); + + await controller.lookupNetwork(); + + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .status, + ).toBe('unknown'); + }, + ); + }); + + it('stores the EIP-1559 support of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, - infuraProjectId: 'some-infura-project-id', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, - beforeCompleting: () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - controller.setActiveNetwork( - 'testNetworkConfigurationId', - ); - }, + response: { + result: POST_1559_BLOCK, }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: PRE_1559_BLOCK, - }, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, + beforeCompleting: () => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - expect( - controller.state.networksMetadata[networkType].EIPS[1559], - ).toBe(true); - - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'testNetworkConfigurationId', - 'EIPS', - ], - operation: async () => { - await controller.lookupNetwork(); }, - }); - - expect( - controller.state.networksMetadata.testNetworkConfigurationId - .EIPS[1559], - ).toBe(false); - }, - ); - }); - - it('emits infuraIsUnblocked, not infuraIsBlocked, assuming that the first network was blocked', async () => { - await withController( - { - state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfigurationId: { - id: 'testNetworkConfigurationId', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', + ]), + buildFakeProvider([ + // Called when switching networks + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: PRE_1559_BLOCK, }, }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect( + controller.state.networksMetadata[infuraNetworkType] + .EIPS[1559], + ).toBe(true); + + await controller.lookupNetwork(); + + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], + ).toBe(false); + }, + ); + }); + + it('emits infuraIsUnblocked, not infuraIsBlocked, assuming that the first network was blocked', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, - infuraProjectId: 'some-infura-project-id', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - beforeCompleting: () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - controller.setActiveNetwork( - 'testNetworkConfigurationId', - ); - }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + beforeCompleting: () => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - const promiseForInfuraIsUnblockedEvents = - waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - }); - const promiseForNoInfuraIsBlockedEvents = - waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - count: 0, - }); - - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'testNetworkConfigurationId', - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); }, + ]), + buildFakeProvider([ + // Called when switching networks + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: ChainId[infuraNetworkType], + infuraProjectId, + network: infuraNetworkType, + ticker: NetworksTicker[infuraNetworkType], + type: NetworkClientType.Infura, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + const promiseForInfuraIsUnblockedEvents = + waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + }); + const promiseForNoInfuraIsBlockedEvents = + waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + count: 0, }); - await expect( - promiseForInfuraIsUnblockedEvents, - ).toBeFulfilled(); - await expect( - promiseForNoInfuraIsBlockedEvents, - ).toBeFulfilled(); - }, - ); - }); - }); + await waitForStateChanges({ + messenger, + propertyPath: [ + 'networksMetadata', + 'AAAA-AAAA-AAAA-AAAA', + 'status', + ], + operation: async () => { + await controller.lookupNetwork(); + }, + }); - lookupNetworkTests({ - expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(networkType), - initialState: { - selectedNetworkClientId: networkType, - }, - operation: async (controller) => { - await controller.lookupNetwork(); - }, + await expect(promiseForInfuraIsUnblockedEvents).toBeFulfilled(); + await expect(promiseForNoInfuraIsBlockedEvents).toBeFulfilled(); + }, + ); }); }); - }, - ); - describe('when selectedNetworkClientId in state is the ID of a network configuration', () => { + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Infura, + initialState: { + selectedNetworkClientId: infuraNetworkType, + }, + operation: async (controller) => { + await controller.lookupNetwork(); + }, + }); + }); + } + + describe('when the selected network client represents a custom RPC endpoint', () => { describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { it('stores the network status of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, - async ({ controller, messenger }) => { + async ({ controller }) => { const fakeProviders = [ buildFakeProvider([ // Called during provider initialization @@ -1401,7 +1628,7 @@ describe('NetworkController', () => { }, response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, beforeCompleting: () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // We are purposefully not awaiting this promise. // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.setProviderType(NetworkType.goerli); }, @@ -1423,17 +1650,17 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + chainId: ChainId[InfuraNetworkType.goerli], + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); @@ -1442,42 +1669,42 @@ describe('NetworkController', () => { controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, ).toBe('available'); - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - NetworkType.goerli, - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + await controller.lookupNetwork(); expect( - controller.state.networksMetadata[NetworkType.goerli].status, + controller.state.networksMetadata[InfuraNetworkType.goerli] + .status, ).toBe('unknown'); }, ); }); it('stores the EIP-1559 support of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, - async ({ controller, messenger }) => { + async ({ controller }) => { const fakeProviders = [ buildFakeProvider([ // Called during provider initialization @@ -1498,7 +1725,7 @@ describe('NetworkController', () => { result: POST_1559_BLOCK, }, beforeCompleting: () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // We are purposefully not awaiting this promise. // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.setProviderType(NetworkType.goerli); }, @@ -1522,17 +1749,17 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + chainId: ChainId[InfuraNetworkType.goerli], + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); @@ -1542,13 +1769,7 @@ describe('NetworkController', () => { .EIPS[1559], ).toBe(true); - await waitForStateChanges({ - messenger, - propertyPath: ['networksMetadata', NetworkType.goerli, 'EIPS'], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + await controller.lookupNetwork(); expect( controller.state.networksMetadata[NetworkType.goerli] @@ -1563,20 +1784,29 @@ describe('NetworkController', () => { }); it('emits infuraIsBlocked, not infuraIsUnblocked, if the second network was blocked and the first network was not', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [ @@ -1595,7 +1825,7 @@ describe('NetworkController', () => { }, response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, beforeCompleting: () => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // We are purposefully not awaiting this promise. // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.setProviderType(NetworkType.goerli); }, @@ -1617,17 +1847,17 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + chainId: ChainId[InfuraNetworkType.goerli], + infuraProjectId, + network: InfuraNetworkType.goerli, + ticker: NetworksTicker[InfuraNetworkType.goerli], type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); @@ -1643,17 +1873,7 @@ describe('NetworkController', () => { eventType: 'NetworkController:infuraIsBlocked', }); - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - NetworkType.goerli, - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + await controller.lookupNetwork(); await expect(promiseForNoInfuraIsUnblockedEvents).toBeFulfilled(); await expect(promiseForInfuraIsBlockedEvents).toBeFulfilled(); @@ -1663,17 +1883,20 @@ describe('NetworkController', () => { }); lookupNetworkTests({ - expectedNetworkClientConfiguration: - buildCustomNetworkClientConfiguration(), + expectedNetworkClientType: NetworkClientType.Custom, initialState: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, operation: async (controller) => { @@ -1684,50 +1907,35 @@ describe('NetworkController', () => { }); describe('setProviderType', () => { - for (const { networkType } of INFURA_NETWORKS) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + // False negative - this is a string. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`given the Infura network "${networkType}"`, () => { + describe(`given the Infura network "${infuraNetworkType}"`, () => { refreshNetworkTests({ expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(networkType), + buildInfuraNetworkClientConfiguration(infuraNetworkType), operation: async (controller) => { - await controller.setProviderType(networkType); + await controller.setProviderType(infuraNetworkType); }, }); }); - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // False negative - this is a string. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - it(`sets selectedNetworkClientId in state to the Infura network "${networkType}"`, async () => { - await withController( - { - state: { - selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + it(`sets selectedNetworkClientId in state to "${infuraNetworkType}"`, async () => { + await withController(async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); - await controller.setProviderType(networkType); + await controller.setProviderType(infuraNetworkType); - expect(controller.state.selectedNetworkClientId).toBe(networkType); - }, - ); + expect(controller.state.selectedNetworkClientId).toBe( + infuraNetworkType, + ); + }); }); } - describe('given the ID of a network configuration', () => { + describe('given "rpc"', () => { it('throws because there is no way to switch to a custom RPC endpoint using this method', async () => { await withController( { @@ -1833,90 +2041,121 @@ describe('NetworkController', () => { }); describe('setActiveNetwork', () => { - refreshNetworkTests({ - expectedNetworkClientConfiguration: buildCustomNetworkClientConfiguration( - { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - }, - ), - initialState: { - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId', - rpcPrefs: undefined, - }, - }, - }, - operation: async (controller) => { - await controller.setActiveNetwork('testNetworkConfigurationId'); - }, + describe('if the given ID does not refer to an existing network client', () => { + it('throws', async () => { + await withController(async ({ controller }) => { + await expect(() => + controller.setActiveNetwork('invalid-network-client-id'), + ).rejects.toThrow( + new Error( + "No network client found with ID 'invalid-network-client-id'", + ), + ); + }); + }); }); - describe('if the given ID refers to no existing network clients (derived from known Infura networks and network configurations)', () => { - it('throws', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`if the ID refers to a network client created for the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + operation: async (controller) => { + await controller.setActiveNetwork(infuraNetworkType); + }, + }); - await expect(() => - controller.setActiveNetwork('invalidNetworkClientId'), - ).rejects.toThrow( - new Error( - "Custom network client not found with ID 'invalidNetworkClientId'", - ), + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`sets selectedNetworkClientId in state to "${infuraNetworkType}"`, async () => { + await withController({}, async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + + await controller.setActiveNetwork(infuraNetworkType); + + expect(controller.state.selectedNetworkClientId).toStrictEqual( + infuraNetworkType, ); + }); + }); + }); + } + + describe('if the ID refers to a custom network client', () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildCustomNetworkClientConfiguration({ + rpcUrl: 'https://test.network', + chainId: '0x1337', + ticker: 'TEST', + }), + initialState: { + selectedNetworkClientId: InfuraNetworkType.mainnet, + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, - ); + }, + operation: async (controller) => { + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + }, }); - }); - describe('if the ID refers to a network client created for a network configuration', () => { it('assigns selectedNetworkClientId in state to the ID', async () => { const testNetworkClientId = 'AAAA-AAAA-AAAA-AAAA'; await withController( { state: { - networkConfigurations: { - [testNetworkClientId]: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - id: testNetworkClientId, - }, + selectedNetworkClientId: InfuraNetworkType.mainnet, + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClient); + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); await controller.setActiveNetwork(testNetworkClientId); @@ -1928,74 +2167,38 @@ describe('NetworkController', () => { }); }); - for (const { networkType } of INFURA_NETWORKS) { - // This is a string. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`if the ID refers to a network client created for the Infura network "${networkType}"`, () => { - refreshNetworkTests({ - expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(networkType), - operation: async (controller) => { - await controller.setActiveNetwork(networkType); - }, - }); - - // This is a string. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - it(`sets selectedNetworkClientId in state to "${networkType}"`, async () => { - await withController({}, async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - await controller.setActiveNetwork(networkType); - - expect(controller.state.selectedNetworkClientId).toStrictEqual( - networkType, - ); - }); - }); - }); - } - it('is able to be called via messenger action', async () => { - const testNetworkClientId = 'testNetworkConfigurationId'; await withController( { state: { - networkConfigurations: { - [testNetworkClientId]: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: testNetworkClientId, - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer-2.com', - }, - }, + selectedNetworkClientId: InfuraNetworkType.mainnet, + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, }, async ({ controller, messenger }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClient); + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); await messenger.call( 'NetworkController:setActiveNetwork', - testNetworkClientId, + 'AAAA-AAAA-AAAA-AAAA', ); - expect(controller.state.selectedNetworkClientId).toStrictEqual( - testNetworkClientId, + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', ); }, ); @@ -2374,42 +2577,44 @@ describe('NetworkController', () => { }); describe('resetConnection', () => { - [NetworkType.mainnet, NetworkType.goerli, NetworkType.sepolia].forEach( - (networkType) => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`when selectedNetworkClientId in state is the Infura network "${networkType}"`, () => { - refreshNetworkTests({ - expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(networkType), - initialState: { - selectedNetworkClientId: networkType, - }, - operation: async (controller) => { - await controller.resetConnection(); - }, - }); + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + initialState: { + selectedNetworkClientId: infuraNetworkType, + }, + operation: async (controller) => { + await controller.resetConnection(); + }, }); - }, - ); + }); + } - describe('when selectedNetworkClientId in state is the ID of a network configuration', () => { + describe('when the selected network client represents a custom RPC endpoint', () => { refreshNetworkTests({ expectedNetworkClientConfiguration: buildCustomNetworkClientConfiguration({ - rpcUrl: 'https://test.network.1', - chainId: toHex(1337), + rpcUrl: 'https://test.network', + chainId: '0x1337', ticker: 'TEST', }), initialState: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, operation: async (controller) => { @@ -2461,1797 +2666,8790 @@ describe('NetworkController', () => { }); }); - for (const [name, getNetworkConfigurationByNetworkClientId] of [ + for (const [name, getNetworkConfigurationByChainId] of [ [ - 'getNetworkConfigurationByNetworkClientId', + 'getNetworkConfigurationByChainId', ({ controller, - networkClientId, + chainId, }: { controller: NetworkController; - networkClientId: NetworkClientId; - }) => - controller.getNetworkConfigurationByNetworkClientId(networkClientId), + chainId: Hex; + }) => controller.getNetworkConfigurationByChainId(chainId), ], [ - 'NetworkController:getNetworkConfigurationByNetworkClientId', + 'NetworkController:getNetworkConfigurationByChainId', ({ messenger, - networkClientId, + chainId, }: { messenger: ControllerMessenger< NetworkControllerActions, NetworkControllerEvents >; - networkClientId: NetworkClientId; + chainId: Hex; }) => messenger.call( - 'NetworkController:getNetworkConfigurationByNetworkClientId', - networkClientId, + 'NetworkController:getNetworkConfigurationByChainId', + chainId, ), ], ] as const) { // This is a string! // eslint-disable-next-line jest/valid-title - describe(String(name), () => { - const infuraProjectId = 'some-infura-project-id'; - const expectedInfuraNetworkConfigurationsByType: Record< - InfuraNetworkType, - NetworkConfiguration - > = { - [InfuraNetworkType.goerli]: { - rpcUrl: 'https://goerli.infura.io/v3/some-infura-project-id', - chainId: '0x5' as const, - ticker: 'GoerliETH', - rpcPrefs: { - blockExplorerUrl: 'https://goerli.etherscan.io', - }, - }, - [InfuraNetworkType.sepolia]: { - rpcUrl: 'https://sepolia.infura.io/v3/some-infura-project-id', - chainId: '0xaa36a7' as const, - ticker: 'SepoliaETH', - rpcPrefs: { - blockExplorerUrl: 'https://sepolia.etherscan.io', - }, - }, - [InfuraNetworkType.mainnet]: { - rpcUrl: 'https://mainnet.infura.io/v3/some-infura-project-id', - chainId: '0x1' as const, - ticker: 'ETH', - rpcPrefs: { - blockExplorerUrl: 'https://etherscan.io', - }, - }, - [InfuraNetworkType['linea-goerli']]: { - rpcUrl: 'https://linea-goerli.infura.io/v3/some-infura-project-id', - chainId: '0xe704' as const, - ticker: 'LineaETH', - rpcPrefs: { - blockExplorerUrl: 'https://goerli.lineascan.build', - }, - }, - [InfuraNetworkType['linea-sepolia']]: { - rpcUrl: 'https://linea-sepolia.infura.io/v3/some-infura-project-id', - chainId: '0xe705' as const, - ticker: 'LineaETH', - rpcPrefs: { - blockExplorerUrl: 'https://sepolia.lineascan.build', - }, - }, - [InfuraNetworkType['linea-mainnet']]: { - rpcUrl: 'https://linea-mainnet.infura.io/v3/some-infura-project-id', - chainId: '0xe708' as const, - ticker: 'ETH', - rpcPrefs: { - blockExplorerUrl: 'https://lineascan.build', - }, - }, - }; + describe(name, () => { + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`given the ID of the Infura-supported chain "${infuraNetworkType}" that a network configuration is filed under`, () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId( + { + networkConfigurationsByChainId: { + [infuraChainId]: registeredNetworkConfiguration, + }, + }, + ), + }, + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByChainId({ + controller, + messenger, + chainId: infuraChainId, + }); + + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, + ); + }, + ); + }); + }); + } - it.each(getKnownPropertyNames(InfuraNetworkType))( - 'constructs a network configuration for the %s network', - async (infuraNetworkType) => { + describe('given the ID of a non-Infura-supported chain that a network configuration is filed under', () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); await withController( - { infuraProjectId }, + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': registeredNetworkConfiguration, + }, + }), + }, ({ controller, messenger }) => { - const networkConfiguration = - getNetworkConfigurationByNetworkClientId({ + const returnedNetworkConfiguration = + getNetworkConfigurationByChainId({ controller, messenger, - networkClientId: infuraNetworkType, + chainId: '0x1337', }); - expect(networkConfiguration).toStrictEqual( - expectedInfuraNetworkConfigurationsByType[infuraNetworkType], + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, ); }, ); - }, - ); - - it('returns the network configuration in state that matches the given ID, if there is one', async () => { - await withController( - { - infuraProjectId, - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - }, - ({ controller, messenger }) => { - const networkConfiguration = - getNetworkConfigurationByNetworkClientId({ - controller, - messenger, - networkClientId: 'AAAA-AAAA-AAAA-AAAA', - }); - - expect(networkConfiguration).toStrictEqual({ - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }); - }, - ); + }); }); - it('returns undefined if the given ID does not match a network configuration in state', async () => { - await withController( - { infuraProjectId }, - ({ controller, messenger }) => { - const networkConfiguration = - getNetworkConfigurationByNetworkClientId({ + describe('given the ID of a chain that no network configuration is filed under', () => { + it('returns undefined', async () => { + await withController(({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByChainId({ controller, messenger, - networkClientId: 'nonexistent', + chainId: '0x9999999999999', }); - expect(networkConfiguration).toBeUndefined(); - }, - ); + expect(returnedNetworkConfiguration).toBeUndefined(); + }); + }); }); }); } - describe('upsertNetworkConfiguration', () => { - describe('when no id is provided and the rpcUrl of the given network configuration does not match an existing network configuration', () => { - it('adds the network configuration to state without updating or removing any existing network configurations', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('BBBB-BBBB-BBBB-BBBB'); - - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network.2', - chainId: toHex(222), - ticker: 'TICKER2', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, + for (const [name, getNetworkConfigurationByNetworkClientId] of [ + [ + 'getNetworkConfigurationByNetworkClientId', + ({ + controller, + networkClientId, + }: { + controller: NetworkController; + networkClientId: NetworkClientId; + }) => + controller.getNetworkConfigurationByNetworkClientId(networkClientId), + ], + [ + 'NetworkController:getNetworkConfigurationByNetworkClientId', + ({ + messenger, + networkClientId, + }: { + messenger: ControllerMessenger< + NetworkControllerActions, + NetworkControllerEvents + >; + networkClientId: NetworkClientId; + }) => + messenger.call( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + networkClientId, + ), + ], + ] as const) { + // This is a string! + // eslint-disable-next-line jest/valid-title + describe(name, () => { + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`given the ID of a network client that corresponds to an RPC endpoint for the Infura network "${infuraNetworkType}" in a network configuration`, () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: registeredNetworkConfiguration, + }, + }, + }, + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByNetworkClientId({ + controller, + messenger, + networkClientId: infuraNetworkType, + }); + + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, + ); + }, + ); + }); + }); + } + + describe('given the ID of a network client that corresponds to a custom RPC endpoint in a network configuration', () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }); + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': registeredNetworkConfiguration, + }, + }, + }, + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByNetworkClientId({ + controller, + messenger, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, + ); + }, + ); + }); + }); + + describe('given the ID of a network client that does not correspond to any RPC endpoint in a network configuration', () => { + it('returns undefined', async () => { + await withController(({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByNetworkClientId({ + controller, + messenger, + networkClientId: 'nonexistent', + }); + + expect(returnedNetworkConfiguration).toBeUndefined(); + }); + }); + }); + }); + } + + describe('addNetwork', () => { + it('throws if the chainId field is a string, but not a 0x-prefixed hex number', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + // @ts-expect-error Intentionally passing bad input + chainId: '12345', + }), + ), + ).toThrow( + new Error( + `Could not add network: Invalid \`chainId\` '12345' (must start with "0x" and not exceed the maximum)`, + ), + ); + }); + }); + + it('throws if the chainId field is greater than the maximum allowed chain ID', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + // False negative - this is a number. + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + chainId: toHex(MAX_SAFE_CHAIN_ID + 1), + }), + ), + ).toThrow( + new Error( + `Could not add network: Invalid \`chainId\` '0xfffffffffffed' (must start with "0x" and not exceed the maximum)`, + ), + ); + }); + }); + + it('throws if defaultBlockExplorerUrlIndex does not refer to an entry in blockExplorerUrls', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 99999, + }), + ), + ).toThrow( + new Error( + 'Could not add network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }); + }); + + it('throws if blockExplorerUrls is non-empty, but defaultBlockExplorerUrlIndex is missing', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + blockExplorerUrls: ['https://block.explorer'], + }), + ), + ).toThrow( + new Error( + 'Could not add network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }); + }); + + it('throws if the rpcEndpoints field is an empty array', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [], + }), + ), + ).toThrow( + new Error( + 'Could not add network: `rpcEndpoints` must be a non-empty array', + ), + ); + }); + }); + + it('throws if one of the rpcEndpoints has an invalid url property', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'clearly-not-a-url', + }), + ], + }), + ), + ).toThrow( + new Error( + "Could not add network: An entry in `rpcEndpoints` has invalid URL 'clearly-not-a-url'", + ), + ); + }); + }); + + it('throws if the URLs of two or more RPC endpoints have similar schemes (comparing case-insensitively)', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'HTTPS://foo.com/bar', + }), + ], + }), + ), + ).toThrow( + new Error( + 'Could not add network: Each entry in rpcEndpoints must have a unique URL', + ), + ); + }); + }); + + it('throws if the URLs of two or more RPC endpoints have similar hostnames (comparing case-insensitively)', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://fOo.CoM/bar', + }), + ], + }), + ), + ).toThrow( + new Error( + 'Could not add network: Each entry in rpcEndpoints must have a unique URL', + ), + ); + }); + }); + + it('does not throw if the URLs of two or more RPC endpoints have similar paths (comparing case-insensitively)', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/BAR', + }), + ], + }), + ), + ).not.toThrow(); + }); + }); + + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`throws if rpcEndpoints contains an Infura RPC endpoint which is already present in the network configuration for the Infura-supported chain ${infuraChainId}`, async () => { + const infuraRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [infuraChainId]: buildInfuraNetworkConfiguration( + infuraNetworkType, + { + rpcEndpoints: [infuraRpcEndpoint], + }, + ), + }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + rpcEndpoints: [infuraRpcEndpoint], + }), + ), + ).toThrow( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not add network that points to same RPC endpoint as existing network for chain ${infuraChainId} ('${infuraNetworkNickname}')`, + ); + }, + ); + }); + } + + it('throws if rpcEndpoints contains a custom RPC endpoint which is already present in another network configuration (comparing URLs case-insensitively)', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x2448': buildNetworkConfiguration({ + chainId: '0x2448', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'http://test.endpoint/bar', + }), + ], + }), + }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'http://test.endpoint/foo', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'HTTP://TEST.ENDPOINT/bar', + }), + ], + }), + ), + ).toThrow( + "Could not add network that points to same RPC endpoint as existing network for chain 0x2448 ('Some Network')", + ); + }, + ); + }); + + it('throws if two or more RPC endpoints are exactly the same object', async () => { + await withController(({ controller }) => { + const rpcEndpoint = buildAddNetworkCustomRpcEndpointFields(); + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint, rpcEndpoint], + }), + ), + ).toThrow( + 'Could not add network: Each entry in rpcEndpoints must be unique', + ); + }); + }); + + it('throws if there are two or more different Infura RPC endpoints', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + }), + }, + }), + }, + ({ controller }) => { + const mainnetRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.mainnet, + ); + const goerliRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.goerli, + ); + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: ChainId.mainnet, + rpcEndpoints: [mainnetRpcEndpoint, goerliRpcEndpoint], + }), + ), + ).toThrow( + 'Could not add network: There cannot be more than one Infura RPC endpoint', + ); + }, + ); + }); + + it('throws if defaultRpcEndpointIndex does not refer to an entry in rpcEndpoints', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + defaultRpcEndpointIndex: 99999, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + url: 'https://bar.com', + }), + ], + }), + ), + ).toThrow( + new Error( + 'Could not add network: `defaultRpcEndpointIndex` must refer to an entry in `rpcEndpoints`', + ), + ); + }); + }); + + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`throws if a network configuration for the Infura network "${infuraNetworkNickname}" is already registered under the given chain ID`, async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: infuraChainId, + }), + ), + ).toThrow( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not add network for chain ${infuraChainId} as another network for that chain already exists ('${infuraNetworkNickname}')`, + ); + }, + ); + }); + } + + it('throws if a custom network is already registered under the given chain ID', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Some Network', + }), + }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + }), + ), + ).toThrow( + `Could not add network for chain 0x1337 as another network for that chain already exists ('Some Network')`, + ); + }, + ); + }); + + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; + + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`given the ID of the Infura-supported chain ${infuraChainId}`, () => { + it('creates a new network client for not only each custom RPC endpoint, but also the Infura RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + }, + }), + infuraProjectId, + }, + ({ controller }) => { + const defaultRpcEndpoint = + buildInfuraRpcEndpoint(infuraNetworkType); + + controller.addNetwork({ + blockExplorerUrls: [], + chainId: infuraChainId, + defaultRpcEndpointIndex: 1, + name: infuraNetworkType, + nativeCurrency: infuraNativeTokenName, + rpcEndpoints: [ + defaultRpcEndpoint, + { + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + { + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/3', + }, + ], + }); + + // Skipping the 1st call because it's for the initial state + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 2, + { + infuraProjectId, + chainId: infuraChainId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 3, + { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 4, + { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + ); + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + [infuraNetworkType]: { + chainId: infuraChainId, + network: infuraNetworkType, + type: NetworkClientType.Infura, + }, + 'BBBB-BBBB-BBBB-BBBB': { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + 'CCCC-CCCC-CCCC-CCCC': { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('adds the network configuration to state under the chain ID', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + }, + }), + }, + ({ controller }) => { + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + { + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId[infuraChainId], + ).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + { + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }, + ], + }); + }, + ); + }); + + it('emits the NetworkController:networkAdded event', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + }, + }), + }, + ({ controller, messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); + + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }, + ], + }); + + expect(networkAddedEventListener).toHaveBeenCalledWith({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }, + ], + }); + }, + ); + }); + + it('returns the newly added network configuration', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + }, + }), + }, + ({ controller }) => { + const newNetworkConfiguration = controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }, + ], + }); + + expect(newNetworkConfiguration).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }, + ], + }); + }, + ); + }); + }); + } + + describe('given the ID of a non-Infura-supported chain', () => { + it('throws (albeit for a different reason) if rpcEndpoints contains an Infura RPC endpoint that represents a different chain that the one being added', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const defaultRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.mainnet, + ); + + await withController( + { + state: { + selectedNetworkClientId: InfuraNetworkType.goerli, + networkConfigurationsByChainId: { + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), + }, + }, + }, + ({ controller }) => { + expect(() => + controller.addNetwork({ + blockExplorerUrls: [], + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + defaultRpcEndpoint, + { + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }), + ).toThrow( + new Error( + "Could not add network with chain ID 0x1337 and Infura RPC endpoint for 'Mainnet' which represents 0x1, as the two conflict", + ), + ); + }, + ); + }); + + it('creates a new network client for each given RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + + await withController(({ controller }) => { + controller.addNetwork({ + blockExplorerUrls: [], + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + }, + { + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + const networkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClient1.configuration).toStrictEqual({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + const networkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + expect(networkClient2.configuration).toStrictEqual({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + }); + }); + + it('adds the network configuration to state under the chain ID', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + + await withController(({ controller }) => { + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + }, + { + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + }, + { + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + }); + }); + + it('emits the NetworkController:networkAdded event', async () => { + uuidV4Mock.mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA'); + + await withController(({ controller, messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); + + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + }); + + expect(networkAddedEventListener).toHaveBeenCalledWith({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + }); + }); + }); + + it('returns the newly added network configuration', async () => { + uuidV4Mock.mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA'); + + await withController(({ controller, messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); + + const newNetworkConfiguration = controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + }); + + expect(newNetworkConfiguration).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + }); + }); + }); + }); + }); + + describe('updateNetwork', () => { + it('throws if the given chain ID does not refer to an existing network configuration', async () => { + await withController(async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + chainId: '0x1337', + }), + ), + ).rejects.toThrow( + new Error( + "Could not update network: Cannot find network configuration for chain '0x1337'", + ), + ); + }); + }); + + it('throws if defaultBlockExplorerUrlIndex does not refer to an entry in blockExplorerUrls', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect(() => + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 99999, + }), + ), + ).rejects.toThrow( + new Error( + 'Could not update network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }, + ); + }); + + it('throws if blockExplorerUrls is non-empty, but defaultBlockExplorerUrlIndex is cleared', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect(() => + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + ...networkConfigurationToUpdate, + defaultBlockExplorerUrlIndex: undefined, + }), + ), + ).rejects.toThrow( + new Error( + 'Could not update network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }, + ); + }); + + it('throws if the new chainId field is a string, but not a 0x-prefixed hex number', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + // @ts-expect-error Intentionally passing bad input + chainId: '12345', + }), + ), + ).rejects.toThrow( + new Error( + `Could not update network: Invalid \`chainId\` '12345' (must start with "0x" and not exceed the maximum)`, + ), + ); + }, + ); + }); + + it('throws if the new chainId field is greater than the maximum allowed chain ID', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + // False negative - this is a number. + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + chainId: toHex(MAX_SAFE_CHAIN_ID + 1), + }), + ), + ).rejects.toThrow( + new Error( + `Could not update network: Invalid \`chainId\` '0xfffffffffffed' (must start with "0x" and not exceed the maximum)`, + ), + ); + }, + ); + }); + + it('throws if the new rpcEndpoints field is an empty array', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildNetworkConfiguration({ + rpcEndpoints: [], + }), + ), + ).rejects.toThrow( + new Error( + 'Could not update network: `rpcEndpoints` must be a non-empty array', + ), + ); + }, + ); + }); + + it('throws if one of the new rpcEndpoints is custom and uses an Infura network name for networkClientId', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + networkClientId: InfuraNetworkType.mainnet, + url: 'https://test.network', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network: Custom RPC endpoint 'https://test.network' has invalid network client ID 'mainnet'", + ), + ); + }, + ); + }); + + it('throws if one of the new rpcEndpoints has an invalid url property', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'clearly-not-a-url', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network: An entry in `rpcEndpoints` has invalid URL 'clearly-not-a-url'", + ), + ); + }, + ); + }); + + it('throws if one of the new RPC endpoints has a networkClientId that does not refer to a registered network client', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com', + networkClientId: 'not-a-real-network-client-id', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network: RPC endpoint 'https://foo.com' refers to network client 'not-a-real-network-client-id' that does not exist", + ), + ); + }, + ); + }); + + it('throws if the URLs of two or more RPC endpoints have similar schemes (comparing case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration(); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'HTTPS://foo.com/bar', + }), + ], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must have a unique URL', + ), + ); + }, + ); + }); + + it('throws if the URLs of two or more RPC endpoints have similar hostnames (comparing case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration(); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://fOo.CoM/bar', + }), + ], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must have a unique URL', + ), + ); + }, + ); + }); + + it('does not throw if the URLs of two or more RPC endpoints have similar paths (comparing case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://foo.com/bar', + }), + ], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + const result = await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[0], + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/BAR', + }), + ], + }); + + expect(result).toBeDefined(); + }, + ); + }); + + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`throws if an Infura RPC endpoint is being added which is already present in the network configuration for the Infura-supported chain ${infuraChainId}`, async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + const infuraRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [infuraChainId]: buildInfuraNetworkConfiguration( + infuraNetworkType, + { + rpcEndpoints: [infuraRpcEndpoint], + }, + ), + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [infuraRpcEndpoint], + }), + ).rejects.toThrow( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network to point to same RPC endpoint as existing network for chain ${infuraChainId} ('${infuraNetworkNickname}')`, + ); + }, + ); + }); + } + + it('throws if a custom RPC endpoint is being added which is already present in another network configuration (comparing URLs case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'http://test.endpoint/foo', + }), + ], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x2448': buildNetworkConfiguration({ + chainId: '0x2448', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'http://test.endpoint/bar', + }), + ], + }), + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'http://test.endpoint/foo', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'HTTP://TEST.ENDPOINT/bar', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network to point to same RPC endpoint as existing network for chain 0x2448 ('Some Network')", + ), + ); + }, + ); + }); + + it('throws if two or more RPC endpoints are exactly the same object', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration(); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + const rpcEndpoint = buildUpdateNetworkCustomRpcEndpointFields(); + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint, rpcEndpoint], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must be unique', + ), + ); + }, + ); + }); + + it('throws if two or more RPC endpoints have the same networkClientId', async () => { + const rpcEndpoint = buildCustomRpcEndpoint({ + url: 'https://test.endpoint', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + rpcEndpoints: [rpcEndpoint], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint, + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.endpoint/2', + networkClientId: rpcEndpoint.networkClientId, + }), + ], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must have a unique networkClientId', + ), + ); + }, + ); + }); + + it('throws (albeit for a different reason) if there are two or more different Infura RPC endpoints', async () => { + const [mainnetRpcEndpoint, goerliRpcEndpoint] = [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet), + buildInfuraRpcEndpoint(InfuraNetworkType.goerli), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + name: 'Mainnet', + chainId: ChainId.mainnet, + rpcEndpoints: [mainnetRpcEndpoint], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [ChainId.mainnet]: networkConfigurationToUpdate, + [ChainId.goerli]: buildNetworkConfiguration({ + name: 'Goerli', + chainId: ChainId.goerli, + rpcEndpoints: [goerliRpcEndpoint], + }), + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork(ChainId.mainnet, { + ...networkConfigurationToUpdate, + rpcEndpoints: [mainnetRpcEndpoint, goerliRpcEndpoint], + }), + ).rejects.toThrow( + new Error( + "Could not update network to point to same RPC endpoint as existing network for chain 0x5 ('Goerli')", + ), + ); + }, + ); + }); + + it('throws if the new defaultRpcEndpointIndex does not refer to an entry in rpcEndpoints', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + url: 'https://bar.com', + }), + ], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 99999, + }), + ).rejects.toThrow( + new Error( + 'Could not update network: `defaultRpcEndpointIndex` must refer to an entry in `rpcEndpoints`', + ), + ); + }, + ); + }); + + it('throws if a RPC endpoint being removed is represented by the selected network client, and replacementSelectedRpcEndpointIndex is not specified', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://bar.com', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [networkConfigurationToUpdate.rpcEndpoints[1]], + }), + ).rejects.toThrow( + new Error( + "Could not update network: Cannot update RPC endpoints in such a way that the selected network 'AAAA-AAAA-AAAA-AAAA' would be removed without a replacement. Choose a different RPC endpoint as the selected network via the `replacementSelectedRpcEndpointIndex` option.", + ), + ); + }, + ); + }); + + it('throws if a RPC endpoint being removed is represented by the selected network client, and an invalid replacementSelectedRpcEndpointIndex is not specified', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://bar.com', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [networkConfigurationToUpdate.rpcEndpoints[1]], + }, + { replacementSelectedRpcEndpointIndex: 9999 }, + ), + ).rejects.toThrow( + new Error( + `Could not update network: \`replacementSelectedRpcEndpointIndex\` 9999 does not refer to an entry in \`rpcEndpoints\``, + ), + ); + }, + ); + }); + + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`if the existing chain ID is the Infura-supported chain ${infuraChainId} and is not being changed`, () => { + describe('when a new Infura RPC endpoint is being added', () => { + it('creates and registers a new network client for the RPC endpoint', async () => { + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const infuraRpcEndpoint = + buildInfuraRpcEndpoint(infuraNetworkType); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); + + // Skipping network client creation for existing RPC endpoints + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(3, { + chainId: infuraChainId, + infuraProjectId: 'some-infura-project-id', + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toStrictEqual({ + [infuraNetworkType]: { + chainId: infuraChainId, + infuraProjectId: 'some-infura-project-id', + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + 'AAAA-AAAA-AAAA-AAAA': { + chainId: infuraChainId, + rpcUrl: 'https://rpc.network', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + 'ZZZZ-ZZZZ-ZZZZ-ZZZZ': { + chainId: '0x9999', + rpcUrl: 'https://selected.endpoint', + ticker: 'TEST-9999', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('stores the network configuration with the new RPC endpoint in state', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const infuraRpcEndpoint = + buildInfuraRpcEndpoint(infuraNetworkType); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + { + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura, + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }, + ], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const infuraRpcEndpoint = + buildInfuraRpcEndpoint(infuraNetworkType); + + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + { + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura, + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }, + ], + }); + }, + ); + }); + }); + + describe('when new custom RPC endpoints are being added', () => { + it('creates and registers new network clients for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 1', + url: 'https://rpc.endpoint/1', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + ]; + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + rpcEndpoint1, + rpcEndpoint2, + ], + }); + + // Skipping network client creation for existing RPC endpoints + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(3, { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(4, { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toStrictEqual({ + [infuraNetworkType]: { + chainId: infuraChainId, + infuraProjectId: 'some-infura-project-id', + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }, + 'AAAA-AAAA-AAAA-AAAA': { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + 'BBBB-BBBB-BBBB-BBBB': { + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + 'ZZZZ-ZZZZ-ZZZZ-ZZZZ': { + chainId: '0x9999', + rpcUrl: 'https://selected.endpoint', + ticker: 'TEST-9999', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('assigns the ID of the created network client to each RPC endpoint in state', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + { + name: 'Endpoint 2', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + name: 'Endpoint 3', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + { + name: 'Endpoint 2', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + name: 'Endpoint 3', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + }); + }, + ); + }); + }); + + describe('when some custom RPC endpoints are being removed', () => { + it('destroys and unregisters existing network clients for the RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('updates the network configuration in state', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + }, + ); + }); + + describe('when one is represented by the selected network client (and a replacement is specified)', () => { + describe('if the new replacement RPC endpoint already exists', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = + controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = + controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + + describe('if the replacement RPC endpoint is being added', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[2]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = + controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'CCCC-CCCC-CCCC-CCCC', + ); + const networkClient2 = + controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 3'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.network/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[2]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'CCCC-CCCC-CCCC-CCCC', + }), + expect.objectContaining({ + op: 'replace', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + }); + + describe('when the URL of an RPC endpoint is changed (using networkClientId as identification)', () => { + it('destroys and unregisters the network client for the previous version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('creates and registers a network client for the new version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'BBBB-BBBB-BBBB-BBBB': { + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('updates the network configuration in state with a new network client ID for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + name: 'Endpoint 1', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: 'custom', + url: 'https://some.other.url', + }, + ], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + name: 'Endpoint 1', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: 'custom', + url: 'https://some.other.url', + }, + ], + }); + }, + ); + }); + + describe('if the previous version of the RPC endpoint was represented by the selected network client', () => { + it('invisibly selects the network client for the new RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + + describe('when all of the RPC endpoints are simply being shuffled', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('updates the network configuration in state with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + }, + ); + }); + + it('returns the network configuration with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + }, + ); + }); + }); + + describe('when the networkClientId of some custom RPC endpoints are being cleared', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('does not touch the network configuration in state, as if the network client IDs had not been cleared', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const previousNetworkConfigurationsByChainId = + controller.state.networkConfigurationsByChainId; + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).toStrictEqual(previousNetworkConfigurationsByChainId); + }, + ); + }); + + it('returns the network configuration, untouched', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual( + networkConfigurationToUpdate, + ); + }, + ); + }); + }); + + describe('when no RPC endpoints are being changed', () => { + it('does not touch the network client registry', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + name: 'Some Name', + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + name: 'Some Other Name', + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + }); + }); + } + + describe('if the existing chain ID is a non-Infura-supported chain and is not being changed', () => { + it('throws (albeit for a different reason) if an Infura RPC endpoint is being added that represents a different chain than the one being updated', async () => { + const defaultRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.mainnet, + ); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + }, + selectedNetworkClientId: InfuraNetworkType.mainnet, + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + defaultRpcEndpoint, + ], + }), + ).rejects.toThrow( + "Could not update network to point to same RPC endpoint as existing network for chain 0x1 ('Mainnet')", + ); + }, + ); + }); + + describe('when new custom RPC endpoints are being added', () => { + it('creates and registers new network clients for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const rpcEndpoint1 = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [rpcEndpoint1], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + rpcEndpoint1, + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'AAAA-AAAA-AAAA-AAAA': { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + 'BBBB-BBBB-BBBB-BBBB': { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + 'CCCC-CCCC-CCCC-CCCC': { + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('assigns the ID of the created network client to each RPC endpoint in state', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const rpcEndpoint1 = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + rpcEndpoint1, + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const rpcEndpoint1 = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + rpcEndpoint1, + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + }); + }, + ); + }); + }); + + describe('when some custom RPC endpoints are being removed', () => { + it('destroys and unregisters existing network clients for the RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('updates the network configuration in state', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + }, + ); + }); + + describe('when one is represented by the selected network client (and a replacement is specified)', () => { + describe('if the replacement RPC endpoint already exists', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + + describe('if the replacement RPC endpoint is being added', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[2]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'CCCC-CCCC-CCCC-CCCC', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 3'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.network/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[2]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'CCCC-CCCC-CCCC-CCCC', + }), + expect.objectContaining({ + op: 'replace', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + }); + + describe('when the URL of an RPC endpoint is changed (using networkClientId as identification)', () => { + it('destroys and unregisters the network client for the previous version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('creates and registers a network client for the new version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'BBBB-BBBB-BBBB-BBBB': { + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('updates the network configuration in state with a new network client ID for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + name: 'Endpoint 1', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: 'custom', + url: 'https://some.other.url', + }, + ], + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + name: 'Endpoint 1', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: 'custom', + url: 'https://some.other.url', + }, + ], + }); + }, + ); + }); + + describe('if the previous version of the RPC endpoint was represented by the selected network client', () => { + it('invisibly selects the network client for the new RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + + describe('when all of the RPC endpoints are simply being shuffled', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://rpc.endpoint/3', + }), + ]; + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('updates the network configuration in state with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://rpc.endpoint/3', + }), + ]; + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + }, + ); + }); + + it('returns the network configuration with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://rpc.endpoint/3', + }), + ]; + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + }, + ); + }); + }); + + describe('when the networkClientId of some custom RPC endpoints are being cleared', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('does not touch the network configuration in state, as if the network client IDs had not been cleared', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const previousNetworkConfigurationsByChainId = + controller.state.networkConfigurationsByChainId; + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).toStrictEqual(previousNetworkConfigurationsByChainId); + }, + ); + }); + + it('returns the network configuration, untouched', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual( + networkConfigurationToUpdate, + ); + }, + ); + }); + }); + + describe('when no RPC endpoints are being changed', () => { + it('does not touch the network client registry', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + name: 'Some Other Name', + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + }); + }); + + const possibleInfuraNetworkTypes = Object.values(InfuraNetworkType); + possibleInfuraNetworkTypes.forEach( + (infuraNetworkType, infuraNetworkTypeIndex) => { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + const anotherInfuraNetworkType = + possibleInfuraNetworkTypes[ + (infuraNetworkTypeIndex + 1) % possibleInfuraNetworkTypes.length + ]; + const anotherInfuraChainId = ChainId[anotherInfuraNetworkType]; + const anotherInfuraNativeTokenName = + NetworksTicker[anotherInfuraNetworkType]; + const anotherInfuraNetworkNickname = + NetworkNickname[anotherInfuraNetworkType]; + + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`if the chain ID is being changed from a non-Infura-supported chain to the Infura-supported chain ${infuraChainId}`, () => { + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`throws if a network configuration for the Infura network "${infuraNetworkNickname}" is already registered under the new chain ID`, async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ chainId: '0x1337' }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }), + ).rejects.toThrow( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot move network from chain 0x1337 to ${infuraChainId} as another network for that chain already exists ('${infuraNetworkNickname}')`, + ); + }, + ); + }); + + it('throws (albeit for a different reason) if an Infura RPC endpoint is being added that represents a different chain than the one being changed to', async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ chainId: '0x1337' }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [anotherInfuraChainId]: buildInfuraNetworkConfiguration( + anotherInfuraNetworkType, + ), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + buildInfuraRpcEndpoint(anotherInfuraNetworkType), + ], + }), + ).rejects.toThrow( + new Error( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network to point to same RPC endpoint as existing network for chain ${anotherInfuraChainId} ('${anotherInfuraNetworkNickname}')`, + ), + ); + }, + ); + }); + + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + // TODO: This is where we stopped + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: infuraChainId, + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + }); + }, + ); + }); + + it('destroys and unregisters every network client for each of the RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn( + existingNetworkClient1, + 'destroy', + ); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn( + existingNetworkClient2, + 'destroy', + ); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('creates and registers new network clients for each of the given RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'CCCC-CCCC-CCCC-CCCC': { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + 'DDDD-DDDD-DDDD-DDDD': { + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + }); }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( { - referrer: 'https://test-dapp.com', - source: 'dapp', + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: infuraChainId, + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + }); }, ); + }); + + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: 'https://test.network.2', - chainId: toHex(222), - ticker: 'TICKER2', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + expect.objectContaining({ + op: 'add', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); }, - id: 'BBBB-BBBB-BBBB-BBBB', - }, + ); }); - }, - ); - }); + }); + }); - it('removes properties not specific to the NetworkConfiguration interface before persisting it to state', async function () { - await withController(async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`if the chain ID is being changed from the Infura-supported chain ${infuraChainId} to a non-Infura-supported chain`, () => { + it('throws if a network configuration for a custom network is already registered under the new chain ID', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Some Network', + }), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - // @ts-expect-error We are intentionally passing bad input. - invalidKey: 'some value', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); - - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + async ({ controller }) => { + await expect( + controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }), + ).rejects.toThrow( + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot move network from chain ${infuraChainId} to 0x1337 as another network for that chain already exists ('Some Network')`, + ); }, - id: 'AAAA-AAAA-AAAA-AAAA', - }, + ); }); - }); - }); - it('creates a new network client for the network configuration and adds it to the registry', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }) - .mockReturnValue(newCustomNetworkClient); + it('throws if the existing Infura RPC endpoint is not removed in the process of changing the chain ID', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); - await controller.upsertNetworkConfiguration( + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller }) => { + await expect( + controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }), + ).rejects.toThrow( + new Error( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network with chain ID 0x1337 and Infura RPC endpoint for '${infuraNetworkNickname}' which represents ${infuraChainId}, as the two conflict`, + ), + ); }, ); + }); - const networkClients = controller.getNetworkClientRegistry(); - expect(Object.keys(networkClients)).toHaveLength(7); - expect(networkClients).toMatchObject({ - 'AAAA-AAAA-AAAA-AAAA': expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }, - }), - }); - }, - ); - }); + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each custom RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); - it('updates state only after creating the new network client', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller, messenger }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }) - .mockReturnValue(newCustomNetworkClient); + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); - await waitForStateChanges({ - messenger, - count: 1, - operation: async () => { - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork( + infuraChainId, { - referrer: 'https://test-dapp.com', - source: 'dapp', + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], }, + { replacementSelectedRpcEndpointIndex: 0 }, ); - }, - beforeResolving: () => { - const newNetworkClient = controller.getNetworkClientById( - 'AAAA-AAAA-AAAA-AAAA', - ); - expect(newNetworkClient).toBeDefined(); - }, - }); - }, - ); - }); - describe('if the setActive option is not given', () => { - it('does not update selectedNetworkClientId to refer to the new network configuration by default', async () => { - await withController(async ({ controller }) => { - const originalSelectedNetworkClientId = - controller.state.selectedNetworkClientId; + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty('0x1337'); + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + }); + }, + ); + }); - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); + it('destroys and unregisters every network client for each of the custom RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); - await controller.upsertNetworkConfiguration( + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn( + existingNetworkClient1, + 'destroy', + ); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn( + existingNetworkClient2, + 'destroy', + ); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); }, ); + }); - expect(controller.state.selectedNetworkClientId).toStrictEqual( - originalSelectedNetworkClientId, + it('creates and registers new network clients for each of the given custom RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', ); - }); - }); - it('does not re-point the provider and block tracker proxies to the new network by default', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const builtInNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from built-in network', + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'ETH', + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, - ]); - const builtInNetworkClient = buildFakeClient( - builtInNetworkProvider, - ); - const newCustomNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from custom network', + }, + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], }, - }, - ]); - const newCustomNetworkClient = buildFakeClient( - newCustomNetworkProvider, - ); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - builtInNetworkClient, - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - // Will use mainnet by default - await controller.initializeProvider(); + { replacementSelectedRpcEndpointIndex: 0 }, + ); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const result = await provider.request({ - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }); - expect(result).toBe('test response from built-in network'); - }, - ); - }); - }); + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'CCCC-CCCC-CCCC-CCCC': { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + 'DDDD-DDDD-DDDD-DDDD': { + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); - describe('if the setActive option is false', () => { - it('does not update selectedNetworkClientId to refer to the new network configuration', async () => { - await withController(async ({ controller }) => { - const originalSelectedNetworkClientId = - controller.state.selectedNetworkClientId; + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, + await withController( { - setActive: false, - referrer: 'https://test-dapp.com', - source: 'dapp', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - ); + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(buildFakeClient()); + + const updatedNetworkConfiguration = + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); - expect(controller.state.selectedNetworkClientId).toStrictEqual( - originalSelectedNetworkClientId, + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + }); + }, ); }); - }); - it('does not re-point the provider and block tracker proxies to the new network', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const builtInNetworkProvider = buildFakeProvider([ + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from built-in network', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, }, }, - ]); - const builtInNetworkClient = buildFakeClient( - builtInNetworkProvider, - ); - const newCustomNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from custom network', - }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); }, - ]); - const newCustomNetworkClient = buildFakeClient( - newCustomNetworkProvider, ); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - builtInNetworkClient, - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - // Will use mainnet by default - await controller.initializeProvider(); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); - await controller.upsertNetworkConfiguration( + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, }, - { - setActive: false, - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + expect.objectContaining({ + op: 'add', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); }, ); - - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const result = await provider.request({ - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }); - expect(result).toBe('test response from built-in network'); - }, - ); + }); + }); }); - }); - describe('if the setActive option is true', () => { - it('updates selectedNetworkClientId to refer to the new network configuration', async () => { - await withController(async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients() - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }) - .mockReturnValue(newCustomNetworkClient); + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`if the chain ID is being changed from the Infura-supported chain ${infuraChainId} to a different Infura-supported chain ${anotherInfuraChainId}`, () => { + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + it(`throws if a network configuration for the Infura network "${infuraNetworkNickname}" is already registered under the new chain ID`, async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); - await controller.upsertNetworkConfiguration( + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://some.chainscan.io', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + [anotherInfuraChainId]: buildInfuraNetworkConfiguration( + anotherInfuraNetworkType, + ), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, }, - { - setActive: true, - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller }) => { + await expect( + controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }), + ).rejects.toThrow( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot move network from chain ${infuraChainId} to ${anotherInfuraChainId} as another network for that chain already exists ('${anotherInfuraNetworkNickname}')`, + ); }, ); - - expect(controller.state.selectedNetworkClientId).toBe( - 'AAAA-AAAA-AAAA-AAAA', - ); }); - }); - refreshNetworkTests({ - expectedNetworkClientConfiguration: - buildCustomNetworkClientConfiguration({ - rpcUrl: 'https://some.other.network', - chainId: toHex(222), - ticker: 'TICKER2', - }), - initialState: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - operation: async (controller) => { - uuidV4Mock.mockReturnValue('BBBB-BBBB-BBBB-BBBB'); + it('throws if the existing Infura RPC endpoint is not updated in the process of changing the chain ID', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); - await controller.upsertNetworkConfiguration( + await withController( { - rpcUrl: 'https://some.other.network', - chainId: toHex(222), - ticker: 'TICKER2', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - { - setActive: true, - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller }) => { + await expect( + controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }), + ).rejects.toThrow( + new Error( + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Could not update network with chain ID ${anotherInfuraChainId} and Infura RPC endpoint for '${infuraNetworkNickname}' which represents ${infuraChainId}, as the two conflict`, + ), + ); }, ); - }, - }); - }); + }); - it('calls trackMetaMetricsEvent with details about the new network', async () => { - const trackMetaMetricsEventSpy = jest.fn(); + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each custom RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); - await withController( - { - trackMetaMetricsEvent: trackMetaMetricsEventSpy, - }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, + await withController( { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); - - expect(trackMetaMetricsEventSpy).toHaveBeenCalledWith({ - event: 'Custom Network Added', - category: 'Network', - referrer: { - url: 'https://test-dapp.com', - }, - properties: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: toHex(111), - symbol: 'TICKER', - source: 'dapp', - }, - }); - }, - ); - }); - }); - - describe.each([ - ['case-sensitively', 'https://test.network', 'https://test.network'], - ['case-insensitively', 'https://test.network', 'https://TEST.NETWORK'], - ])( - 'when no id is provided and the rpcUrl of the given network configuration matches an existing network configuration in state (%s)', - (_qualifier, oldRpcUrl, newRpcUrl) => { - it('completely overwrites the existing network configuration in state, but does not update or remove any other network configurations', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: oldRpcUrl, - chainId: toHex(222), - ticker: 'TICKER2', - id: 'BBBB-BBBB-BBBB-BBBB', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, + infuraProjectId: 'some-infura-project-id', }, - }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + async ({ controller }) => { + mockCreateNetworkClient() + .calledWith({ + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, + }) + .mockReturnValue(buildFakeClient()); - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - id: 'BBBB-BBBB-BBBB-BBBB', - }, - }); - }, - ); - }); + const anotherInfuraRpcEndpoint = buildInfuraRpcEndpoint( + anotherInfuraNetworkType, + ); + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); - it('removes properties not specific to the NetworkConfiguration interface before persisting it to state', async function () { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(anotherInfuraChainId); + expect( + controller.state.networkConfigurationsByChainId[ + anotherInfuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + }); }, - }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - // @ts-expect-error We are intentionally passing bad input. - invalidKey: 'some value', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + ); + }); - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - id: 'AAAA-AAAA-AAAA-AAAA', - }, + it('destroys and unregisters every network client for each of the custom RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], }); - }, - ); - }); - describe('if at least the chain ID is being updated', () => { - it('destroys and removes the existing network client for the old network configuration', async () => { await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', }, async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) + mockCreateNetworkClient() .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, }) - .mockReturnValue(newCustomNetworkClient); - const networkClientToDestroy = Object.values( - controller.getNetworkClientRegistry(), - ).find(({ configuration }) => { - return ( - configuration.type === NetworkClientType.Custom && - configuration.chainId === toHex(111) && - configuration.rpcUrl === 'https://test.network' - ); - }); - assert(networkClientToDestroy); - jest.spyOn(networkClientToDestroy, 'destroy'); - - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, + .mockReturnValue(buildFakeClient()); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn( + existingNetworkClient1, + 'destroy', + ); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn( + existingNetworkClient2, + 'destroy', ); - const networkClients = controller.getNetworkClientRegistry(); - expect(networkClientToDestroy.destroy).toHaveBeenCalled(); - expect(Object.keys(networkClients)).toHaveLength(7); - expect(networkClients).not.toMatchObject({ - [oldRpcUrl]: expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: oldRpcUrl, - type: NetworkClientType.Custom, - ticker: 'TEST', - }, - }), + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + buildInfuraRpcEndpoint(anotherInfuraNetworkType), + customRpcEndpoint1, + customRpcEndpoint2, + ], }); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); }, ); }); - it('creates a new network client for the network configuration and adds it to the registry', async () => { + it('creates and registers new network clients for each of the given custom RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'ETH', + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', }, async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) + mockCreateNetworkClient() .calledWith({ - chainId: toHex(999), - rpcUrl: newRpcUrl, - type: NetworkClientType.Custom, - ticker: 'TICKER', + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, }) - .mockReturnValue(newCustomNetworkClient); + .mockReturnValue(buildFakeClient()); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + buildInfuraRpcEndpoint(anotherInfuraNetworkType), + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'TICKER', + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: anotherInfuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: anotherInfuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'CCCC-CCCC-CCCC-CCCC': { + chainId: anotherInfuraChainId, + rpcUrl: 'https://test.endpoint/1', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + 'DDDD-DDDD-DDDD-DDDD': { + chainId: anotherInfuraChainId, + rpcUrl: 'https://test.endpoint/2', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, }, - ); - - const networkClients = controller.getNetworkClientRegistry(); - expect(Object.keys(networkClients)).toHaveLength(7); - expect(networkClients).toMatchObject({ - 'AAAA-AAAA-AAAA-AAAA': expect.objectContaining({ - configuration: { - chainId: toHex(999), - rpcUrl: newRpcUrl, - type: NetworkClientType.Custom, - ticker: 'TICKER', - }, - }), }); }, ); }); - }); - describe('if the chain ID is not being updated', () => { - it('does not update the network client registry', async () => { + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', }, async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) + mockCreateNetworkClient() .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', + chainId: anotherInfuraChainId, + infuraProjectId: 'some-infura-project-id', + network: anotherInfuraNetworkType, + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Infura, }) - .mockReturnValue(newCustomNetworkClient); - const networkClientsBefore = - controller.getNetworkClientRegistry(); + .mockReturnValue(buildFakeClient()); - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(111), - ticker: 'NEW_TICKER', + const anotherInfuraRpcEndpoint = buildInfuraRpcEndpoint( + anotherInfuraNetworkType, + ); + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + }); + }, + ); + }); + + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: anotherInfuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: infuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: anotherInfuraChainId, + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + expect.objectContaining({ + op: 'add', + path: [ + 'networkConfigurationsByChainId', + anotherInfuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + }, + ); + + describe('if the chain ID is being changed from one non-Infura-supported chain to another', () => { + it('throws if a network configuration for a custom network is already registered under the new chain ID', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x2448': buildNetworkConfiguration({ + name: 'Some Network', + chainId: '0x2448', + }), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect(() => + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }), + ).rejects.toThrow( + "Cannot move network from chain 0x1337 to 0x2448 as another network for that chain already exists ('Some Network')", + ); + }, + ); + }); + + it('throws (albeit for a different reason) if an Infura RPC endpoint is being added that represents a different chain than the one being changed to', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const newRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.goerli, + ); + await expect(() => + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + rpcEndpoints: [newRpcEndpoint], + }), + ).rejects.toThrow( + new Error( + "Could not update network to point to same RPC endpoint as existing network for chain 0x5 ('Goerli')", + ), + ); + }, + ); + }); + + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + response: { + result: 'test response from 1', }, - ); - - const networkClientsAfter = - controller.getNetworkClientRegistry(); - expect(networkClientsBefore).toStrictEqual(networkClientsAfter); - }, - ); - }); - }); + }, + ]), + ]; + const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]); - it('does not call trackMetaMetricsEvent', async () => { - const trackMetaMetricsEventSpy = jest.fn(); + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - trackMetaMetricsEvent: trackMetaMetricsEventSpy, - }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty('0x2448'); + expect( + controller.state.networkConfigurationsByChainId['0x2448'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x2448', + rpcEndpoints: [ { - rpcUrl: newRpcUrl, - chainId: toHex(111), - ticker: 'NEW_TICKER', + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', }, { - referrer: 'https://test-dapp.com', - source: 'dapp', + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', }, - ); + ], + }); + }, + ); + }); - expect(trackMetaMetricsEventSpy).not.toHaveBeenCalled(); - }, - ); + it('destroys and unregisters every network client for each of the RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], }); - }, - ); - describe('when an id is provided and matches an existing network configuration in state with the rpcUrl', () => { - it('completely overwrites the existing network configuration in state, but does not update or remove any other network configurations', async () => { await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: 'https://test.network.2', - chainId: toHex(222), - ticker: 'TICKER2', - id: 'BBBB-BBBB-BBBB-BBBB', - }, + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, }, async ({ controller }) => { - await controller.upsertNetworkConfiguration( - { - id: 'BBBB-BBBB-BBBB-BBBB', - rpcUrl: 'https://test.network.2', - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, }, - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, + ]), + ]; + const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn(existingNetworkClient1, 'destroy'); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', ); + const destroySpy2 = jest.spyOn(existingNetworkClient2, 'destroy'); - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('creates and registers new network clients for each of the given RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: 'https://test.network.2', - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, }, - id: 'BBBB-BBBB-BBBB-BBBB', + ]), + ]; + const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'CCCC-CCCC-CCCC-CCCC': { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + 'DDDD-DDDD-DDDD-DDDD': { + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, }, }); }, ); }); - it('removes properties not specific to the NetworkConfiguration interface before persisting it to state', async function () { + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, }, async ({ controller }) => { - await controller.upsertNetworkConfiguration( - { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, }, - // @ts-expect-error We are intentionally passing bad input. - invalidKey: 'some value', - }, + ]), + ]; + const fakeNetworkClients = [buildFakeClient(fakeProviders[0])]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x2448', + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]); + + const updatedNetworkConfiguration = await controller.updateNetwork( + '0x1337', { - referrer: 'https://test-dapp.com', - source: 'dapp', + ...networkConfigurationToUpdate, + chainId: '0x2448', }, ); - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x2448', + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', }, - id: 'AAAA-AAAA-AAAA-AAAA', - }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], }); }, ); }); - describe('if at least the chain ID is being updated', () => { - it('destroys and removes the existing network client for the old network configuration', async () => { + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, }, }, - infuraProjectId: 'some-infura-project-id', }, async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', type: NetworkClientType.Custom, - ticker: 'TEST', }) - .mockReturnValue(newCustomNetworkClient); - const networkClientToDestroy = Object.values( - controller.getNetworkClientRegistry(), - ).find(({ configuration }) => { - return ( - configuration.type === NetworkClientType.Custom && - configuration.chainId === toHex(111) && - configuration.rpcUrl === 'https://test.network' - ); + .mockReturnValue(fakeNetworkClients[0]) + .calledWith({ + chainId: '0x2448', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', }); - assert(networkClientToDestroy); - jest.spyOn(networkClientToDestroy, 'destroy'); + expect(result1).toBe('test response from 1'); - await controller.upsertNetworkConfiguration( - { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(999), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); - const networkClients = controller.getNetworkClientRegistry(); - expect(networkClientToDestroy.destroy).toHaveBeenCalled(); - expect(Object.keys(networkClients)).toHaveLength(7); - expect(networkClients).not.toMatchObject({ - 'https://test.network.1': expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: 'https://test.network.1', - type: NetworkClientType.Custom, - ticker: 'TEST', - }, - }), + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', }); + expect(result2).toBe('test response from 2'); }, ); }); - it('creates a new network client for the network configuration and adds it to the registry', async () => { + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, }, }, - infuraProjectId: 'some-infura-project-id', }, - async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + mockCreateNetworkClient() + .calledWith({ + chainId: '0x1337', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }) + .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - chainId: toHex(999), - rpcUrl: 'https://test.network.1', + chainId: '0x2448', + rpcUrl: 'https://rpc.endpoint', + ticker: 'TOKEN', type: NetworkClientType.Custom, - ticker: 'TICKER', }) - .mockReturnValue(newCustomNetworkClient); + .mockReturnValue(fakeNetworkClients[1]); + await controller.initializeProvider(); - await controller.upsertNetworkConfiguration( - { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(999), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); - const networkClients = controller.getNetworkClientRegistry(); - expect(Object.keys(networkClients)).toHaveLength(7); - expect(networkClients).toMatchObject({ - 'AAAA-AAAA-AAAA-AAAA': expect.objectContaining({ - configuration: { - chainId: toHex(999), - rpcUrl: 'https://test.network.1', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }, - }), + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + expect.objectContaining({ + op: 'add', + path: ['networkConfigurationsByChainId', '0x2448'], + }), + ]), + ], + ]); }, ); }); }); + }); - describe('if the chain ID is not being updated', () => { - it('does not update the network client registry', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', + describe('if nothing is being changed', () => { + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`given the ID of the Infura-supported chain ${infuraChainId}`, () => { + it('makes no updates to state', async () => { + const existingNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - const networkClientsBefore = - controller.getNetworkClientRegistry(); - - await controller.upsertNetworkConfiguration( - { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'NEW_TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + async ({ controller }) => { + await controller.updateNetwork( + infuraChainId, + existingNetworkConfiguration, + ); - const networkClientsAfter = controller.getNetworkClientRegistry(); - expect(networkClientsBefore).toStrictEqual(networkClientsAfter); - }, - ); - }); - }); + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual(existingNetworkConfiguration); + }, + ); + }); - it('does not call trackMetaMetricsEvent', async () => { - const trackMetaMetricsEventSpy = jest.fn(); + it('does not destroy any existing clients for the network', async () => { + const existingNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - trackMetaMetricsEvent: trackMetaMetricsEventSpy, - }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( + await withController( { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'NEW_TICKER', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller }) => { + const existingNetworkClient = + controller.getNetworkClientById(infuraNetworkType); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork( + infuraChainId, + existingNetworkConfiguration, + ); + + expect(destroySpy).not.toHaveBeenCalled(); }, ); + }); - expect(trackMetaMetricsEventSpy).not.toHaveBeenCalled(); - }, - ); - }); - }); + it('does not create any new clients for the network', async () => { + const existingNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); - it('throws if an id is provided and it does not match an existing network configuration', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - id: 'networkClientIdDoesNotExist', - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error('No network configuration matches the provided id'), - ); - }); - }); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); - it('throws if an id is provided and it does not match an existing network configuration with the rpcUrl', async () => { - await withController( - { - state: { - networkConfigurations: { - networkClientIdExists: { - rpcUrl: 'https://test.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'networkClientIdExists', - }, - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://existing-rpcurl-on-different-client.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( + await withController( { - id: 'networkClientIdExists', - rpcUrl: 'https://existing-rpcurl-on-different-client.network', - chainId: toHex(111), - ticker: 'TICKER', + state: { + networkConfigurationsByChainId: { + [infuraChainId]: existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + async ({ controller }) => { + await controller.updateNetwork( + infuraChainId, + existingNetworkConfiguration, + ); + + // 2 times for existing RPC endpoints, but no more + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledTimes( + 2, + ); }, - ), - ).rejects.toThrow( - new Error( - 'A different network configuration already exists with the provided rpcUrl', - ), - ); - }, - ); - }); + ); + }); + }); + } - it('throws if the given chain ID is not a 0x-prefixed hex number', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - // @ts-expect-error We are intentionally passing bad input. - chainId: '1', - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error('Value must be a hexadecimal string, starting with "0x".'), - ); - }); - }); + describe('given the ID of a non-Infura-supported chain', () => { + it('makes no updates to state', async () => { + const existingNetworkConfiguration = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); - it('throws if the given chain ID is greater than the maximum allowed ID', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - chainId: toHex(MAX_SAFE_CHAIN_ID + 1), - ticker: 'TICKER', - }, + await withController( { - referrer: 'https://test-dapp.com', - source: 'dapp', + state: { + networkConfigurationsByChainId: { + '0x1337': existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - ), - ).rejects.toThrow( - new Error( - 'Invalid chain ID "0xfffffffffffed": numerical value greater than max safe value.', - ), - ); - }); - }); + async ({ controller }) => { + await controller.updateNetwork( + '0x1337', + existingNetworkConfiguration, + ); - it('throws if a falsy rpcUrl is given', async () => { - await withController(async ({ controller }) => { - await expect(() => - controller.upsertNetworkConfiguration( - { - // @ts-expect-error We are intentionally passing bad input. - rpcUrl: false, - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual(existingNetworkConfiguration); }, - ), - ).rejects.toThrow( - new Error( - 'An rpcUrl is required to add or update network configuration', - ), - ); - }); - }); + ); + }); - it('throws if no rpcUrl is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - // @ts-expect-error We are intentionally passing bad input. - { - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'An rpcUrl is required to add or update network configuration', - ), - ); - }); - }); + it('does not destroy any existing clients for the network', async () => { + const existingNetworkConfiguration = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }); - it('throws if the rpcUrl given is not a valid URL', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'test', - chainId: toHex(111), - ticker: 'TICKER', - }, + await withController( { - referrer: 'https://test-dapp.com', - source: 'dapp', + state: { + networkConfigurationsByChainId: { + '0x1337': existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - ), - ).rejects.toThrow(new Error('rpcUrl must be a valid URL')); - }); - }); + async ({ controller }) => { + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); - it('throws if a falsy referrer is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - // @ts-expect-error We are intentionally passing bad input. - referrer: false, - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), - ); - }); - }); + await controller.updateNetwork( + '0x1337', + existingNetworkConfiguration, + ); - it('throws if no referrer is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - // @ts-expect-error We are intentionally passing bad input. - { - source: 'dapp', + expect(destroySpy).not.toHaveBeenCalled(); }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), - ); - }); - }); + ); + }); - it('throws if a falsy source is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( + it('does not create any new clients for the network', async () => { + const existingNetworkConfiguration = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', + state: { + networkConfigurationsByChainId: { + '0x1337': existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, }, - { - referrer: 'https://test-dapp.com', - // @ts-expect-error We are intentionally passing bad input. - source: false, + async ({ controller }) => { + await controller.updateNetwork( + '0x1337', + existingNetworkConfiguration, + ); + + // 2 times for existing RPC endpoints, but no more + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledTimes( + 2, + ); }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), - ); + ); + }); }); }); + }); - it('throws if no source is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - // @ts-expect-error We are intentionally passing bad input. - { - referrer: 'https://test-dapp.com', - }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), + describe('removeNetwork', () => { + it('throws if the given chain ID does not refer to an existing network configuration', async () => { + await withController(({ controller }) => { + expect(() => controller.removeNetwork('0x1337')).toThrow( + new Error("Cannot find network configuration for chain '0x1337'"), ); }); }); - it('throws if a falsy ticker is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - // @ts-expect-error We are intentionally passing bad input. - ticker: false, - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + it('throws if selectedNetworkClientId matches the networkClientId of any RPC endpoint in the existing network configuration', async () => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, - ), - ).rejects.toThrow( - new Error( - 'A ticker is required to add or update networkConfiguration', - ), - ); - }); + }, + }, + ({ controller }) => { + expect(() => controller.removeNetwork('0x1337')).toThrow( + 'Cannot remove the currently selected network', + ); + }, + ); }); - it('throws if no ticker is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - // @ts-expect-error We are intentionally passing bad input. + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + + // This is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`given the ID of the Infura-supported chain ${infuraChainId}`, () => { + it('removes the existing network configuration from state', async () => { + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(infuraChainId); + + controller.removeNetwork(infuraChainId); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty(infuraChainId); }, - ), - ).rejects.toThrow( - new Error( - 'A ticker is required to add or update networkConfiguration', - ), - ); - }); - }); - }); + ); + }); - describe('removeNetworkConfiguration', () => { - describe('given an ID that identifies a network configuration in state', () => { - it('removes the network configuration from state', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'AAAA-AAAA-AAAA-AAAA', + it('destroys and unregisters the network clients for each of the RPC endpoints defined in the network configuration (even the Infura endpoint)', async () => { + const defaultRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: buildInfuraNetworkConfiguration( + infuraNetworkType, + { + rpcEndpoints: [ + defaultRpcEndpoint, + buildCustomRpcEndpoint({ + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint', + }), + ], + }, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + ], + }), }, }, }, - }, - async ({ controller }) => { - controller.removeNetworkConfiguration('AAAA-AAAA-AAAA-AAAA'); + ({ controller }) => { + const existingNetworkClient1 = + controller.getNetworkClientById(infuraNetworkType); + const destroySpy1 = jest.spyOn(existingNetworkClient1, 'destroy'); + const existingNetworkClient2 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy2 = jest.spyOn(existingNetworkClient2, 'destroy'); - expect(controller.state.networkConfigurations).toStrictEqual({}); - }, - ); + controller.removeNetwork(infuraChainId); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + infuraNetworkType, + ); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); }); + } - it('destroys and removes the network client in the network client registry that corresponds to the given ID', async () => { + describe('given the ID of a non-Infura-supported chain', () => { + it('removes the existing network configuration', async () => { await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'AAAA-AAAA-AAAA-AAAA', - }, + selectedNetworkClientId: InfuraNetworkType.goerli, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration(), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, }, - async ({ controller }) => { - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients() - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(buildFakeClient()); - const networkClientToDestroy = Object.values( - controller.getNetworkClientRegistry(), - ).find(({ configuration }) => { - return ( - configuration.type === NetworkClientType.Custom && - configuration.chainId === toHex(111) && - configuration.rpcUrl === 'https://test.network' - ); - }); - assert(networkClientToDestroy); - jest.spyOn(networkClientToDestroy, 'destroy'); + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty('0x1337'); - controller.removeNetworkConfiguration('AAAA-AAAA-AAAA-AAAA'); + controller.removeNetwork('0x1337'); - expect(networkClientToDestroy.destroy).toHaveBeenCalled(); - expect(controller.getNetworkClientRegistry()).not.toMatchObject({ - 'https://test.network': expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }, - }), - }); + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); }, ); }); - it('throws an error if the given ID corresponds to the selected network', async () => { + it('destroys the network clients for each of the RPC endpoints defined in the network configuration', async () => { await withController( { state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'AAAA-AAAA-AAAA-AAAA', - }, + selectedNetworkClientId: InfuraNetworkType.goerli, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, - selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', }, }, - async ({ controller }) => { - expect(() => - controller.removeNetworkConfiguration('AAAA-AAAA-AAAA-AAAA'), - ).toThrow('The selected network configuration cannot be removed'); - }, - ); - }); - }); - - describe('given an ID that does not identify a network configuration in state', () => { - it('throws', async () => { - await withController(async ({ controller }) => { - expect(() => - controller.removeNetworkConfiguration('NONEXISTENT'), - ).toThrow( - `networkConfigurationId NONEXISTENT does not match a configured networkConfiguration`, - ); - }); - }); - - it('does not update the network client registry', async () => { - await withController(async ({ controller }) => { - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients(); - const networkClients = controller.getNetworkClientRegistry(); + ({ controller }) => { + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn(existingNetworkClient1, 'destroy'); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn(existingNetworkClient2, 'destroy'); - try { - controller.removeNetworkConfiguration('NONEXISTENT'); - } catch { - // ignore error (it is tested elsewhere) - } + controller.removeNetwork('0x1337'); - expect(controller.getNetworkClientRegistry()).toStrictEqual( - networkClients, - ); - }); + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); }); }); }); describe('rollbackToPreviousProvider', () => { describe('when called not following any network switches', () => { - [NetworkType.mainnet, NetworkType.goerli, NetworkType.sepolia].forEach( - (networkType) => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`when selectedNetworkClientId in state is the Infura network "${networkType}"`, () => { - refreshNetworkTests({ - expectedNetworkClientConfiguration: - buildInfuraNetworkClientConfiguration(networkType), - initialState: { - selectedNetworkClientId: networkType, - }, - operation: async (controller) => { - await controller.rollbackToPreviousProvider(); - }, - }); + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + initialState: { + selectedNetworkClientId: infuraNetworkType, + }, + operation: async (controller) => { + await controller.rollbackToPreviousProvider(); + }, }); - }, - ); + }); + } - describe('when selectedNetworkClientId in state is the ID of a network configuration', () => { + describe('when the selected network client represents a custom RPC endpoint', () => { refreshNetworkTests({ expectedNetworkClientConfiguration: buildCustomNetworkClientConfiguration({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), + rpcUrl: 'https://test.network', + chainId: '0x1337', ticker: 'TEST', }), initialState: { selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, operation: async (controller) => { @@ -4261,26 +11459,29 @@ describe('NetworkController', () => { }); }); - for (const { networkType } of INFURA_NETWORKS) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. + for (const infuraNetworkType of Object.values(InfuraNetworkType)) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; + + // False negative - this is a string. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - describe(`when called following a network switch away from the Infura network "${networkType}"`, () => { + describe(`when called following a switch away from the Infura network "${infuraNetworkType}"`, () => { it('emits networkWillChange with state payload', async () => { await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, }, @@ -4288,7 +11489,7 @@ describe('NetworkController', () => { const fakeProvider = buildFakeProvider(); const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); const networkWillChange = waitForPublishedEvents({ messenger, @@ -4297,7 +11498,6 @@ describe('NetworkController', () => { operation: () => { // Intentionally not awaited because we're capturing an event // emitted partway through the operation - // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.rollbackToPreviousProvider(); }, @@ -4312,18 +11512,18 @@ describe('NetworkController', () => { await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, }, @@ -4331,7 +11531,7 @@ describe('NetworkController', () => { const fakeProvider = buildFakeProvider(); const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); const networkDidChange = waitForPublishedEvents({ messenger, @@ -4352,24 +11552,28 @@ describe('NetworkController', () => { }); it('sets selectedNetworkClientId in state to the previous version', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; @@ -4379,49 +11583,57 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); expect(controller.state.selectedNetworkClientId).toBe( - 'testNetworkConfiguration', + 'AAAA-AAAA-AAAA-AAAA', ); await controller.rollbackToPreviousProvider(); expect(controller.state.selectedNetworkClientId).toBe( - networkType, + infuraNetworkType, ); }, ); }); it('resets the network status to "unknown" before updating the provider', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [ @@ -4441,45 +11653,40 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - infuraProjectId: 'some-infura-project-id', + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, ).toBe('available'); await waitForStateChanges({ messenger, - propertyPath: ['networksMetadata', networkType, 'status'], + propertyPath: ['networksMetadata', infuraNetworkType, 'status'], // We only care about the first state change, because it // happens before networkDidChange count: 1, operation: () => { // Intentionally not awaited because we want to check state // while this operation is in-progress - // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.rollbackToPreviousProvider(); }, beforeResolving: () => { expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata[infuraNetworkType].status, ).toBe('unknown'); }, }); @@ -4487,23 +11694,31 @@ describe('NetworkController', () => { ); }); - // This is a string. + // False negative - this is a string. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - it(`initializes a provider pointed to the "${networkType}" Infura network`, async () => { + it(`initializes a provider pointed to the "${infuraNetworkType}" Infura network`, async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [ @@ -4525,27 +11740,27 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); await controller.rollbackToPreviousProvider(); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); - const result = await provider.request({ + const networkClient = controller.getSelectedNetworkClient(); + assert(networkClient, 'Network client is somehow unset'); + const result = await networkClient.provider.request({ id: '1', jsonrpc: '2.0', method: 'test', @@ -4556,20 +11771,28 @@ describe('NetworkController', () => { }); it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; @@ -4579,48 +11802,58 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - const { provider: providerBefore } = - controller.getProviderAndBlockTracker(); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + const networkClientBefore = controller.getSelectedNetworkClient(); + assert(networkClientBefore, 'Network client is somehow unset'); await controller.rollbackToPreviousProvider(); - const { provider: providerAfter } = - controller.getProviderAndBlockTracker(); - expect(providerBefore).toBe(providerAfter); + const networkClientAfter = controller.getSelectedNetworkClient(); + assert(networkClientAfter, 'Network client is somehow unset'); + expect(networkClientBefore.provider).toBe( + networkClientAfter.provider, + ); }, ); }); it('emits infuraIsBlocked or infuraIsUnblocked, depending on whether Infura is blocking requests for the previous network', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [ @@ -4640,21 +11873,21 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); const promiseForNoInfuraIsUnblockedEvents = waitForPublishedEvents({ messenger, @@ -4675,20 +11908,28 @@ describe('NetworkController', () => { }); it('checks the status of the previous network again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [ @@ -4715,58 +11956,62 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, ).toBe('unavailable'); await waitForStateChanges({ messenger, - propertyPath: ['networksMetadata', networkType, 'status'], + propertyPath: ['networksMetadata', infuraNetworkType, 'status'], operation: async () => { await controller.rollbackToPreviousProvider(); }, }); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata[infuraNetworkType].status, ).toBe('available'); }, ); }); it('checks whether the previous network supports EIP-1559 again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: networkType, - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [ @@ -4797,39 +12042,36 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, + chainId: infuraChainId, + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], ).toBe(false); await waitForStateChanges({ messenger, - propertyPath: ['networksMetadata', networkType, 'EIPS'], + propertyPath: ['networksMetadata', infuraNetworkType, 'EIPS'], count: 2, operation: async () => { await controller.rollbackToPreviousProvider(); }, }); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], + controller.state.networksMetadata[infuraNetworkType].EIPS[1559], ).toBe(true); }, ); @@ -4837,19 +12079,26 @@ describe('NetworkController', () => { }); } - describe('when called following a network switch away from a network configuration', () => { + describe('when called following a switch away from a custom RPC endpoint', () => { it('emits networkWillChange with state payload', async () => { await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, }, @@ -4857,7 +12106,7 @@ describe('NetworkController', () => { const fakeProvider = buildFakeProvider(); const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setProviderType(InfuraNetworkType.goerli); + await controller.setActiveNetwork(InfuraNetworkType.goerli); const networkWillChange = waitForPublishedEvents({ messenger, @@ -4880,14 +12129,21 @@ describe('NetworkController', () => { await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, }, @@ -4895,7 +12151,7 @@ describe('NetworkController', () => { const fakeProvider = buildFakeProvider(); const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setProviderType(InfuraNetworkType.goerli); + await controller.setActiveNetwork(InfuraNetworkType.goerli); const networkDidChange = waitForPublishedEvents({ messenger, @@ -4915,20 +12171,29 @@ describe('NetworkController', () => { }); it('sets selectedNetworkClientId to the previous version', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; @@ -4938,46 +12203,57 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - expect(controller.state.selectedNetworkClientId).toBe('goerli'); + await controller.setActiveNetwork(InfuraNetworkType.goerli); + expect(controller.state.selectedNetworkClientId).toBe( + InfuraNetworkType.goerli, + ); await controller.rollbackToPreviousProvider(); expect(controller.state.selectedNetworkClientId).toBe( - 'testNetworkConfiguration', + 'AAAA-AAAA-AAAA-AAAA', ); }, ); }); it('resets the network state to "unknown" before updating the provider', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [ @@ -4997,21 +12273,21 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + await controller.setActiveNetwork(InfuraNetworkType.goerli); expect( controller.state.networksMetadata[ controller.state.selectedNetworkClientId @@ -5022,7 +12298,7 @@ describe('NetworkController', () => { messenger, propertyPath: [ 'networksMetadata', - 'testNetworkConfiguration', + 'AAAA-AAAA-AAAA-AAAA', 'status', ], // We only care about the first state change, because it @@ -5036,9 +12312,8 @@ describe('NetworkController', () => { }, beforeResolving: () => { expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .status, ).toBe('unknown'); }, }); @@ -5047,20 +12322,29 @@ describe('NetworkController', () => { }); it('initializes a provider pointed to the given RPC URL', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [ @@ -5068,7 +12352,7 @@ describe('NetworkController', () => { buildFakeProvider([ { request: { - method: 'test', + method: 'test_method', }, response: { result: 'test response', @@ -5082,30 +12366,30 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + await controller.setActiveNetwork(InfuraNetworkType.goerli); await controller.rollbackToPreviousProvider(); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); - const result = await provider.request({ + const networkClient = controller.getSelectedNetworkClient(); + assert(networkClient, 'Network client is somehow unset'); + const result = await networkClient.provider.request({ id: '1', jsonrpc: '2.0', - method: 'test', + method: 'test_method', }); expect(result).toBe('test response'); }, @@ -5113,20 +12397,29 @@ describe('NetworkController', () => { }); it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; @@ -5136,48 +12429,59 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - const { provider: providerBefore } = - controller.getProviderAndBlockTracker(); + await controller.setActiveNetwork(InfuraNetworkType.goerli); + const networkClientBefore = controller.getSelectedNetworkClient(); + assert(networkClientBefore, 'Network client is somehow unset'); await controller.rollbackToPreviousProvider(); - const { provider: providerAfter } = - controller.getProviderAndBlockTracker(); - expect(providerBefore).toBe(providerAfter); + const networkClientAfter = controller.getSelectedNetworkClient(); + assert(networkClientAfter, 'Network client is somehow unset'); + expect(networkClientBefore.provider).toBe( + networkClientAfter.provider, + ); }, ); }); it('emits infuraIsUnblocked', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller, messenger }) => { const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; @@ -5187,21 +12491,21 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + await controller.setActiveNetwork(InfuraNetworkType.goerli); const promiseForInfuraIsUnblocked = waitForPublishedEvents({ messenger, @@ -5217,20 +12521,29 @@ describe('NetworkController', () => { }); it('checks the status of the previous network again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [ @@ -5257,52 +12570,58 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + await controller.setActiveNetwork(InfuraNetworkType.goerli); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata[InfuraNetworkType.goerli] + .status, ).toBe('unavailable'); await controller.rollbackToPreviousProvider(); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, ).toBe('available'); }, ); }); it('checks whether the previous network supports EIP-1559 again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - selectedNetworkClientId: 'testNetworkConfiguration', - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [ChainId.goerli]: buildInfuraNetworkConfiguration( + InfuraNetworkType.goerli, + ), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { const fakeProviders = [ @@ -5333,32 +12652,30 @@ describe('NetworkController', () => { ]; mockCreateNetworkClient() .calledWith({ + chainId: ChainId.goerli, + infuraProjectId, network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, + ticker: NetworksTicker.goerli, type: NetworkClientType.Infura, }) .mockReturnValue(fakeNetworkClients[0]) .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, + chainId: '0x1337', + rpcUrl: 'https://test.network', ticker: 'TEST', + type: NetworkClientType.Custom, }) .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + await controller.setActiveNetwork(InfuraNetworkType.goerli); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], + controller.state.networksMetadata[InfuraNetworkType.goerli] + .EIPS[1559], ).toBe(false); await controller.rollbackToPreviousProvider(); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], ).toBe(true); }, ); @@ -5370,49 +12687,152 @@ describe('NetworkController', () => { it('merges the network configurations from the given backup into state', async () => { await withController( { - state: { - networkConfigurations: { - networkConfigurationId1: { - id: 'networkConfigurationId1', - rpcUrl: 'https://rpc-url1.com', - chainId: toHex(1), - ticker: 'TEST1', + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': { + blockExplorerUrls: [], + chainId: '0x1337' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 1', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + { + name: 'Test Endpoint', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + type: RpcEndpointType.Custom, + }, + ], }, }, - }, + }), }, ({ controller }) => { controller.loadBackup({ - networkConfigurations: { - networkConfigurationId2: { - id: 'networkConfigurationId2', - rpcUrl: 'https://rpc-url2.com', - chainId: toHex(2), - ticker: 'TEST2', + networkConfigurationsByChainId: { + '0x2448': { + blockExplorerUrls: [], + chainId: '0x2448' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 2', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + { + name: 'Test Endpoint', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + type: RpcEndpointType.Custom, + }, + ], }, }, }); - expect(controller.state.networkConfigurations).toStrictEqual({ - networkConfigurationId1: { - id: 'networkConfigurationId1', - rpcUrl: 'https://rpc-url1.com', - chainId: toHex(1), - ticker: 'TEST1', - }, - networkConfigurationId2: { - id: 'networkConfigurationId2', - rpcUrl: 'https://rpc-url2.com', - chainId: toHex(2), - ticker: 'TEST2', + expect(controller.state.networkConfigurationsByChainId).toStrictEqual( + { + '0x1337': { + blockExplorerUrls: [], + chainId: '0x1337' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 1', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + { + name: 'Test Endpoint', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + type: RpcEndpointType.Custom, + }, + ], + }, + '0x2448': { + blockExplorerUrls: [], + chainId: '0x2448' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 2', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + { + name: 'Test Endpoint', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + type: RpcEndpointType.Custom, + }, + ], + }, }, - }); + ); }, ); }); }); }); +describe('getNetworkConfigurations', () => { + it('returns network configurations available in the state', () => { + const state = getDefaultNetworkControllerState(); + + expect(getNetworkConfigurations(state)).toStrictEqual( + Object.values(state.networkConfigurationsByChainId), + ); + }); +}); + +describe('getAvailableNetworkClientIds', () => { + it('returns network client ids available in the state', () => { + const networkConfigurations = [ + { + rpcEndpoints: [ + { + networkClientId: 'foo', + }, + ], + }, + { + rpcEndpoints: [ + { + networkClientId: 'bar', + }, + ], + }, + ] as NetworkConfiguration[]; + + expect(getAvailableNetworkClientIds(networkConfigurations)).toStrictEqual([ + 'foo', + 'bar', + ]); + }); +}); + +describe('selectAvailableNetworkClientIds', () => { + it('selects all network client ids available in the state', () => { + const state = { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: { + '0x12': { + rpcEndpoints: [ + { + networkClientId: 'foo', + }, + ], + } as NetworkConfiguration, + '0x34': { + rpcEndpoints: [ + { + networkClientId: 'bar', + }, + ], + } as NetworkConfiguration, + }, + }; + + expect(selectAvailableNetworkClientIds(state)).toStrictEqual([ + 'foo', + 'bar', + ]); + }); +}); + /** * Creates a mocked version of `createNetworkClient` where multiple mock * invocations can be specified. A default implementation is provided so that if @@ -5437,49 +12857,6 @@ function mockCreateNetworkClient() { }); } -/** - * Creates a mocked version of `createNetworkClient` where multiple mock - * invocations can be specified. Requests for built-in networks are already - * mocked. - * - * @param options - The options. - * @param options.builtInNetworkClient - The network client to use for requests - * to built-in networks. - * @param options.infuraProjectId - The Infura project ID that each network - * client is expected to be created with. - * @returns The mocked version of `createNetworkClient`. - */ -function mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - builtInNetworkClient = buildFakeClient(), - infuraProjectId = 'infura-project-id', -} = {}) { - return mockCreateNetworkClient() - .calledWith({ - network: NetworkType.mainnet, - infuraProjectId, - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - }) - .mockReturnValue(builtInNetworkClient) - .calledWith({ - network: NetworkType.goerli, - infuraProjectId, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(builtInNetworkClient) - .calledWith({ - network: NetworkType.sepolia, - infuraProjectId, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(builtInNetworkClient); -} - /** * Test an operation that performs a `#refreshNetwork` call with the given * provider configuration. All effects of the `#refreshNetwork` call should be @@ -5653,30 +13030,45 @@ function refreshNetworkTests({ buildFakeClient(fakeProviders[1]), ]; const { selectedNetworkClientId } = controller.state; - let initializationNetworkClientOptions: Parameters< - typeof createNetworkClient - >[0]; + let initializationNetworkClientConfiguration: + | Parameters[0] + | undefined; + + for (const matchingNetworkConfiguration of Object.values( + controller.state.networkConfigurationsByChainId, + )) { + const matchingRpcEndpoint = + matchingNetworkConfiguration.rpcEndpoints.find( + (rpcEndpoint) => + rpcEndpoint.networkClientId === selectedNetworkClientId, + ); + if (matchingRpcEndpoint) { + if (isInfuraNetworkType(selectedNetworkClientId)) { + initializationNetworkClientConfiguration = { + chainId: ChainId[selectedNetworkClientId], + infuraProjectId: 'infura-project-id', + network: selectedNetworkClientId, + ticker: NetworksTicker[selectedNetworkClientId], + type: NetworkClientType.Infura, + }; + } else { + initializationNetworkClientConfiguration = { + chainId: matchingNetworkConfiguration.chainId, + rpcUrl: matchingRpcEndpoint.url, + ticker: matchingNetworkConfiguration.nativeCurrency, + type: NetworkClientType.Custom, + }; + } + } + } - if (isInfuraNetworkType(selectedNetworkClientId)) { - initializationNetworkClientOptions = { - network: selectedNetworkClientId, - infuraProjectId: 'infura-project-id', - chainId: BUILT_IN_NETWORKS[selectedNetworkClientId].chainId, - ticker: BUILT_IN_NETWORKS[selectedNetworkClientId].ticker, - type: NetworkClientType.Infura, - }; - } else { - const networkConfiguration = - controller.state.networkConfigurations[selectedNetworkClientId]; - initializationNetworkClientOptions = { - chainId: networkConfiguration.chainId, - rpcUrl: networkConfiguration.rpcUrl, - type: NetworkClientType.Custom, - ticker: networkConfiguration.ticker, - }; + if (initializationNetworkClientConfiguration === undefined) { + throw new Error( + 'Could not set initializationNetworkClientConfiguration', + ); } - const operationNetworkClientOptions: Parameters< + const operationNetworkClientConfiguration: Parameters< typeof createNetworkClient >[0] = expectedNetworkClientConfiguration.type === NetworkClientType.Custom @@ -5686,9 +13078,9 @@ function refreshNetworkTests({ infuraProjectId: 'infura-project-id', }; mockCreateNetworkClient() - .calledWith(initializationNetworkClientOptions) + .calledWith(initializationNetworkClientConfiguration) .mockReturnValue(fakeNetworkClients[0]) - .calledWith(operationNetworkClientOptions) + .calledWith(operationNetworkClientConfiguration) .mockReturnValue(fakeNetworkClients[1]); await controller.initializeProvider(); const { provider: providerBefore } = @@ -5704,7 +13096,7 @@ function refreshNetworkTests({ }); lookupNetworkTests({ - expectedNetworkClientConfiguration, + expectedNetworkClientType: expectedNetworkClientConfiguration.type, initialState, operation, }); @@ -5716,17 +13108,17 @@ function refreshNetworkTests({ * covered by these tests. * * @param args - Arguments. - * @param args.expectedNetworkClientConfiguration - The network client - * configuration that the operation is expected to set. + * @param args.expectedNetworkClientType - The type of the network client + * that the operation is expected to set. * @param args.initialState - The initial state of the network controller. * @param args.operation - The operation to test. */ function lookupNetworkTests({ - expectedNetworkClientConfiguration, + expectedNetworkClientType, initialState, operation, }: { - expectedNetworkClientConfiguration: NetworkClientConfiguration; + expectedNetworkClientType: NetworkClientType; initialState?: Partial; operation: (controller: NetworkController) => Promise; }) { @@ -5943,7 +13335,7 @@ function lookupNetworkTests({ ); }); - if (expectedNetworkClientConfiguration.type === NetworkClientType.Custom) { + if (expectedNetworkClientType === NetworkClientType.Custom) { it('emits infuraIsUnblocked', async () => { await withController( { @@ -6045,7 +13437,7 @@ function lookupNetworkTests({ }); describe('if a country blocked error is encountered while retrieving the network details of the current network', () => { - if (expectedNetworkClientConfiguration.type === NetworkClientType.Custom) { + if (expectedNetworkClientType === NetworkClientType.Custom) { it('updates the network in state to "unknown"', async () => { await withController( { @@ -6359,7 +13751,7 @@ function lookupNetworkTests({ ); }); - if (expectedNetworkClientConfiguration.type === NetworkClientType.Custom) { + if (expectedNetworkClientType === NetworkClientType.Custom) { it('emits infuraIsUnblocked', async () => { await withController( { @@ -6549,7 +13941,6 @@ async function withController( const restrictedMessenger = buildNetworkControllerMessenger(messenger); const controller = new NetworkController({ messenger: restrictedMessenger, - trackMetaMetricsEvent: jest.fn(), infuraProjectId: 'infura-project-id', ...rest, }); @@ -6875,3 +14266,76 @@ function didPropertyChange(patches: Patch[], propertyPath: string[]): boolean { ); }); } + +/** + * Extracts the network client configurations from a network client registry so + * that it is easier to test without having to ignore every property in + * NetworkClient but `configuration`. + * + * @param networkClientRegistry - The network client registry. + * @returns A map of network client ID to network client configuration. + */ +function getNetworkConfigurationsByNetworkClientId( + networkClientRegistry: AutoManagedBuiltInNetworkClientRegistry & + AutoManagedCustomNetworkClientRegistry, +): Record { + return Object.entries(networkClientRegistry).reduce( + ( + obj: Partial>, + [networkClientId, networkClient], + ) => { + return { + ...obj, + [networkClientId]: networkClient.configuration, + }; + }, + {}, + ) as Record; +} + +/** + * When initializing NetworkController with state, the `selectedNetworkClientId` + * property must match the `networkClientId` of an RPC endpoint in + * `networkConfigurationsByChainId`. Sometimes when writing tests we care about + * what the `selectedNetworkClientId` is, but sometimes we don't and we'd rather + * have this property automatically filled in for us. + * + * This function takes care of filling in the `selectedNetworkClientId` using + * the first RPC endpoint of the first network configuration given. + * + * @param networkControllerState - The desired NetworkController state + * overrides. + * @param networkControllerState.networkConfigurationsByChainId - The desired + * `networkConfigurationsByChainId`. + * @param networkControllerState.selectedNetworkClientId - The desired + * `selectedNetworkClientId`; if not provided, then will be set to the + * `networkClientId` of the first RPC endpoint in + * `networkConfigurationsByChainId`. + * @returns The complete NetworkController state with `selectedNetworkClientId` + * properly filled in. + */ +function buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId, + selectedNetworkClientId: givenSelectedNetworkClientId, + ...rest +}: Partial> & + Pick) { + if (givenSelectedNetworkClientId === undefined) { + const networkConfigurations = Object.values(networkConfigurationsByChainId); + const selectedNetworkClientId = + networkConfigurations.length > 0 + ? networkConfigurations[0].rpcEndpoints[0].networkClientId + : undefined; + return { + networkConfigurationsByChainId, + selectedNetworkClientId, + ...rest, + }; + } + + return { + networkConfigurationsByChainId, + selectedNetworkClientId: givenSelectedNetworkClientId, + ...rest, + }; +} diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index c7072665ea..2c6067011c 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -1,21 +1,34 @@ import { - BUILT_IN_NETWORKS, + ChainId, InfuraNetworkType, + NetworkNickname, + NetworksTicker, toHex, } from '@metamask/controller-utils'; +import { v4 as uuidV4 } from 'uuid'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; import type { FakeProviderStub } from '../../../tests/fake-provider'; +import { buildTestObject } from '../../../tests/helpers'; import type { BuiltInNetworkClientId, CustomNetworkClientId, NetworkClient, NetworkClientConfiguration, NetworkClientId, + NetworkConfiguration, NetworkController, } from '../src'; import type { AutoManagedNetworkClient } from '../src/create-auto-managed-network-client'; +import type { + AddNetworkCustomRpcEndpointFields, + AddNetworkFields, + CustomRpcEndpoint, + InfuraRpcEndpoint, + UpdateNetworkCustomRpcEndpointFields, +} from '../src/NetworkController'; +import { RpcEndpointType } from '../src/NetworkController'; import type { CustomNetworkClientConfiguration, InfuraNetworkClientConfiguration, @@ -138,8 +151,8 @@ export function buildInfuraNetworkClientConfiguration( type: NetworkClientType.Infura, network, infuraProjectId: 'test-infura-project-id', - chainId: BUILT_IN_NETWORKS[network].chainId, - ticker: BUILT_IN_NETWORKS[network].ticker, + chainId: ChainId[network], + ticker: NetworksTicker[network], ...overrides, }; } @@ -168,3 +181,263 @@ export function buildCustomNetworkClientConfiguration( }, ); } + +/** + * Constructs a NetworkConfiguration object for use in testing, providing + * defaults and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new + * NetworkConfiguration with. + * @param defaultRpcEndpointType - The type of the RPC endpoint you want to + * use by default. + * @returns The complete NetworkConfiguration object. + */ +export function buildNetworkConfiguration( + overrides: Partial = {}, + defaultRpcEndpointType: RpcEndpointType = RpcEndpointType.Custom, +): NetworkConfiguration { + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => '0x1337', + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => 'Some Network', + nativeCurrency: () => 'TOKEN', + rpcEndpoints: () => [ + defaultRpcEndpointType === RpcEndpointType.Infura + ? buildInfuraRpcEndpoint(InfuraNetworkType['linea-goerli']) + : buildCustomRpcEndpoint({ url: 'https://test.endpoint' }), + ], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs a NetworkConfiguration object preloaded with a custom RPC endpoint + * for use in testing, providing defaults and allowing properties to be + * overridden at will. + * + * @param overrides - The properties to override the new NetworkConfiguration + * with. + * @returns The complete NetworkConfiguration object. + */ +export function buildCustomNetworkConfiguration( + overrides: Partial = {}, +): NetworkConfiguration { + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => '0x1337' as const, + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => 'Some Network', + nativeCurrency: () => 'TOKEN', + rpcEndpoints: () => [ + buildCustomRpcEndpoint({ + url: generateCustomRpcEndpointUrl(), + }), + ], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs a NetworkConfiguration object preloaded with an Infura RPC + * endpoint for use in testing. + * + * @param infuraNetworkType - The Infura network type from which to create the + * NetworkConfiguration. + * @param overrides - The properties to override the new NetworkConfiguration + * with. + * @param overrides.rpcEndpoints - Extra RPC endpoints. + * @returns The complete NetworkConfiguration object. + */ +export function buildInfuraNetworkConfiguration( + infuraNetworkType: InfuraNetworkType, + overrides: Partial = {}, +): NetworkConfiguration { + const defaultRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => ChainId[infuraNetworkType], + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => NetworkNickname[infuraNetworkType], + nativeCurrency: () => NetworksTicker[infuraNetworkType], + rpcEndpoints: () => [defaultRpcEndpoint], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs a InfuraRpcEndpoint object for use in testing. + * + * @param infuraNetworkType - The Infura network type from which to create the + * InfuraRpcEndpoint. + * @returns The created InfuraRpcEndpoint object. + */ +export function buildInfuraRpcEndpoint( + infuraNetworkType: InfuraNetworkType, +): InfuraRpcEndpoint { + return { + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + // False negative - this is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }; +} + +/** + * Constructs an CustomRpcEndpoint object for use in testing, providing defaults + * and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new CustomRpcEndpoint with. + * @returns The complete CustomRpcEndpoint object. + */ +export function buildCustomRpcEndpoint( + overrides: Partial = {}, +): CustomRpcEndpoint { + return buildTestObject( + { + networkClientId: () => uuidV4(), + type: () => RpcEndpointType.Custom as const, + url: () => generateCustomRpcEndpointUrl(), + }, + overrides, + ); +} + +/** + * Constructs an AddNetworkFields object for use in testing, providing defaults + * and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new AddNetworkFields with. + * @returns The complete AddNetworkFields object. + */ +export function buildAddNetworkFields( + overrides: Partial = {}, +): AddNetworkFields { + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => '0x1337' as const, + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => 'Some Network', + nativeCurrency: () => 'TOKEN', + rpcEndpoints: () => [ + buildAddNetworkCustomRpcEndpointFields({ + url: generateCustomRpcEndpointUrl(), + }), + ], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs an AddNetworkCustomRpcEndpointFields object for use in testing, + * providing defaults and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new + * AddNetworkCustomRpcEndpointFields with. + * @returns The complete AddNetworkCustomRpcEndpointFields object. + */ +export function buildAddNetworkCustomRpcEndpointFields( + overrides: Partial = {}, +): AddNetworkCustomRpcEndpointFields { + return buildTestObject( + { + type: () => RpcEndpointType.Custom as const, + url: () => generateCustomRpcEndpointUrl(), + }, + overrides, + ); +} + +/** + * Constructs an UpdateNetworkCustomRpcEndpointFields object for use in testing, + * providing defaults and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new + * UpdateNetworkCustomRpcEndpointFields with. + * @returns The complete UpdateNetworkCustomRpcEndpointFields object. + */ +export function buildUpdateNetworkCustomRpcEndpointFields( + overrides: Partial = {}, +): UpdateNetworkCustomRpcEndpointFields { + return buildTestObject( + { + type: () => RpcEndpointType.Custom as const, + url: () => generateCustomRpcEndpointUrl(), + }, + overrides, + ); +} + +let testEndpointCounter = 0; + +/** + * Generates a unique custom RPC endpoint URL for testing. + * + * @returns The generated RPC endpoint URL. + */ +function generateCustomRpcEndpointUrl(): string { + const url = `https://test.endpoint/${testEndpointCounter}`; + testEndpointCounter += 1; + return url; +} diff --git a/packages/queued-request-controller/src/QueuedRequestController.test.ts b/packages/queued-request-controller/src/QueuedRequestController.test.ts index aa52b7f7e4..239ae82885 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.test.ts @@ -1,12 +1,11 @@ import { ControllerMessenger } from '@metamask/base-controller'; import { - defaultState as defaultNetworkState, + getDefaultNetworkControllerState, type NetworkControllerGetStateAction, type NetworkControllerSetActiveNetworkAction, } from '@metamask/network-controller'; import type { SelectedNetworkControllerGetNetworkClientIdForDomainAction } from '@metamask/selected-network-controller'; import { createDeferredPromise } from '@metamask/utils'; -import { cloneDeep } from 'lodash'; import type { AllowedActions, @@ -67,7 +66,7 @@ describe('QueuedRequestController', () => { const mockSetActiveNetwork = jest.fn(); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: mockSetActiveNetwork, @@ -104,7 +103,7 @@ describe('QueuedRequestController', () => { const mockSetActiveNetwork = jest.fn(); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: mockSetActiveNetwork, @@ -136,7 +135,7 @@ describe('QueuedRequestController', () => { const mockSetActiveNetwork = jest.fn(); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: mockSetActiveNetwork, @@ -452,7 +451,7 @@ describe('QueuedRequestController', () => { const mockSetActiveNetwork = jest.fn(); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: mockSetActiveNetwork, @@ -507,7 +506,7 @@ describe('QueuedRequestController', () => { const mockSetActiveNetwork = jest.fn(); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: mockSetActiveNetwork, @@ -554,7 +553,7 @@ describe('QueuedRequestController', () => { const switchError = new Error('switch error'); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: jest @@ -586,7 +585,7 @@ describe('QueuedRequestController', () => { const switchError = new Error('switch error'); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: jest @@ -641,7 +640,7 @@ describe('QueuedRequestController', () => { const switchError = new Error('switch error'); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: jest @@ -695,7 +694,7 @@ describe('QueuedRequestController', () => { const switchError = new Error('switch error'); const { messenger } = buildControllerMessenger({ networkControllerGetState: jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'selectedNetworkClientId', }), networkControllerSetActiveNetwork: jest @@ -1006,7 +1005,7 @@ function buildControllerMessenger({ const mockNetworkControllerGetState = networkControllerGetState ?? jest.fn().mockReturnValue({ - ...cloneDeep(defaultNetworkState), + ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'defaultNetworkClientId', }); messenger.registerActionHandler( diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index 5fd617e674..5c10acf407 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -9,6 +9,7 @@ import type { NetworkControllerStateChangeEvent, ProviderProxy, } from '@metamask/network-controller'; +import { selectAvailableNetworkClientIds } from '@metamask/network-controller'; import type { PermissionControllerStateChange, GetSubjects as PermissionControllerGetSubjectsAction, @@ -191,24 +192,21 @@ export class SelectedNetworkController extends BaseController< this.messagingSystem.subscribe( 'NetworkController:stateChange', - ({ selectedNetworkClientId }, patches) => { - patches.forEach(({ op, path }) => { - // if a network is removed, update the networkClientId for all domains that were using it to the selected network - if (op === 'remove' && path[0] === 'networkConfigurations') { - const removedNetworkClientId = path[1] as NetworkClientId; - Object.entries(this.state.domains).forEach( - ([domain, networkClientIdForDomain]) => { - if (networkClientIdForDomain === removedNetworkClientId) { - this.setNetworkClientIdForDomain( - domain, - selectedNetworkClientId, - ); - } - }, - ); - } - }); + (availableNetworkClientIds) => { + // if a network is updated or removed, update the networkClientId for all domains + // that were using it to the selected network client id + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + Object.entries(this.state.domains).forEach( + ([domain, networkClientIdForDomain]) => { + if (!availableNetworkClientIds.includes(networkClientIdForDomain)) { + this.setNetworkClientIdForDomain(domain, selectedNetworkClientId); + } + }, + ); }, + selectAvailableNetworkClientIds, ); onPreferencesStateChange(({ useRequestQueue }) => { diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index 203f92bcd4..9dd3fb2d1a 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -1,7 +1,9 @@ import { ControllerMessenger } from '@metamask/base-controller'; -import type { - ProviderProxy, - BlockTrackerProxy, +import { + type ProviderProxy, + type BlockTrackerProxy, + type NetworkState, + getDefaultNetworkControllerState, } from '@metamask/network-controller'; import { createEventEmitterProxy } from '@metamask/swappable-obj-proxy'; @@ -290,34 +292,32 @@ describe('SelectedNetworkController', () => { }); }); - describe('networkController:stateChange', () => { + describe('NetworkController:stateChange', () => { describe('when a networkClient is deleted from the network controller state', () => { it('does not update state when useRequestQueuePreference is false', () => { - const { controller, messenger } = setup({ + const { controller, messenger, mockNetworkControllerGetState } = setup({ state: { domains: {}, }, }); + const mockNetworkControllerStateUpdate: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'goerli', + }; + mockNetworkControllerGetState.mockReturnValueOnce( + mockNetworkControllerStateUpdate, + ); messenger.publish( 'NetworkController:stateChange', - { - selectedNetworkClientId: 'goerli', - networkConfigurations: {}, - networksMetadata: {}, - }, - [ - { - op: 'remove', - path: ['networkConfigurations', 'test-network-client-id'], - }, - ], + mockNetworkControllerStateUpdate, + [], ); expect(controller.state.domains).toStrictEqual({}); }); it('updates the networkClientId for domains which were previously set to the deleted networkClientId when useRequestQueuePreference is true', () => { - const { controller, messenger } = setup({ + const { controller, messenger, mockNetworkControllerGetState } = setup({ state: { domains: { metamask: 'goerli', @@ -327,20 +327,18 @@ describe('SelectedNetworkController', () => { }, useRequestQueuePreference: true, }); + const mockNetworkControllerStateUpdate: NetworkState = { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'goerli', + }; + mockNetworkControllerGetState.mockReturnValueOnce( + mockNetworkControllerStateUpdate, + ); messenger.publish( 'NetworkController:stateChange', - { - selectedNetworkClientId: 'goerli', - networkConfigurations: {}, - networksMetadata: {}, - }, - [ - { - op: 'remove', - path: ['networkConfigurations', 'test-network-client-id'], - }, - ], + mockNetworkControllerStateUpdate, + [], ); expect(controller.state.domains['example.com']).toBe('goerli'); expect(controller.state.domains['test.com']).toBe('goerli'); diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index d078c05bd8..05dd039e87 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -29,7 +29,7 @@ import type { import { NetworkClientType, NetworkStatus, - defaultState as defaultNetworkState, + getDefaultNetworkControllerState, } from '@metamask/network-controller'; import * as NonceTrackerPackage from '@metamask/nonce-tracker'; import { errorCodes, providerErrors, rpcErrors } from '@metamask/rpc-errors'; @@ -337,7 +337,7 @@ const MOCK_NETWORK: MockNetwork = { status: NetworkStatus.Available, }, }, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, }, subscribe: () => undefined, }; @@ -354,7 +354,7 @@ const MOCK_MAINNET_NETWORK: MockNetwork = { status: NetworkStatus.Available, }, }, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, }, subscribe: () => undefined, }; @@ -371,7 +371,7 @@ const MOCK_LINEA_MAINNET_NETWORK: MockNetwork = { status: NetworkStatus.Available, }, }, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, }, subscribe: () => undefined, }; @@ -388,7 +388,7 @@ const MOCK_LINEA_GOERLI_NETWORK: MockNetwork = { status: NetworkStatus.Available, }, }, - networkConfigurations: {}, + networkConfigurationsByChainId: {}, }, subscribe: () => undefined, }; @@ -560,7 +560,7 @@ describe('TransactionController', () => { >; } = {}) { let networkState = { - ...defaultNetworkState, + ...getDefaultNetworkControllerState(), selectedNetworkClientId: MOCK_NETWORK.state.selectedNetworkClientId, ...network.state, }; diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 9e792deddf..a6234dd909 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -9,6 +9,7 @@ import { ControllerMessenger } from '@metamask/base-controller'; import { ApprovalType, BUILT_IN_NETWORKS, + ChainId, InfuraNetworkType, NetworkType, } from '@metamask/controller-utils'; @@ -28,10 +29,15 @@ import assert from 'assert'; import nock from 'nock'; import type { SinonFakeTimers } from 'sinon'; import { useFakeTimers } from 'sinon'; -import { v4 } from 'uuid'; +import { v4 as uuidV4 } from 'uuid'; import { advanceTime } from '../../../tests/helpers'; import { mockNetwork } from '../../../tests/mock-network'; +import { + buildAddNetworkFields, + buildCustomNetworkClientConfiguration, + buildUpdateNetworkCustomRpcEndpointFields, +} from '../../network-controller/tests/helpers'; import { ETHERSCAN_TRANSACTION_BASE_MOCK, ETHERSCAN_TRANSACTION_RESPONSE_MOCK, @@ -60,6 +66,15 @@ import { TransactionStatus, TransactionType } from './types'; import { getEtherscanApiHost } from './utils/etherscan'; import * as etherscanUtils from './utils/etherscan'; +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + + return { + ...actual, + v4: jest.fn(), + }; +}); + type UnrestrictedControllerMessenger = ControllerMessenger< | NetworkControllerActions | ApprovalControllerActions @@ -70,8 +85,10 @@ type UnrestrictedControllerMessenger = ControllerMessenger< | TransactionControllerEvents >; +const uuidV4Mock = jest.mocked(uuidV4); + const createMockInternalAccount = ({ - id = v4(), + id = uuidV4(), address = '0x2990079bcdee240329a520d2444386fc119da21a', name = 'Account 1', importTime = Date.now(), @@ -133,13 +150,6 @@ function buildInfuraNetworkClientConfiguration( }; } -const customGoerliNetworkClientConfiguration = { - type: NetworkClientType.Custom, - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, - rpcUrl: 'https://mock.rpc.url', -} as const; - const setupController = async ( givenOptions: Partial< ConstructorParameters[0] @@ -169,9 +179,6 @@ const setupController = async ( allowedActions: [], allowedEvents: [], }), - trackMetaMetricsEvent: () => { - // noop - }, infuraProjectId, }); await networkController.initializeProvider(); @@ -224,8 +231,8 @@ const setupController = async ( ); }, getNetworkState: () => networkController.state, - getNetworkClientRegistry: - networkController.getNetworkClientRegistry.bind(networkController), + getNetworkClientRegistry: () => + networkController.getNetworkClientRegistry(), getPermittedAccounts: async () => [ACCOUNT_MOCK], hooks: {}, isMultichainEnabled: false, @@ -255,8 +262,16 @@ const setupController = async ( describe('TransactionController Integration', () => { let clock: SinonFakeTimers; + let uuidCounter = 0; + beforeEach(() => { clock = useFakeTimers(); + + uuidV4Mock.mockImplementation(() => { + const uuid = `UUID-${uuidCounter}`; + uuidCounter += 1; + return uuid; + }); }); afterEach(() => { @@ -813,10 +828,11 @@ describe('TransactionController Integration', () => { describe('when transactions are added concurrently with different networkClientIds but on the same chainId', () => { // eslint-disable-next-line jest/no-disabled-tests it('should add each transaction with consecutive nonces', async () => { + const goerliNetworkClientConfiguration = + buildInfuraNetworkClientConfiguration(InfuraNetworkType.goerli); + mockNetwork({ - networkClientConfiguration: buildInfuraNetworkClientConfiguration( - InfuraNetworkType.goerli, - ), + networkClientConfiguration: goerliNetworkClientConfiguration, mocks: [ buildEthBlockNumberRequestMock('0x1'), buildEthGetBlockByNumberRequestMock('0x1'), @@ -841,7 +857,10 @@ describe('TransactionController Integration', () => { }); mockNetwork({ - networkClientConfiguration: customGoerliNetworkClientConfiguration, + networkClientConfiguration: buildCustomNetworkClientConfiguration({ + rpcUrl: 'https://mock.rpc.url', + ticker: goerliNetworkClientConfiguration.ticker, + }), mocks: [ buildEthBlockNumberRequestMock('0x1'), buildEthBlockNumberRequestMock('0x1'), @@ -866,25 +885,34 @@ describe('TransactionController Integration', () => { }); const { approvalController, networkController, transactionController } = - await setupController( - { - isMultichainEnabled: true, - getPermittedAccounts: async () => [ACCOUNT_MOCK], - }, - { selectedAccount: INTERNAL_ACCOUNT_MOCK }, - ); - const otherNetworkClientIdOnGoerli = - await networkController.upsertNetworkConfiguration( - { - rpcUrl: 'https://mock.rpc.url', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, - }, - { - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); + await setupController({ + isMultichainEnabled: true, + getPermittedAccounts: async () => [ACCOUNT_MOCK], + }); + const existingGoerliNetworkConfiguration = + networkController.getNetworkConfigurationByChainId(ChainId.goerli); + assert( + existingGoerliNetworkConfiguration, + 'Could not find network configuration for Goerli', + ); + const updatedGoerliNetworkConfiguration = + await networkController.updateNetwork(ChainId.goerli, { + ...existingGoerliNetworkConfiguration, + rpcEndpoints: [ + ...existingGoerliNetworkConfiguration.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://mock.rpc.url', + }), + ], + }); + const otherGoerliRpcEndpoint = + updatedGoerliNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return rpcEndpoint.url === 'https://mock.rpc.url'; + }); + assert( + otherGoerliRpcEndpoint, + 'Could not find other Goerli RPC endpoint', + ); const addTx1 = await transactionController.addTransaction( { @@ -900,7 +928,7 @@ describe('TransactionController Integration', () => { to: ACCOUNT_3_MOCK, }, { - networkClientId: otherNetworkClientIdOnGoerli, + networkClientId: otherGoerliRpcEndpoint.networkClientId, }, ); @@ -1001,79 +1029,101 @@ describe('TransactionController Integration', () => { }); }); - describe('when changing rpcUrl of networkClient', () => { - it('should start tracking when a new network is added', async () => { - mockNetwork({ - networkClientConfiguration: customGoerliNetworkClientConfiguration, - mocks: [ - buildEthBlockNumberRequestMock('0x1'), - buildEthBlockNumberRequestMock('0x1'), - buildEthGetBlockByNumberRequestMock('0x1'), - buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), - buildEthGasPriceRequestMock(), + it('should start tracking when a new network is added', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + ], + }); + mockNetwork({ + networkClientConfiguration: buildCustomNetworkClientConfiguration({ + rpcUrl: 'https://mock.rpc.url', + }), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + ], + }); + const { networkController, transactionController } = await setupController({ + isMultichainEnabled: true, + }); + + const existingGoerliNetworkConfiguration = + networkController.getNetworkConfigurationByChainId(ChainId.goerli); + assert( + existingGoerliNetworkConfiguration, + 'Could not find network configuration for Goerli', + ); + const updatedGoerliNetworkConfiguration = + await networkController.updateNetwork(ChainId.goerli, { + ...existingGoerliNetworkConfiguration, + rpcEndpoints: [ + ...existingGoerliNetworkConfiguration.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://mock.rpc.url', + }), ], }); - const { networkController, transactionController } = - await setupController({ isMultichainEnabled: true }); + const otherGoerliRpcEndpoint = + updatedGoerliNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return rpcEndpoint.url === 'https://mock.rpc.url'; + }); + assert(otherGoerliRpcEndpoint, 'Could not find other Goerli RPC endpoint'); + + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_3_MOCK, + }, + { + networkClientId: otherGoerliRpcEndpoint.networkClientId, + }, + ); + + expect(transactionController.state.transactions[0]).toStrictEqual( + expect.objectContaining({ + networkClientId: otherGoerliRpcEndpoint.networkClientId, + }), + ); + transactionController.destroy(); + }); - const otherNetworkClientIdOnGoerli = - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - setActive: false, - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); + it('should stop tracking when a network is removed', async () => { + const { networkController, transactionController } = + await setupController(); - await transactionController.addTransaction( + const networkConfiguration = await networkController.addNetwork( + buildAddNetworkFields(), + ); + + networkController.removeNetwork(networkConfiguration.chainId); + + await expect( + transactionController.addTransaction( { from: ACCOUNT_MOCK, - to: ACCOUNT_3_MOCK, + to: ACCOUNT_2_MOCK, }, { - networkClientId: otherNetworkClientIdOnGoerli, + networkClientId: networkConfiguration.rpcEndpoints[0].networkClientId, }, - ); - - expect(transactionController.state.transactions[0]).toStrictEqual( - expect.objectContaining({ - networkClientId: otherNetworkClientIdOnGoerli, - }), - ); - transactionController.destroy(); - }); - it('should stop tracking when a network is removed', async () => { - const { networkController, transactionController } = - await setupController(); - - const configurationId = - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - setActive: false, - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); - - networkController.removeNetworkConfiguration(configurationId); - - await expect( - transactionController.addTransaction( - { - from: ACCOUNT_MOCK, - to: ACCOUNT_2_MOCK, - }, - { networkClientId: configurationId }, - ), - ).rejects.toThrow( - 'The networkClientId for this transaction could not be found', - ); + ), + ).rejects.toThrow( + 'The networkClientId for this transaction could not be found', + ); - expect(transactionController).toBeDefined(); - transactionController.destroy(); - }); + expect(transactionController).toBeDefined(); + transactionController.destroy(); }); describe('feature flag', () => { @@ -1096,15 +1146,9 @@ describe('TransactionController Integration', () => { isMultichainEnabled: false, }); - const configurationId = - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - setActive: false, - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); + const networkConfiguration = await networkController.addNetwork( + buildAddNetworkFields(), + ); // add a transaction with the networkClientId of the newly added network // and expect it to throw since the networkClientId won't be found in the trackingMap @@ -1114,7 +1158,10 @@ describe('TransactionController Integration', () => { from: ACCOUNT_MOCK, to: ACCOUNT_2_MOCK, }, - { networkClientId: configurationId }, + { + networkClientId: + networkConfiguration.rpcEndpoints[0].networkClientId, + }, ), ).rejects.toThrow( 'The networkClientId for this transaction could not be found', @@ -1129,14 +1176,9 @@ describe('TransactionController Integration', () => { ).toBeDefined(); transactionController.destroy(); }); + it('should not call getNetworkClientRegistry on networkController:stateChange when feature flag is disabled', async () => { - const getNetworkClientRegistrySpy = jest.fn().mockImplementation(() => { - return { - [NetworkType.goerli]: { - configuration: customGoerliNetworkClientConfiguration, - }, - }; - }); + const getNetworkClientRegistrySpy = jest.fn(); const { networkController, transactionController } = await setupController({ @@ -1144,45 +1186,28 @@ describe('TransactionController Integration', () => { getNetworkClientRegistry: getNetworkClientRegistrySpy, }); - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - setActive: false, - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); + await networkController.addNetwork(buildAddNetworkFields()); expect(getNetworkClientRegistrySpy).not.toHaveBeenCalled(); transactionController.destroy(); }); + it('should call getNetworkClientRegistry on networkController:stateChange when feature flag is enabled', async () => { - const getNetworkClientRegistrySpy = jest.fn().mockImplementation(() => { - return { - [NetworkType.goerli]: { - configuration: BUILT_IN_NETWORKS[NetworkType.goerli], - }, - }; - }); + uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); const { networkController, transactionController } = - await setupController({ - isMultichainEnabled: true, - getNetworkClientRegistry: getNetworkClientRegistrySpy, - }); - - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - setActive: false, - referrer: 'https://mock.referrer', - source: 'dapp', - }, + await setupController({ isMultichainEnabled: true }); + const getNetworkClientRegistrySpy = jest.spyOn( + networkController, + 'getNetworkClientRegistry', ); + await networkController.addNetwork(buildAddNetworkFields()); + expect(getNetworkClientRegistrySpy).toHaveBeenCalled(); transactionController.destroy(); }); + it('should call getNetworkClientRegistry on construction when feature flag is enabled', async () => { const getNetworkClientRegistrySpy = jest.fn().mockImplementation(() => { return { @@ -1387,9 +1412,8 @@ describe('TransactionController Integration', () => { // mock the other goerli network client node requests mockNetwork({ networkClientConfiguration: { + ...buildInfuraNetworkClientConfiguration(InfuraNetworkType.goerli), type: NetworkClientType.Custom, - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, rpcUrl: 'https://mock.rpc.url', }, mocks: [ @@ -1413,19 +1437,35 @@ describe('TransactionController Integration', () => { { selectedAccount: selectedAccountMock }, ); - const otherGoerliClientNetworkClientId = - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); + const existingGoerliNetworkConfiguration = + networkController.getNetworkConfigurationByChainId(ChainId.goerli); + assert( + existingGoerliNetworkConfiguration, + 'Could not find network configuration for Goerli', + ); + const updatedGoerliNetworkConfiguration = + await networkController.updateNetwork(ChainId.goerli, { + ...existingGoerliNetworkConfiguration, + rpcEndpoints: [ + ...existingGoerliNetworkConfiguration.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://mock.rpc.url', + }), + ], + }); + const otherGoerliRpcEndpoint = + updatedGoerliNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return rpcEndpoint.url === 'https://mock.rpc.url'; + }); + assert( + otherGoerliRpcEndpoint, + 'Could not find other Goerli RPC endpoint', + ); // Etherscan API Mocks // Non-token transactions - nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.goerli].chainId)) + nock(getEtherscanApiHost(ChainId.goerli)) .get( `/api?module=account&address=${ETHERSCAN_TRANSACTION_BASE_MOCK.to}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, ) @@ -1444,7 +1484,7 @@ describe('TransactionController Integration', () => { .persist(); // token transactions - nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.goerli].chainId)) + nock(getEtherscanApiHost(ChainId.goerli)) .get( `/api?module=account&address=${ETHERSCAN_TRANSACTION_BASE_MOCK.to}&offset=40&sort=desc&action=tokentx&tag=latest&page=1`, ) @@ -1464,7 +1504,7 @@ describe('TransactionController Integration', () => { // start polling with two clients which share the same chainId transactionController.startIncomingTransactionPolling([ NetworkType.goerli, - otherGoerliClientNetworkClientId, + otherGoerliRpcEndpoint.networkClientId, ]); await advanceTime({ clock, duration: 1 }); expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(1); @@ -1882,21 +1922,41 @@ describe('TransactionController Integration', () => { }); mockNetwork({ - networkClientConfiguration: customGoerliNetworkClientConfiguration, + networkClientConfiguration: { + ...buildInfuraNetworkClientConfiguration(InfuraNetworkType.goerli), + rpcUrl: 'https://mock.rpc.url', + type: NetworkClientType.Custom, + }, mocks: [ buildEthBlockNumberRequestMock('0x1'), buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), ], }); - const otherNetworkClientIdOnGoerli = - await networkController.upsertNetworkConfiguration( - customGoerliNetworkClientConfiguration, - { - referrer: 'https://mock.referrer', - source: 'dapp', - }, - ); + const existingGoerliNetworkConfiguration = + networkController.getNetworkConfigurationByChainId(ChainId.goerli); + assert( + existingGoerliNetworkConfiguration, + 'Could not find network configuration for Goerli', + ); + const updatedGoerliNetworkConfiguration = + await networkController.updateNetwork(ChainId.goerli, { + ...existingGoerliNetworkConfiguration, + rpcEndpoints: [ + ...existingGoerliNetworkConfiguration.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://mock.rpc.url', + }), + ], + }); + const otherGoerliRpcEndpoint = + updatedGoerliNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return rpcEndpoint.url === 'https://mock.rpc.url'; + }); + assert( + otherGoerliRpcEndpoint, + 'Could not find other Goerli RPC endpoint', + ); const firstNonceLockPromise = transactionController.getNonceLock( ACCOUNT_MOCK, @@ -1910,7 +1970,7 @@ describe('TransactionController Integration', () => { const secondNonceLockPromise = transactionController.getNonceLock( ACCOUNT_MOCK, - otherNetworkClientIdOnGoerli, + otherGoerliRpcEndpoint.networkClientId, ); const delay = () => // TODO: Either fix this lint violation or explain why it's necessary to ignore. diff --git a/packages/transaction-controller/tsconfig.json b/packages/transaction-controller/tsconfig.json index bf67c43710..338e2016b8 100644 --- a/packages/transaction-controller/tsconfig.json +++ b/packages/transaction-controller/tsconfig.json @@ -12,5 +12,5 @@ { "path": "../gas-fee-controller" }, { "path": "../network-controller" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./tests"] } diff --git a/tests/helpers.ts b/tests/helpers.ts index ed0b2660f8..8267f1b7c8 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,3 +1,5 @@ +import { getKnownPropertyNames } from '@metamask/utils'; + /** * Advances the provided fake timer by a specified duration in incremental steps. * Between each step, any enqueued promises are processed. However, any setTimeouts created @@ -39,3 +41,48 @@ export async function advanceTime({ export async function flushPromises(): Promise { await new Promise(jest.requireActual('timers').setImmediate); } + +/** + * It's common when writing tests to need an object which fits the shape of a + * type. However, some properties are unimportant to a test, and so it's useful + * if such properties can get filled in with defaults if not explicitly + * provided, so that a complete version of that object can still be produced. + * + * A naive approach to doing this is to define those defaults and then mix them + * in with overrides using the spread operator; however, this causes issues if + * creating a default value causes a change in global test state — such as + * causing a mocked function to get called inadvertently. + * + * This function solves this problem by allowing defaults to be defined lazily. + * + * @param defaults - An object where each value is wrapped in a function so that + * it doesn't get evaluated unless `overrides` does not contain the key. + * @param overrides - The values to override the defaults with. + * @param finalizeObject - An optional function to call which will create the + * final version of the object. This is useful if you need to customize how a + * value receives its default version (say, if it needs be calculated based on + * some other property). + * @returns The complete version of the object. + */ +export function buildTestObject>( + defaults: { [K in keyof Type]: () => Type[K] }, + overrides: Partial, + finalizeObject?: (object: Type) => Type, +): Type { + const keys = [ + ...new Set([ + ...getKnownPropertyNames(defaults), + ...getKnownPropertyNames(overrides), + ]), + ]; + const object = keys.reduce((workingObject, key) => { + if (key in overrides) { + return { ...workingObject, [key]: overrides[key] }; + } else if (key in defaults) { + return { ...workingObject, [key]: defaults[key]() }; + } + return workingObject; + }, {} as never); + + return finalizeObject ? finalizeObject(object) : object; +} diff --git a/yarn.lock b/yarn.lock index 56468f32d9..c2876acf50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3198,11 +3198,13 @@ __metadata: lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" sinon: "npm:^9.2.4" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" + uri-js: "npm:^4.4.1" uuid: "npm:^8.3.2" languageName: unknown linkType: soft @@ -11387,6 +11389,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:^5.1.1": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 10/1fdae11a39ed9c8d85a24df19517c8372ee24fefea9cce3fae9eaad8e9cefbba5a3d4940c6fe31296b6addf76e035588c55798f7e6e147e1b7c0855f119e7fa5 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0"