diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index ef1bedd9bd5..4192b2fafec 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Infer RPC endpoint types from their URLs when initializing, adding, or updating network configurations. - Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) ## [36.0.0] diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 5f0cbc17be1..2302bc99a50 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -97,7 +97,7 @@ import type { const debugLog = createModuleLogger(projectLogger, 'NetworkController'); const INFURA_URL_REGEX = - /^https:\/\/(?[^.]+)\.infura\.io\/v\d+\/(?.+)$/u; + /^https:\/\/(?[^./]+)\.infura\.io\/v3\/(?[^/]+)$/u; export type Block = { baseFeePerGas?: string; @@ -168,7 +168,7 @@ export type InfuraRpcEndpoint = { * `{infuraProjectId}`, which will get replaced with the Infura project ID * when the network client is created. */ - url: `https://${InfuraNetworkType}.infura.io/v3/{infuraProjectId}`; + url: string; }; /** @@ -277,8 +277,20 @@ export type NetworkConfiguration = { */ export type AddNetworkCustomRpcEndpointFields = Omit< CustomRpcEndpoint, - 'networkClientId' ->; + 'networkClientId' | 'type' +> & { + /** + * The type of the endpoint. If omitted, it is inferred from the URL. + */ + type?: RpcEndpointType; +}; + +type AddNetworkInfuraRpcEndpointFields = Omit & { + /** + * The type of the endpoint. If omitted, it is inferred from the URL. + */ + type?: RpcEndpointType; +}; /** * A new network configuration that `addNetwork` takes. @@ -288,7 +300,10 @@ export type AddNetworkCustomRpcEndpointFields = Omit< * network clients yet. */ export type AddNetworkFields = Omit & { - rpcEndpoints: (InfuraRpcEndpoint | AddNetworkCustomRpcEndpointFields)[]; + rpcEndpoints: ( + | AddNetworkInfuraRpcEndpointFields + | AddNetworkCustomRpcEndpointFields + )[]; }; /** @@ -299,10 +314,22 @@ export type AddNetworkFields = Omit & { * 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 UpdateNetworkCustomRpcEndpointFields = Partialize< - CustomRpcEndpoint, - 'networkClientId' ->; +export type UpdateNetworkCustomRpcEndpointFields = Omit< + Partialize, + 'type' +> & { + /** + * The type of the endpoint. If omitted, it is inferred from the URL. + */ + type?: RpcEndpointType; +}; + +type UpdateNetworkInfuraRpcEndpointFields = Omit & { + /** + * The type of the endpoint. If omitted, it is inferred from the URL. + */ + type?: RpcEndpointType; +}; /** * An updated representation of an existing network configuration that @@ -313,9 +340,45 @@ export type UpdateNetworkCustomRpcEndpointFields = Partialize< * assumed that they are new and are not represented by network clients yet. */ export type UpdateNetworkFields = Omit & { - rpcEndpoints: (InfuraRpcEndpoint | UpdateNetworkCustomRpcEndpointFields)[]; + rpcEndpoints: ( + | UpdateNetworkInfuraRpcEndpointFields + | UpdateNetworkCustomRpcEndpointFields + )[]; +}; + +type RpcEndpointWithOptionalType = + | (Omit & { + type?: RpcEndpointType; + }) + | (Omit & { + type?: RpcEndpointType; + }); + +type NetworkConfigurationWithOptionalRpcEndpointType = Omit< + NetworkConfiguration, + 'rpcEndpoints' +> & { + rpcEndpoints: RpcEndpointWithOptionalType[]; }; +type NetworkStateWithOptionalRpcEndpointType = Omit< + NetworkState, + 'networkConfigurationsByChainId' +> & { + networkConfigurationsByChainId: Record< + Hex, + NetworkConfigurationWithOptionalRpcEndpointType + >; +}; + +function hasRpcEndpointTypes( + networkConfiguration: NetworkConfigurationWithOptionalRpcEndpointType, +): networkConfiguration is NetworkConfiguration { + return networkConfiguration.rpcEndpoints.every( + (rpcEndpoint) => rpcEndpoint.type !== undefined, + ); +} + /** * `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 @@ -772,7 +835,7 @@ export type NetworkControllerOptions = { * specified, `networkConfigurationsByChainId` will default to a basic set of * network configurations (see {@link InfuraNetworkType} for the list). */ - state?: Partial; + state?: Partial; /** * A `loglevel` logger object. */ @@ -1128,9 +1191,180 @@ function deriveInfuraNetworkNameFromRpcEndpointUrl( return match.groups.networkName; } + /* istanbul ignore next -- The URL is matched before this function is called. */ throw new Error('Could not derive Infura network from RPC endpoint URL'); } +type RpcEndpointFields = { + failoverUrls?: string[]; + name?: string; + networkClientId?: NetworkClientId; + type?: RpcEndpointType; + url: string; +}; + +type InferredRpcEndpoint = + | (Omit & { + type: RpcEndpointType.Infura; + }) + | (Omit & { + networkClientId?: CustomNetworkClientId; + type: RpcEndpointType.Custom; + }); + +function isInfuraRpcEndpoint( + rpcEndpointFields: RpcEndpointFields, +): rpcEndpointFields is Extract< + InferredRpcEndpoint, + { type: RpcEndpointType.Infura } +> { + return ( + rpcEndpointFields.type === RpcEndpointType.Infura && + rpcEndpointFields.networkClientId !== undefined + ); +} + +function isCustomRpcEndpoint( + rpcEndpointFields: RpcEndpointFields, +): rpcEndpointFields is Extract< + InferredRpcEndpoint, + { type: RpcEndpointType.Custom } +> { + return rpcEndpointFields.type === RpcEndpointType.Custom; +} + +function hasNetworkClientId( + rpcEndpoint: InferredRpcEndpoint, +): rpcEndpoint is RpcEndpoint { + return rpcEndpoint.networkClientId !== undefined; +} + +/** + * Checks whether an RPC URL is a MetaMask Infura endpoint. The URL may contain + * either the placeholder persisted in built-in network configurations or the + * controller's Infura project ID. + * + * @param url - The RPC URL to check. + * @param infuraProjectId - The controller's Infura project ID. + * @returns Whether the URL is a MetaMask Infura endpoint. + */ +function isInfuraEndpointUrl(url: string, infuraProjectId: string): boolean { + const projectId = INFURA_URL_REGEX.exec(url)?.groups?.projectId; + return projectId === '{infuraProjectId}' || projectId === infuraProjectId; +} + +/** + * Infers the type of an RPC endpoint from its URL. + * + * @param rpcEndpointFields - The RPC endpoint fields. + * @param infuraProjectId - The controller's Infura project ID. + * @returns The RPC endpoint fields with an inferred type. + */ +function inferRpcEndpointType( + rpcEndpointFields: RpcEndpointFields, + infuraProjectId: string, +): InferredRpcEndpoint { + if (isInfuraEndpointUrl(rpcEndpointFields.url, infuraProjectId)) { + if (isInfuraRpcEndpoint(rpcEndpointFields)) { + return rpcEndpointFields; + } + + return { + ...rpcEndpointFields, + networkClientId: + rpcEndpointFields.networkClientId ?? + deriveInfuraNetworkNameFromRpcEndpointUrl(rpcEndpointFields.url), + type: RpcEndpointType.Infura, + }; + } + + if (isCustomRpcEndpoint(rpcEndpointFields)) { + return rpcEndpointFields; + } + + return { + ...rpcEndpointFields, + type: RpcEndpointType.Custom, + }; +} + +/** + * Normalizes the endpoint types in a network configuration. + * + * @param networkConfiguration - The network configuration to normalize. + * @param infuraProjectId - The controller's Infura project ID. + * @returns The normalized network configuration. + */ +function normalizeNetworkConfiguration( + networkConfiguration: NetworkConfigurationWithOptionalRpcEndpointType, + infuraProjectId: string, +): NetworkConfiguration { + let hasChanges = false; + const rpcEndpoints = networkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + const inferredRpcEndpoint = inferRpcEndpointType( + rpcEndpoint, + infuraProjectId, + ); + + /* istanbul ignore if -- State endpoint IDs are required by the public state type. */ + if (!hasNetworkClientId(inferredRpcEndpoint)) { + throw new Error( + `Network configuration '${networkConfiguration.name}' has an RPC endpoint without a network client ID`, + ); + } + + hasChanges ||= inferredRpcEndpoint !== rpcEndpoint; + return inferredRpcEndpoint; + }); + + if (!hasChanges && hasRpcEndpointTypes(networkConfiguration)) { + return networkConfiguration; + } + + return { + ...networkConfiguration, + rpcEndpoints, + }; +} + +/** + * Constructs the initial NetworkController state and infers RPC endpoint + * types in any provided network configurations. + * + * @param state - The desired initial state. + * @param infuraProjectId - The controller's Infura project ID. + * @returns The complete normalized initial state. + */ +function getInitialState( + state: NetworkControllerOptions['state'], + infuraProjectId: string, +): NetworkState { + const defaultState = getDefaultNetworkControllerState(); + const networkConfigurationsByChainId = + state?.networkConfigurationsByChainId ?? + defaultState.networkConfigurationsByChainId; + + const normalizedNetworkConfigurationsByChainId = Object.entries( + networkConfigurationsByChainId, + ).reduce>( + (normalizedConfigurations, [chainId, networkConfiguration]) => { + const normalizedNetworkConfiguration = normalizeNetworkConfiguration( + networkConfiguration, + infuraProjectId, + ); + normalizedConfigurations[chainId as Hex] = normalizedNetworkConfiguration; + return normalizedConfigurations; + }, + {}, + ); + + return { + ...defaultState, + ...state, + networkConfigurationsByChainId: normalizedNetworkConfigurationsByChainId, + }; +} + /** * Performs a series of checks that the given NetworkController state is * internally consistent — that all parts of state that are supposed to match in @@ -1330,17 +1564,15 @@ export class NetworkController extends BaseController< getBlockTrackerOptions, analyticsOptions, } = options; - const initialState = { - ...getDefaultNetworkControllerState(), - ...state, - }; + const initialState = getInitialState(state, infuraProjectId); validateInitialState(initialState); - const correctedInitialState = correctInitialState(initialState, messenger); if (!infuraProjectId || typeof infuraProjectId !== 'string') { throw new Error('Invalid Infura project ID'); } + const correctedInitialState = correctInitialState(initialState, messenger); + super({ name: controllerName, metadata: { @@ -2119,14 +2351,21 @@ export class NetworkController extends BaseController< * @see {@link NetworkConfiguration} */ addNetwork(fields: AddNetworkFields): NetworkConfiguration { - const { rpcEndpoints: setOfRpcEndpointFields } = fields; + const fieldsWithInferredRpcEndpointTypes = { + ...fields, + rpcEndpoints: fields.rpcEndpoints.map((rpcEndpointFields) => + inferRpcEndpointType(rpcEndpointFields, this.#infuraProjectId), + ), + }; + const { rpcEndpoints: setOfRpcEndpointFields } = + fieldsWithInferredRpcEndpointTypes; const autoManagedNetworkClientRegistry = this.#ensureAutoManagedNetworkClientRegistryPopulated(); this.#validateNetworkFields({ mode: 'add', - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, autoManagedNetworkClientRegistry, }); @@ -2148,11 +2387,11 @@ export class NetworkController extends BaseController< const newNetworkConfiguration = this.#determineNetworkConfigurationToPersist({ - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkClientOperations, }); this.#registerNetworkClientsAsNeeded({ - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkClientOperations, autoManagedNetworkClientRegistry, }); @@ -2160,7 +2399,7 @@ export class NetworkController extends BaseController< this.#updateNetworkConfigurations({ state, mode: 'add', - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkConfigurationToPersist: newNetworkConfiguration, }); }); @@ -2214,15 +2453,21 @@ export class NetworkController extends BaseController< } const existingChainId = chainId; + const fieldsWithInferredRpcEndpointTypes = { + ...fields, + rpcEndpoints: fields.rpcEndpoints.map((rpcEndpointFields) => + inferRpcEndpointType(rpcEndpointFields, this.#infuraProjectId), + ), + }; const { chainId: newChainId, rpcEndpoints: setOfNewRpcEndpointFields } = - fields; + fieldsWithInferredRpcEndpointTypes; const autoManagedNetworkClientRegistry = this.#ensureAutoManagedNetworkClientRegistryPopulated(); this.#validateNetworkFields({ mode: 'update', - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, existingNetworkConfiguration, autoManagedNetworkClientRegistry, }); @@ -2352,7 +2597,7 @@ export class NetworkController extends BaseController< const updatedNetworkConfiguration = this.#determineNetworkConfigurationToPersist({ - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkClientOperations, }); @@ -2379,7 +2624,7 @@ export class NetworkController extends BaseController< } this.#registerNetworkClientsAsNeeded({ - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkClientOperations, autoManagedNetworkClientRegistry, }); @@ -2423,7 +2668,7 @@ export class NetworkController extends BaseController< this.#updateNetworkConfigurations({ state, mode: 'update', - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkConfigurationToPersist: updatedNetworkConfiguration, existingNetworkConfiguration, }); @@ -2434,7 +2679,7 @@ export class NetworkController extends BaseController< this.#updateNetworkConfigurations({ state, mode: 'update', - networkFields: fields, + networkFields: fieldsWithInferredRpcEndpointTypes, networkConfigurationToPersist: updatedNetworkConfiguration, existingNetworkConfiguration, }); @@ -3196,8 +3441,7 @@ export class NetworkController extends BaseController< type: RpcEndpointType.Infura, networkClientId: registryNetworkConfig.rpcProviders.default.networkClientId, - url: registryNetworkConfig.rpcProviders.default - .url as InfuraRpcEndpoint['url'], + url: registryNetworkConfig.rpcProviders.default.url, } : { type: RpcEndpointType.Custom, diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 5ef7bc6b351..93c3c677de8 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -421,6 +421,60 @@ describe('NetworkController', () => { ); }); + it.each([ + 'https://mainnet.infura.io/v3/{infuraProjectId}', + 'https://mainnet.infura.io/v3/infura-project-id', + ])('corrects RPC endpoint types based on their URLs', async (url) => { + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url, + }), + ], + }), + }, + }, + }, + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId['0x1337'] + .rpcEndpoints[0].type, + ).toBe(RpcEndpointType.Infura); + }, + ); + }); + + it('infers the type of RPC endpoints missing a type in the initial state', async () => { + const { type: _, ...rpcEndpoint } = buildCustomRpcEndpoint({ + url: 'https://custom.endpoint', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [rpcEndpoint], + }), + }, + }, + }, + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId['0x1337'] + .rpcEndpoints[0], + ).toMatchObject({ + type: RpcEndpointType.Custom, + url: 'https://custom.endpoint', + }); + }, + ); + }); + it('removes invalid network client IDs from networksMetadata, logging this fact', () => { const messenger = buildRootMessenger(); const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); @@ -4248,6 +4302,46 @@ describe('NetworkController', () => { }); }); + it('infers the type of an RPC endpoint when adding a network', async () => { + const { type: _, ...rpcEndpoint } = + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://custom.endpoint', + }); + + await withController(({ controller }) => { + const result = controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [rpcEndpoint], + }), + ); + + expect(result.rpcEndpoints[0]).toMatchObject({ + type: RpcEndpointType.Custom, + url: 'https://custom.endpoint', + }); + }); + }); + + it('infers an Infura RPC endpoint when adding a network', async () => { + const rpcEndpoint = { + url: 'https://some-network.infura.io/v3/{infuraProjectId}', + }; + + await withController(({ controller }) => { + const result = controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [rpcEndpoint], + }), + ); + + expect(result.rpcEndpoints[0]).toStrictEqual({ + networkClientId: 'some-network', + type: RpcEndpointType.Infura, + url: 'https://some-network.infura.io/v3/{infuraProjectId}', + }); + }); + }); + it('throws if the rpcEndpoints field is an empty array', async () => { await withController(({ controller }) => { expect(() => @@ -5453,6 +5547,41 @@ describe('NetworkController', () => { ); }); + it('infers the type of an RPC endpoint when updating a network', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + const { type: _, ...rpcEndpoint } = + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://custom.endpoint', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const result = await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); + + expect(result.rpcEndpoints[0]).toMatchObject({ + type: RpcEndpointType.Custom, + url: 'https://custom.endpoint', + }); + }, + ); + }); + it('throws if one of the new rpcEndpoints has an invalid url property', async () => { const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ chainId: '0x1337',