From 3577d3545f79ed0ffa8a7ad8a14cd177c7898293 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 11 Apr 2023 10:07:24 -0600 Subject: [PATCH] Convert NetworkController to TS (#18358) Converting this controller to TypeScript furthers the goal of getting this whole codebase converted, of course, but it also helps in comparing the differences between this version of the NetworkController and the version in the `core` repo more easily, which will ultimately help us in coalescing the two implementations. Co-authored-by: Mark Stacey Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com> --- app/scripts/controllers/detect-tokens.test.js | 4 +- app/scripts/controllers/network/index.js | 1 - app/scripts/controllers/network/index.ts | 1 + .../controllers/network/network-controller.js | 679 ---------- .../network/network-controller.test.js | 10 +- .../controllers/network/network-controller.ts | 1171 +++++++++++++++++ app/scripts/controllers/preferences.test.js | 2 +- app/scripts/metamask-controller.js | 27 +- package.json | 2 +- shared/constants/network.ts | 2 +- types/eth-query.d.ts | 50 + yarn.lock | 10 +- 12 files changed, 1251 insertions(+), 708 deletions(-) delete mode 100644 app/scripts/controllers/network/index.js create mode 100644 app/scripts/controllers/network/index.ts delete mode 100644 app/scripts/controllers/network/network-controller.js create mode 100644 app/scripts/controllers/network/network-controller.ts create mode 100644 types/eth-query.d.ts diff --git a/app/scripts/controllers/detect-tokens.test.js b/app/scripts/controllers/detect-tokens.test.js index c90ebc590..10db93359 100644 --- a/app/scripts/controllers/detect-tokens.test.js +++ b/app/scripts/controllers/detect-tokens.test.js @@ -13,7 +13,7 @@ import { convertHexToDecimal } from '@metamask/controller-utils'; import { NETWORK_TYPES } from '../../../shared/constants/network'; import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; import DetectTokensController from './detect-tokens'; -import NetworkController, { NetworkControllerEventTypes } from './network'; +import { NetworkController, NetworkControllerEventType } from './network'; import PreferencesController from './preferences'; describe('DetectTokensController', function () { @@ -248,7 +248,7 @@ describe('DetectTokensController', function () { ), onNetworkStateChange: (cb) => networkControllerMessenger.subscribe( - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, () => { const networkState = network.store.getState(); const modifiedNetworkState = { diff --git a/app/scripts/controllers/network/index.js b/app/scripts/controllers/network/index.js deleted file mode 100644 index b91e16698..000000000 --- a/app/scripts/controllers/network/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default, NetworkControllerEventTypes } from './network-controller'; diff --git a/app/scripts/controllers/network/index.ts b/app/scripts/controllers/network/index.ts new file mode 100644 index 000000000..de3e59ea1 --- /dev/null +++ b/app/scripts/controllers/network/index.ts @@ -0,0 +1 @@ +export * from './network-controller'; diff --git a/app/scripts/controllers/network/network-controller.js b/app/scripts/controllers/network/network-controller.js deleted file mode 100644 index 4449bc683..000000000 --- a/app/scripts/controllers/network/network-controller.js +++ /dev/null @@ -1,679 +0,0 @@ -import { strict as assert } from 'assert'; -import EventEmitter from 'events'; -import { ComposedStore, ObservableStore } from '@metamask/obs-store'; -import log from 'loglevel'; -import { - createSwappableProxy, - createEventEmitterProxy, -} from '@metamask/swappable-obj-proxy'; -import EthQuery from 'eth-query'; -// ControllerMessenger is referred to in the JSDocs -// eslint-disable-next-line no-unused-vars -import { ControllerMessenger } from '@metamask/base-controller'; -import { v4 as random } from 'uuid'; -import { hasProperty, isPlainObject } from '@metamask/utils'; -import { errorCodes } from 'eth-rpc-errors'; -import { - INFURA_PROVIDER_TYPES, - BUILT_IN_NETWORKS, - INFURA_BLOCKED_KEY, - TEST_NETWORK_TICKER_MAP, - CHAIN_IDS, - NETWORK_TYPES, - NetworkStatus, -} from '../../../../shared/constants/network'; -import { - isPrefixedFormattedHexString, - isSafeChainId, -} from '../../../../shared/modules/network.utils'; -import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; -import { createNetworkClient } from './create-network-client'; - -/** - * @typedef {object} NetworkConfiguration - * @property {string} rpcUrl - RPC target URL. - * @property {string} chainId - Network ID as per EIP-155 - * @property {string} ticker - Currency ticker. - * @property {object} [rpcPrefs] - Personalized preferences. - * @property {string} [nickname] - Personalized network name. - */ - -function buildDefaultProviderConfigState() { - if (process.env.IN_TEST) { - return { - type: NETWORK_TYPES.RPC, - rpcUrl: 'http://localhost:8545', - chainId: '0x539', - nickname: 'Localhost 8545', - ticker: 'ETH', - }; - } else if ( - process.env.METAMASK_DEBUG || - process.env.METAMASK_ENV === 'test' - ) { - return { - type: NETWORK_TYPES.GOERLI, - chainId: CHAIN_IDS.GOERLI, - ticker: TEST_NETWORK_TICKER_MAP.GOERLI, - }; - } - - return { - type: NETWORK_TYPES.MAINNET, - chainId: CHAIN_IDS.MAINNET, - ticker: 'ETH', - }; -} - -function buildDefaultNetworkIdState() { - return null; -} - -function buildDefaultNetworkStatusState() { - return NetworkStatus.Unknown; -} - -function buildDefaultNetworkDetailsState() { - return { - EIPS: { - 1559: undefined, - }, - }; -} - -function buildDefaultNetworkConfigurationsState() { - return {}; -} - -/** - * The name of the controller. - */ -const name = 'NetworkController'; - -/** - * The set of event types that this controller can publish via its messenger. - */ -export const NetworkControllerEventTypes = { - /** - * Fired after the current network is changed. - */ - NetworkDidChange: `${name}:networkDidChange`, - /** - * Fired when there is a request to change the current network, but no state - * changes have occurred yet. - */ - NetworkWillChange: `${name}:networkWillChange`, - /** - * Fired after the network is changed to an Infura network, but when Infura - * returns an error denying support for the user's location. - */ - InfuraIsBlocked: `${name}:infuraIsBlocked`, - /** - * Fired after the network is changed to an Infura network and Infura does not - * return an error denying support for the user's location, or after the - * network is changed to a custom network. - */ - InfuraIsUnblocked: `${name}:infuraIsUnblocked`, -}; - -export default class NetworkController extends EventEmitter { - /** - * Construct a NetworkController. - * - * @param {object} options - Options for this controller. - * @param {ControllerMessenger} options.messenger - The controller messenger. - * @param {object} [options.state] - Initial controller state. - * @param {string} [options.infuraProjectId] - The Infura project ID. - * @param {string} [options.trackMetaMetricsEvent] - A method to forward events to the MetaMetricsController - */ - constructor({ - messenger, - state = {}, - infuraProjectId, - trackMetaMetricsEvent, - } = {}) { - super(); - - this.messenger = messenger; - - // create stores - this.providerStore = new ObservableStore( - state.provider || buildDefaultProviderConfigState(), - ); - this.previousProviderStore = new ObservableStore( - this.providerStore.getState(), - ); - this.networkIdStore = new ObservableStore(buildDefaultNetworkIdState()); - this.networkStatusStore = new ObservableStore( - buildDefaultNetworkStatusState(), - ); - // We need to keep track of a few details about the current network. - // Ideally we'd merge this.networkStatusStore with this new store, but doing - // so will require a decent sized refactor of how we're accessing network - // state. Currently this is only used for detecting EIP-1559 support but can - // be extended to track other network details. - this.networkDetails = new ObservableStore( - state.networkDetails || buildDefaultNetworkDetailsState(), - ); - - this.networkConfigurationsStore = new ObservableStore( - state.networkConfigurations || buildDefaultNetworkConfigurationsState(), - ); - - this.store = new ComposedStore({ - provider: this.providerStore, - previousProviderStore: this.previousProviderStore, - networkId: this.networkIdStore, - networkStatus: this.networkStatusStore, - networkDetails: this.networkDetails, - networkConfigurations: this.networkConfigurationsStore, - }); - - // provider and block tracker - this._provider = null; - this._blockTracker = null; - - // provider and block tracker proxies - because the network changes - this._providerProxy = null; - this._blockTrackerProxy = null; - - if (!infuraProjectId || typeof infuraProjectId !== 'string') { - throw new Error('Invalid Infura project ID'); - } - this._infuraProjectId = infuraProjectId; - - this._trackMetaMetricsEvent = trackMetaMetricsEvent; - } - - /** - * Destroy the network controller, stopping any ongoing polling. - * - * In-progress requests will not be aborted. - */ - async destroy() { - await this._blockTracker?.destroy(); - } - - async initializeProvider() { - const { type, rpcUrl, chainId } = this.providerStore.getState(); - this._configureProvider({ type, rpcUrl, chainId }); - await this.lookupNetwork(); - } - - // return the proxies so the references will always be good - getProviderAndBlockTracker() { - const provider = this._providerProxy; - const blockTracker = this._blockTrackerProxy; - return { provider, blockTracker }; - } - - /** - * Determines whether the network supports EIP-1559 by checking whether the - * latest block has a `baseFeePerGas` property, then updates state - * appropriately. - * - * @returns {Promise} A promise that resolves to true if the network - * supports EIP-1559 and false otherwise. - */ - async getEIP1559Compatibility() { - const { EIPS } = this.networkDetails.getState(); - // NOTE: This isn't necessary anymore because the block cache middleware - // already prevents duplicate requests from taking place - if (EIPS[1559] !== undefined) { - return EIPS[1559]; - } - const supportsEIP1559 = await this._determineEIP1559Compatibility(); - this.networkDetails.updateState({ - EIPS: { - ...this.networkDetails.getState().EIPS, - 1559: supportsEIP1559, - }, - }); - return supportsEIP1559; - } - - /** - * Captures information about the currently selected network — namely, - * the network ID and whether the network supports EIP-1559 — and then uses - * the results of these requests to determine the status of the network. - */ - async lookupNetwork() { - const { chainId, type } = this.providerStore.getState(); - let networkChanged = false; - let networkId; - let supportsEIP1559; - let networkStatus; - - if (!this._provider) { - log.warn( - 'NetworkController - lookupNetwork aborted due to missing provider', - ); - return; - } - - if (!chainId) { - log.warn( - 'NetworkController - lookupNetwork aborted due to missing chainId', - ); - this._resetNetworkId(); - this._resetNetworkStatus(); - this._resetNetworkDetails(); - return; - } - - const isInfura = INFURA_PROVIDER_TYPES.includes(type); - - const listener = () => { - networkChanged = true; - this.messenger.unsubscribe( - NetworkControllerEventTypes.NetworkDidChange, - listener, - ); - }; - this.messenger.subscribe( - NetworkControllerEventTypes.NetworkDidChange, - listener, - ); - - try { - const results = await Promise.all([ - this._getNetworkId(), - this._determineEIP1559Compatibility(), - ]); - networkId = results[0]; - supportsEIP1559 = results[1]; - networkStatus = NetworkStatus.Available; - } catch (error) { - if (hasProperty(error, 'code')) { - let responseBody; - try { - responseBody = JSON.parse(error.message); - } catch { - // error.message must not be JSON - } - - if ( - isPlainObject(responseBody) && - responseBody.error === INFURA_BLOCKED_KEY - ) { - networkStatus = NetworkStatus.Blocked; - } else if (error.code === errorCodes.rpc.internal) { - networkStatus = NetworkStatus.Unknown; - } else { - networkStatus = NetworkStatus.Unavailable; - } - } else { - log.warn( - 'NetworkController - could not determine network status', - error, - ); - networkStatus = NetworkStatus.Unknown; - } - } - - if (networkChanged) { - // If the network has changed, then `lookupNetwork` either has been or is - // in the process of being called, so we don't need to go further. - return; - } - this.messenger.unsubscribe( - NetworkControllerEventTypes.NetworkDidChange, - listener, - ); - - this.networkStatusStore.putState(networkStatus); - - if (networkStatus === NetworkStatus.Available) { - this.networkIdStore.putState(networkId); - this.networkDetails.updateState({ - EIPS: { - ...this.networkDetails.getState().EIPS, - 1559: supportsEIP1559, - }, - }); - } else { - this._resetNetworkId(); - this._resetNetworkDetails(); - } - - if (isInfura) { - if (networkStatus === NetworkStatus.Available) { - this.messenger.publish(NetworkControllerEventTypes.InfuraIsUnblocked); - } else if (networkStatus === NetworkStatus.Blocked) { - this.messenger.publish(NetworkControllerEventTypes.InfuraIsBlocked); - } - } else { - // Always publish infuraIsUnblocked regardless of network status to - // prevent consumers from being stuck in a blocked state if they were - // previously connected to an Infura network that was blocked - this.messenger.publish(NetworkControllerEventTypes.InfuraIsUnblocked); - } - } - - /** - * A method for setting the currently selected network provider by networkConfigurationId. - * - * @param {string} networkConfigurationId - the universal unique identifier that corresponds to the network configuration to set as active. - * @returns {string} The rpcUrl of the network that was just set as active - */ - setActiveNetwork(networkConfigurationId) { - const targetNetwork = - this.networkConfigurationsStore.getState()[networkConfigurationId]; - - if (!targetNetwork) { - throw new Error( - `networkConfigurationId ${networkConfigurationId} does not match a configured networkConfiguration`, - ); - } - - this._setProviderConfig({ - type: NETWORK_TYPES.RPC, - ...targetNetwork, - }); - - return targetNetwork.rpcUrl; - } - - setProviderType(type) { - assert.notStrictEqual( - type, - NETWORK_TYPES.RPC, - `NetworkController - cannot call "setProviderType" with type "${NETWORK_TYPES.RPC}". Use "setActiveNetwork"`, - ); - assert.ok( - INFURA_PROVIDER_TYPES.includes(type), - `Unknown Infura provider type "${type}".`, - ); - const { chainId, ticker, blockExplorerUrl } = BUILT_IN_NETWORKS[type]; - this._setProviderConfig({ - type, - rpcUrl: '', - chainId, - ticker: ticker ?? 'ETH', - nickname: '', - rpcPrefs: { blockExplorerUrl }, - }); - } - - resetConnection() { - this._setProviderConfig(this.providerStore.getState()); - } - - rollbackToPreviousProvider() { - const config = this.previousProviderStore.getState(); - this.providerStore.putState(config); - this._switchNetwork(config); - } - - // - // Private - // - - /** - * Method to return the latest block for the current network - * - * @returns {object} Block header - */ - _getLatestBlock() { - const { provider } = this.getProviderAndBlockTracker(); - const ethQuery = new EthQuery(provider); - - return new Promise((resolve, reject) => { - ethQuery.sendAsync( - { method: 'eth_getBlockByNumber', params: ['latest', false] }, - (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }, - ); - }); - } - - /** - * Get the network ID for the current selected network - * - * @returns {string} The network ID for the current network. - */ - async _getNetworkId() { - const { provider } = this.getProviderAndBlockTracker(); - const ethQuery = new EthQuery(provider); - - return await new Promise((resolve, reject) => { - ethQuery.sendAsync({ method: 'net_version' }, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); - } - - /** - * Clears the stored network ID. - */ - _resetNetworkId() { - this.networkIdStore.putState(buildDefaultNetworkIdState()); - } - - /** - * Resets network status to the default ("unknown"). - */ - _resetNetworkStatus() { - this.networkStatusStore.putState(buildDefaultNetworkStatusState()); - } - - /** - * Clears details previously stored for the network. - */ - _resetNetworkDetails() { - this.networkDetails.putState(buildDefaultNetworkDetailsState()); - } - - /** - * Sets the provider config and switches the network. - * - * @param config - */ - _setProviderConfig(config) { - this.previousProviderStore.putState(this.providerStore.getState()); - this.providerStore.putState(config); - this._switchNetwork(config); - } - - /** - * Retrieves the latest block from the currently selected network; if the - * block has a `baseFeePerGas` property, then we know that the network - * supports EIP-1559; otherwise it doesn't. - * - * @returns {Promise} A promise that resolves to true if the network - * supports EIP-1559 and false otherwise. - */ - async _determineEIP1559Compatibility() { - const latestBlock = await this._getLatestBlock(); - return latestBlock && latestBlock.baseFeePerGas !== undefined; - } - - _switchNetwork(opts) { - this.messenger.publish(NetworkControllerEventTypes.NetworkWillChange); - this._resetNetworkId(); - this._resetNetworkStatus(); - this._resetNetworkDetails(); - this._configureProvider(opts); - this.messenger.publish(NetworkControllerEventTypes.NetworkDidChange); - this.lookupNetwork(); - } - - _configureProvider({ type, rpcUrl, chainId }) { - // infura type-based endpoints - const isInfura = INFURA_PROVIDER_TYPES.includes(type); - if (isInfura) { - this._configureInfuraProvider({ - type, - infuraProjectId: this._infuraProjectId, - }); - // url-based rpc endpoints - } else if (type === NETWORK_TYPES.RPC) { - this._configureStandardProvider(rpcUrl, chainId); - } else { - throw new Error( - `NetworkController - _configureProvider - unknown type "${type}"`, - ); - } - } - - _configureInfuraProvider({ type, infuraProjectId }) { - log.info('NetworkController - configureInfuraProvider', type); - const { provider, blockTracker } = createNetworkClient({ - network: type, - infuraProjectId, - type: 'infura', - }); - this._setProviderAndBlockTracker({ provider, blockTracker }); - } - - _configureStandardProvider(rpcUrl, chainId) { - log.info('NetworkController - configureStandardProvider', rpcUrl); - const { provider, blockTracker } = createNetworkClient({ - chainId, - rpcUrl, - type: 'custom', - }); - this._setProviderAndBlockTracker({ provider, blockTracker }); - } - - _setProviderAndBlockTracker({ provider, blockTracker }) { - // update or initialize proxies - if (this._providerProxy) { - this._providerProxy.setTarget(provider); - } else { - this._providerProxy = createSwappableProxy(provider); - } - if (this._blockTrackerProxy) { - this._blockTrackerProxy.setTarget(blockTracker); - } else { - this._blockTrackerProxy = createEventEmitterProxy(blockTracker, { - eventFilter: 'skipInternal', - }); - } - // set new provider and blockTracker - this._provider = provider; - this._blockTracker = blockTracker; - } - - /** - * Network Configuration management functions - */ - - /** - * Adds a network configuration if the rpcUrl is not already present on an - * existing network configuration. Otherwise updates the entry with the matching rpcUrl. - * - * @param {NetworkConfiguration} networkConfiguration - The network configuration to add or, if rpcUrl matches an existing entry, to modify. - * @param {object} options - * @param {boolean} options.setActive - An option to set the newly added networkConfiguration as the active provider. - * @param {string} options.referrer - The site from which the call originated, or 'metamask' for internal calls - used for event metrics. - * @param {string} options.source - Where the upsertNetwork event originated (i.e. from a dapp or from the network form)- used for event metrics. - * @returns {string} id for the added or updated network configuration - */ - upsertNetworkConfiguration( - { rpcUrl, chainId, ticker, nickname, rpcPrefs }, - { setActive = false, referrer, source }, - ) { - assert.ok( - isPrefixedFormattedHexString(chainId), - `Invalid chain ID "${chainId}": invalid hex string.`, - ); - assert.ok( - isSafeChainId(parseInt(chainId, 16)), - `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 { - // eslint-disable-next-line no-new - new URL(rpcUrl); - } catch (e) { - if (e.message.includes('Invalid URL')) { - throw new Error('rpcUrl must be a valid URL'); - } - } - - if (!ticker) { - throw new Error( - 'A ticker is required to add or update networkConfiguration', - ); - } - - const networkConfigurations = this.networkConfigurationsStore.getState(); - const newNetworkConfiguration = { - rpcUrl, - chainId, - ticker, - nickname, - rpcPrefs, - }; - - const oldNetworkConfigurationId = Object.values(networkConfigurations).find( - (networkConfiguration) => - networkConfiguration.rpcUrl?.toLowerCase() === rpcUrl?.toLowerCase(), - )?.id; - - const newNetworkConfigurationId = oldNetworkConfigurationId || random(); - this.networkConfigurationsStore.putState({ - ...networkConfigurations, - [newNetworkConfigurationId]: { - ...newNetworkConfiguration, - id: newNetworkConfigurationId, - }, - }); - - if (!oldNetworkConfigurationId) { - this._trackMetaMetricsEvent({ - event: 'Custom Network Added', - category: MetaMetricsEventCategory.Network, - referrer: { - url: referrer, - }, - properties: { - chain_id: chainId, - symbol: ticker, - source, - }, - }); - } - - if (setActive) { - this.setActiveNetwork(newNetworkConfigurationId); - } - - return newNetworkConfigurationId; - } - - /** - * Removes network configuration from state. - * - * @param {string} networkConfigurationId - the unique id for the network configuration to remove. - */ - removeNetworkConfiguration(networkConfigurationId) { - const networkConfigurations = { - ...this.networkConfigurationsStore.getState(), - }; - delete networkConfigurations[networkConfigurationId]; - this.networkConfigurationsStore.putState(networkConfigurations); - } -} diff --git a/app/scripts/controllers/network/network-controller.test.js b/app/scripts/controllers/network/network-controller.test.js index a58c563a8..fb86440c4 100644 --- a/app/scripts/controllers/network/network-controller.test.js +++ b/app/scripts/controllers/network/network-controller.test.js @@ -6,7 +6,7 @@ import sinon from 'sinon'; import { ControllerMessenger } from '@metamask/base-controller'; import { BUILT_IN_NETWORKS } from '../../../../shared/constants/network'; import { MetaMetricsNetworkEventSource } from '../../../../shared/constants/metametrics'; -import NetworkController from './network-controller'; +import { NetworkController } from './network-controller'; jest.mock('uuid', () => { const actual = jest.requireActual('uuid'); @@ -1100,7 +1100,7 @@ describe('NetworkController', () => { }); describe('when the request for the latest block responds with null', () => { - it('stores null as whether the network supports EIP-1559', async () => { + it('persists false to state as whether the network supports EIP-1559', async () => { await withController( { state: { @@ -1118,13 +1118,13 @@ describe('NetworkController', () => { await controller.getEIP1559Compatibility(); expect(controller.store.getState().networkDetails.EIPS[1559]).toBe( - null, + false, ); }, ); }); - it('returns null', async () => { + it('returns false', async () => { await withController(async ({ controller, network }) => { network.mockEssentialRpcCalls({ latestBlock: null, @@ -1133,7 +1133,7 @@ describe('NetworkController', () => { const supportsEIP1559 = await controller.getEIP1559Compatibility(); - expect(supportsEIP1559).toBe(null); + expect(supportsEIP1559).toBe(false); }); }); }); diff --git a/app/scripts/controllers/network/network-controller.ts b/app/scripts/controllers/network/network-controller.ts new file mode 100644 index 000000000..9249e0fa2 --- /dev/null +++ b/app/scripts/controllers/network/network-controller.ts @@ -0,0 +1,1171 @@ +import { strict as assert } from 'assert'; +import EventEmitter from 'events'; +import { ComposedStore, ObservableStore } from '@metamask/obs-store'; +import log from 'loglevel'; +import { + createSwappableProxy, + createEventEmitterProxy, + SwappableProxy, +} from '@metamask/swappable-obj-proxy'; +import EthQuery from 'eth-query'; +import { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { v4 as uuid } from 'uuid'; +import { Hex, isPlainObject } from '@metamask/utils'; +import { errorCodes } from 'eth-rpc-errors'; +import { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; +import { PollingBlockTracker } from 'eth-block-tracker'; +import { + INFURA_PROVIDER_TYPES, + INFURA_BLOCKED_KEY, + TEST_NETWORK_TICKER_MAP, + CHAIN_IDS, + NETWORK_TYPES, + BUILT_IN_INFURA_NETWORKS, + BuiltInInfuraNetwork, + NetworkStatus, +} from '../../../../shared/constants/network'; +import { + isPrefixedFormattedHexString, + isSafeChainId, +} from '../../../../shared/modules/network.utils'; +import { + MetaMetricsEventCategory, + MetaMetricsEventPayload, +} from '../../../../shared/constants/metametrics'; +import { isErrorWithMessage } from '../../../../shared/modules/error'; +import { + createNetworkClient, + NetworkClientType, +} from './create-network-client'; + +/** + * The name of NetworkController. + */ +const name = 'NetworkController'; + +/** + * A block header object that `eth_getBlockByNumber` returns. Note that this + * type does not specify all of the properties present within the block header; + * within NetworkController, we are only interested in `baseFeePerGas`. + */ +type Block = { + baseFeePerGas?: unknown; +}; + +/** + * Encodes a few pieces of information: + * + * - Whether or not a provider is configured for an Infura network or a + * non-Infura network. + * - If an Infura network, then which network. + * - If a non-Infura network, then whether the network exists locally or + * remotely. + * + * Primarily used to build the network client and check the availability of a + * network. + */ +type ProviderType = BuiltInInfuraNetwork | typeof NETWORK_TYPES.RPC; + +/** + * The network ID of a network. + */ +type NetworkId = `${number}`; + +/** + * The ID of a network configuration. + */ +type NetworkConfigurationId = string; + +/** + * The chain ID of a network. + */ +type ChainId = Hex; + +/** + * The set of event types that NetworkController can publish via its messenger. + */ +export enum NetworkControllerEventType { + /** + * @see {@link NetworkControllerNetworkWillChangeEvent} + */ + NetworkWillChange = 'NetworkController:networkWillChange', + /** + * @see {@link NetworkControllerNetworkDidChangeEvent} + */ + NetworkDidChange = 'NetworkController:networkDidChange', + /** + * @see {@link NetworkControllerInfuraIsBlockedEvent} + */ + InfuraIsBlocked = 'NetworkController:infuraIsBlocked', + /** + * @see {@link NetworkControllerInfuraIsUnblockedEvent} + */ + InfuraIsUnblocked = 'NetworkController:infuraIsUnblocked', +} + +/** + * `networkWillChange` is published when the current network is about to be + * switched, but the new provider has not been created and no state changes have + * occurred yet. + */ +type NetworkControllerNetworkWillChangeEvent = { + type: NetworkControllerEventType.NetworkWillChange; + payload: []; +}; + +/** + * `networkDidChange` is published after a provider has been created for a newly + * switched network (but before the network has been confirmed to be available). + */ +type NetworkControllerNetworkDidChangeEvent = { + type: NetworkControllerEventType.NetworkDidChange; + payload: []; +}; + +/** + * `infuraIsBlocked` is published after the network is switched to an Infura + * network, but when Infura returns an error blocking the user based on their + * location. + */ +type NetworkControllerInfuraIsBlockedEvent = { + type: NetworkControllerEventType.InfuraIsBlocked; + payload: []; +}; + +/** + * `infuraIsBlocked` is published either after the network is switched to an + * Infura network and Infura does not return an error blocking the user based on + * their location, or the network is switched to a non-Infura network. + */ +type NetworkControllerInfuraIsUnblockedEvent = { + type: NetworkControllerEventType.InfuraIsUnblocked; + payload: []; +}; + +/** + * The set of events that the NetworkController messenger can publish. + */ +type NetworkControllerEvent = + | NetworkControllerNetworkDidChangeEvent + | NetworkControllerNetworkWillChangeEvent + | NetworkControllerInfuraIsBlockedEvent + | NetworkControllerInfuraIsUnblockedEvent; + +/** + * The messenger that the NetworkController uses to publish events. + */ +type NetworkControllerMessenger = RestrictedControllerMessenger< + typeof name, + never, + NetworkControllerEvent, + never, + NetworkControllerEventType +>; + +/** + * Information used to set up the middleware stack for a particular kind of + * network. Currently has overlap with `NetworkConfiguration`, although the + * two will be merged down the road. + */ +type ProviderConfiguration = { + /** + * Either a type of Infura network, "localhost" for a locally operated + * network, or "rpc" for everything else. + */ + type: ProviderType; + /** + * The chain ID as per EIP-155. + */ + chainId: ChainId; + /** + * The URL of the RPC endpoint. Only used when `type` is "localhost" or "rpc". + */ + rpcUrl?: string; + /** + * The shortname of the currency used by the network. + */ + ticker?: string; + /** + * The user-customizable name of the network. + */ + nickname?: string; + /** + * User-customizable details for the network. + */ + rpcPrefs?: { + blockExplorerUrl?: string; + }; +}; + +/** + * The contents of the `networkId` store. + */ +type NetworkIdState = NetworkId | null; + +/** + * Information about the network not held by any other part of state. Currently + * only used to capture whether a network supports EIP-1559. + */ +type NetworkDetails = { + /** + * EIPs supported by the network. + */ + EIPS: { + [eipNumber: number]: boolean | undefined; + }; +}; + +/** + * A "network configuration" represents connection data directly provided by + * users via the wallet UI for a custom network (we already have this + * information for networks that come pre-shipped with the wallet). Ultimately + * used to set up the middleware stack so that the wallet can make requests to + * the network. Currently has overlap with `ProviderConfiguration`, although the + * two will be merged down the road. + */ +type NetworkConfiguration = { + /** + * The unique ID of the network configuration. Useful for switching to and + * removing specific networks. + */ + id: NetworkConfigurationId; + /** + * The URL of the RPC endpoint. Only used when `type` is "localhost" or "rpc". + */ + rpcUrl: string; + /** + * The chain ID as per EIP-155. + */ + chainId: ChainId; + /** + * The shortname of the currency used for this network. + */ + ticker: string; + /** + * The user-customizable name of the network. + */ + nickname?: string; + /** + * User-customizable details for the network. + */ + rpcPrefs?: { + blockExplorerUrl: string; + }; +}; + +/** + * A set of network configurations, keyed by ID. + */ +type NetworkConfigurations = Record< + NetworkConfigurationId, + NetworkConfiguration +>; + +/** + * The state that NetworkController holds after combining its individual stores. + */ +type CompositeState = { + provider: ProviderConfiguration; + previousProviderStore: ProviderConfiguration; + networkId: NetworkIdState; + networkStatus: NetworkStatus; + networkDetails: NetworkDetails; + networkConfigurations: NetworkConfigurations; +}; + +/** + * The options that NetworkController takes. + */ +type NetworkControllerOptions = { + messenger: NetworkControllerMessenger; + state?: { + provider?: ProviderConfiguration; + networkDetails?: NetworkDetails; + networkConfigurations?: NetworkConfigurations; + }; + infuraProjectId: string; + trackMetaMetricsEvent: (payload: MetaMetricsEventPayload) => void; +}; + +/** + * Type guard for determining whether the given value is an error object with a + * `code` property, such as an instance of Error. + * + * TODO: Move this to @metamask/utils + * + * @param error - The object to check. + * @returns True if `error` has a `code`, false otherwise. + */ +function isErrorWithCode(error: unknown): error is { code: string | number } { + return typeof error === 'object' && error !== null && 'code' in error; +} + +/** + * Asserts that the given value is a network ID, i.e., that it is a decimal + * number represented as a string. + * + * @param value - The value to check. + */ +function assertNetworkId(value: any): asserts value is NetworkId { + assert( + /^\d+$/u.test(value) && !Number.isNaN(Number(value)), + 'value is not a number', + ); +} + +/** + * Builds the default provider config used to initialize the network controller. + */ +function buildDefaultProviderConfigState(): ProviderConfiguration { + if (process.env.IN_TEST) { + return { + type: NETWORK_TYPES.RPC, + rpcUrl: 'http://localhost:8545', + chainId: '0x539', + nickname: 'Localhost 8545', + ticker: 'ETH', + }; + } else if ( + process.env.METAMASK_DEBUG || + process.env.METAMASK_ENV === 'test' + ) { + return { + type: NETWORK_TYPES.GOERLI, + chainId: CHAIN_IDS.GOERLI, + ticker: TEST_NETWORK_TICKER_MAP[NETWORK_TYPES.GOERLI], + }; + } + + return { + type: NETWORK_TYPES.MAINNET, + chainId: CHAIN_IDS.MAINNET, + ticker: 'ETH', + }; +} + +/** + * Builds the default network ID state used to initialize the network + * controller. + */ +function buildDefaultNetworkIdState(): NetworkIdState { + return null; +} + +/** + * Builds the default network status state used to initialize the network + * controller. + */ +function buildDefaultNetworkStatusState(): NetworkStatus { + return NetworkStatus.Unknown; +} + +/** + * Builds the default network details state used to initialize the + * network controller. + */ +function buildDefaultNetworkDetailsState(): NetworkDetails { + return { + EIPS: { + 1559: undefined, + }, + }; +} + +/** + * Builds the default network configurations state used to initialize the + * network controller. + */ +function buildDefaultNetworkConfigurationsState(): NetworkConfigurations { + return {}; +} + +/** + * Returns whether the given argument is a type that our Infura middleware + * recognizes. We can't calculate this inline because the usual type of `type`, + * which we get from the provider config, is not a subset of the type of + * `INFURA_PROVIDER_TYPES`, but rather a superset, and therefore we cannot make + * a proper comparison without TypeScript complaining. However, if we downcast + * both variables, then we are able to achieve this. As a bonus, this function + * also types the given argument as a `BuiltInInfuraNetwork` assuming that the + * check succeeds. + * + * @param type - A type to compare. + * @returns True or false, depending on whether the given type is one that our + * Infura middleware recognizes. + */ +function isInfuraProviderType(type: string): type is BuiltInInfuraNetwork { + const infuraProviderTypes: readonly string[] = INFURA_PROVIDER_TYPES; + return infuraProviderTypes.includes(type); +} + +/** + * The network controller creates and manages the "provider" object which allows + * our code and external dapps to make requests to a network. The requests are + * filtered through a set of middleware (provided by + * [`eth-json-rpc-middleware`][1]) which not only performs the HTTP request to + * the appropriate RPC endpoint but also uses caching to limit duplicate + * requests to Infura and smoothens interactions with the blockchain in general. + * + * [1]: https://github.com/MetaMask/eth-json-rpc-middleware + */ +export class NetworkController extends EventEmitter { + /** + * The messenger that NetworkController uses to publish events. + */ + messenger: NetworkControllerMessenger; + + /** + * Observable store containing the provider configuration. + */ + providerStore: ObservableStore; + + /** + * Observable store containing the provider configuration for the previously + * configured network. + */ + previousProviderStore: ObservableStore; + + /** + * Observable store containing the network ID for the current network or null + * if there is no current network. + */ + networkIdStore: ObservableStore; + + /** + * Observable store for the network status. + */ + networkStatusStore: ObservableStore; + + /** + * Observable store for details about the network. + */ + networkDetails: ObservableStore; + + /** + * Observable store for network configurations. + */ + networkConfigurationsStore: ObservableStore; + + /** + * Observable store containing a combination of data from all of the + * individual stores. + */ + store: ComposedStore; + + _provider: SafeEventEmitterProvider | null; + + _blockTracker: PollingBlockTracker | null; + + _providerProxy: SwappableProxy | null; + + _blockTrackerProxy: SwappableProxy | null; + + _infuraProjectId: NetworkControllerOptions['infuraProjectId']; + + _trackMetaMetricsEvent: NetworkControllerOptions['trackMetaMetricsEvent']; + + /** + * Constructs a network controller. + * + * @param options - Options for this constructor. + * @param options.messenger - The NetworkController messenger. + * @param options.state - Initial controller state. + * @param options.infuraProjectId - The Infura project ID. + * @param options.trackMetaMetricsEvent - A method to forward events to the + * {@link MetaMetricsController}. + */ + constructor({ + messenger, + state = {}, + infuraProjectId, + trackMetaMetricsEvent, + }: NetworkControllerOptions) { + super(); + + this.messenger = messenger; + + // create stores + this.providerStore = new ObservableStore( + state.provider || buildDefaultProviderConfigState(), + ); + this.previousProviderStore = new ObservableStore( + this.providerStore.getState(), + ); + this.networkIdStore = new ObservableStore(buildDefaultNetworkIdState()); + this.networkStatusStore = new ObservableStore( + buildDefaultNetworkStatusState(), + ); + // We need to keep track of a few details about the current network. + // Ideally we'd merge this.networkStatusStore with this new store, but doing + // so will require a decent sized refactor of how we're accessing network + // state. Currently this is only used for detecting EIP-1559 support but can + // be extended to track other network details. + this.networkDetails = new ObservableStore( + state.networkDetails || buildDefaultNetworkDetailsState(), + ); + + this.networkConfigurationsStore = new ObservableStore( + state.networkConfigurations || buildDefaultNetworkConfigurationsState(), + ); + + this.store = new ComposedStore({ + provider: this.providerStore, + previousProviderStore: this.previousProviderStore, + networkId: this.networkIdStore, + networkStatus: this.networkStatusStore, + networkDetails: this.networkDetails, + networkConfigurations: this.networkConfigurationsStore, + }); + + // provider and block tracker + this._provider = null; + this._blockTracker = null; + + // provider and block tracker proxies - because the network changes + this._providerProxy = null; + this._blockTrackerProxy = null; + + if (!infuraProjectId || typeof infuraProjectId !== 'string') { + throw new Error('Invalid Infura project ID'); + } + this._infuraProjectId = infuraProjectId; + this._trackMetaMetricsEvent = trackMetaMetricsEvent; + } + + /** + * Deactivates the controller, stopping any ongoing polling. + * + * In-progress requests will not be aborted. + */ + async destroy(): Promise { + await this._blockTracker?.destroy(); + } + + /** + * Creates the provider and block tracker for the configured network, + * using the provider to gather details about the network. + */ + async initializeProvider(): Promise { + const { type, rpcUrl, chainId } = this.providerStore.getState(); + this._configureProvider({ type, rpcUrl, chainId }); + await this.lookupNetwork(); + } + + /** + * Returns the proxies wrapping the currently set provider and block tracker. + */ + getProviderAndBlockTracker(): { + provider: SwappableProxy | null; + blockTracker: SwappableProxy | null; + } { + const provider = this._providerProxy; + const blockTracker = this._blockTrackerProxy; + return { provider, blockTracker }; + } + + /** + * Determines whether the network supports EIP-1559 by checking whether the + * latest block has a `baseFeePerGas` property, then updates state + * appropriately. + * + * @returns A promise that resolves to true if the network supports EIP-1559 + * and false otherwise. + */ + async getEIP1559Compatibility(): Promise { + const { EIPS } = this.networkDetails.getState(); + // NOTE: This isn't necessary anymore because the block cache middleware + // already prevents duplicate requests from taking place + if (EIPS[1559] !== undefined) { + return EIPS[1559]; + } + + const { provider } = this.getProviderAndBlockTracker(); + if (!provider) { + // Really we should throw an error if a provider hasn't been initialized + // yet, but that might have undesirable repercussions, so return false for + // now + return false; + } + + const supportsEIP1559 = await this._determineEIP1559Compatibility(provider); + this.networkDetails.updateState({ + EIPS: { + ...this.networkDetails.getState().EIPS, + 1559: supportsEIP1559, + }, + }); + return supportsEIP1559; + } + + /** + * Performs side effects after switching to a network. If the network is + * available, updates the network state with the network ID of the network and + * stores whether the network supports EIP-1559; otherwise clears said + * information about the network that may have been previously stored. + * + * @fires infuraIsBlocked if the network is Infura-supported and is blocking + * requests. + * @fires infuraIsUnblocked if the network is Infura-supported and is not + * blocking requests, or if the network is not Infura-supported. + */ + async lookupNetwork(): Promise { + const { chainId, type } = this.providerStore.getState(); + const { provider } = this.getProviderAndBlockTracker(); + let networkChanged = false; + let networkId: NetworkIdState = null; + let supportsEIP1559 = false; + let networkStatus: NetworkStatus; + + if (provider === null) { + log.warn( + 'NetworkController - lookupNetwork aborted due to missing provider', + ); + return; + } + + if (!chainId) { + log.warn( + 'NetworkController - lookupNetwork aborted due to missing chainId', + ); + this._resetNetworkId(); + this._resetNetworkStatus(); + this._resetNetworkDetails(); + return; + } + + const isInfura = isInfuraProviderType(type); + + const listener = () => { + networkChanged = true; + this.messenger.unsubscribe( + NetworkControllerEventType.NetworkDidChange, + listener, + ); + }; + this.messenger.subscribe( + NetworkControllerEventType.NetworkDidChange, + listener, + ); + + try { + const results = await Promise.all([ + this._getNetworkId(provider), + this._determineEIP1559Compatibility(provider), + ]); + const possibleNetworkId = results[0]; + assertNetworkId(possibleNetworkId); + networkId = possibleNetworkId; + supportsEIP1559 = results[1]; + networkStatus = NetworkStatus.Available; + } catch (error) { + if (isErrorWithCode(error) && isErrorWithMessage(error)) { + let responseBody; + try { + responseBody = JSON.parse(error.message); + } catch { + // error.message must not be JSON + } + + if ( + isPlainObject(responseBody) && + responseBody.error === INFURA_BLOCKED_KEY + ) { + networkStatus = NetworkStatus.Blocked; + } else if (error.code === errorCodes.rpc.internal) { + networkStatus = NetworkStatus.Unknown; + } else { + networkStatus = NetworkStatus.Unavailable; + } + } else { + log.warn( + 'NetworkController - could not determine network status', + error, + ); + networkStatus = NetworkStatus.Unknown; + } + } + + if (networkChanged) { + // If the network has changed, then `lookupNetwork` either has been or is + // in the process of being called, so we don't need to go further. + return; + } + this.messenger.unsubscribe( + NetworkControllerEventType.NetworkDidChange, + listener, + ); + + this.networkStatusStore.putState(networkStatus); + + if (networkStatus === NetworkStatus.Available) { + this.networkIdStore.putState(networkId); + this.networkDetails.updateState({ + EIPS: { + ...this.networkDetails.getState().EIPS, + 1559: supportsEIP1559, + }, + }); + } else { + this._resetNetworkId(); + this._resetNetworkDetails(); + } + + if (isInfura) { + if (networkStatus === NetworkStatus.Available) { + this.messenger.publish(NetworkControllerEventType.InfuraIsUnblocked); + } else if (networkStatus === NetworkStatus.Blocked) { + this.messenger.publish(NetworkControllerEventType.InfuraIsBlocked); + } + } else { + // Always publish infuraIsUnblocked regardless of network status to + // prevent consumers from being stuck in a blocked state if they were + // previously connected to an Infura network that was blocked + this.messenger.publish(NetworkControllerEventType.InfuraIsUnblocked); + } + } + + /** + * Switches to the network specified by a network configuration. + * + * @param networkConfigurationId - The unique identifier that refers to a + * previously added network configuration. + * @returns The URL of the RPC endpoint representing the newly switched + * network. + */ + setActiveNetwork(networkConfigurationId: NetworkConfigurationId): string { + const targetNetwork = + this.networkConfigurationsStore.getState()[networkConfigurationId]; + + if (!targetNetwork) { + throw new Error( + `networkConfigurationId ${networkConfigurationId} does not match a configured networkConfiguration`, + ); + } + + this._setProviderConfig({ + type: NETWORK_TYPES.RPC, + ...targetNetwork, + }); + + return targetNetwork.rpcUrl; + } + + /** + * Switches to an Infura-supported network. + * + * @param type - The shortname of the network. + * @throws if the `type` is "rpc" or if it is not a known Infura-supported + * network. + */ + setProviderType(type: string): void { + assert.notStrictEqual( + type, + NETWORK_TYPES.RPC, + `NetworkController - cannot call "setProviderType" with type "${NETWORK_TYPES.RPC}". Use "setActiveNetwork"`, + ); + assert.ok( + isInfuraProviderType(type), + `Unknown Infura provider type "${type}".`, + ); + const network = BUILT_IN_INFURA_NETWORKS[type]; + this._setProviderConfig({ + type, + rpcUrl: '', + chainId: network.chainId, + ticker: 'ticker' in network ? network.ticker : 'ETH', + nickname: '', + rpcPrefs: { blockExplorerUrl: network.blockExplorerUrl }, + }); + } + + /** + * Re-initializes the provider and block tracker for the current network. + */ + resetConnection(): void { + this._setProviderConfig(this.providerStore.getState()); + } + + /** + * Switches to the previous network, assuming that the current network is + * different than the initial network (if it is, then this is equivalent to + * calling `resetConnection`). + */ + rollbackToPreviousProvider(): void { + const config = this.previousProviderStore.getState(); + this.providerStore.putState(config); + this._switchNetwork(config); + } + + /** + * Fetches the latest block for the network. + * + * @param provider - A provider, which is guaranteed to be available. + * @returns A promise that either resolves to the block header or null if + * there is no latest block, or rejects with an error. + */ + _getLatestBlock(provider: SafeEventEmitterProvider): Promise { + return new Promise((resolve, reject) => { + const ethQuery = new EthQuery(provider); + ethQuery.sendAsync<['latest', false], Block | null>( + { method: 'eth_getBlockByNumber', params: ['latest', false] }, + (...args) => { + if (args[0] === null) { + resolve(args[1]); + } else { + reject(args[0]); + } + }, + ); + }); + } + + /** + * Fetches the network ID for the network. + * + * @param provider - A provider, which is guaranteed to be available. + * @returns A promise that either resolves to the network ID, or rejects with + * an error. + */ + async _getNetworkId(provider: SafeEventEmitterProvider): Promise { + const ethQuery = new EthQuery(provider); + return await new Promise((resolve, reject) => { + ethQuery.sendAsync( + { method: 'net_version' }, + (...args) => { + if (args[0] === null) { + resolve(args[1]); + } else { + reject(args[0]); + } + }, + ); + }); + } + + /** + * Clears the stored network ID. + */ + _resetNetworkId(): void { + this.networkIdStore.putState(buildDefaultNetworkIdState()); + } + + /** + * Resets network status to the default ("unknown"). + */ + _resetNetworkStatus(): void { + this.networkStatusStore.putState(buildDefaultNetworkStatusState()); + } + + /** + * Clears details previously stored for the network. + */ + _resetNetworkDetails(): void { + this.networkDetails.putState(buildDefaultNetworkDetailsState()); + } + + /** + * Stores the given provider configuration representing a network in state, + * then uses it to create a new provider for that network. + * + * @param providerConfig - The provider configuration. + */ + _setProviderConfig(providerConfig: ProviderConfiguration): void { + this.previousProviderStore.putState(this.providerStore.getState()); + this.providerStore.putState(providerConfig); + this._switchNetwork(providerConfig); + } + + /** + * Retrieves the latest block from the currently selected network; if the + * block has a `baseFeePerGas` property, then we know that the network + * supports EIP-1559; otherwise it doesn't. + * + * @param provider - A provider, which is guaranteed to be available. + * @returns A promise that resolves to true if the network supports EIP-1559 + * and false otherwise. + */ + async _determineEIP1559Compatibility( + provider: SafeEventEmitterProvider, + ): Promise { + const latestBlock = await this._getLatestBlock(provider); + return latestBlock?.baseFeePerGas !== undefined; + } + + /** + * Executes a series of steps to change the current network: + * + * 1. Notifies subscribers that the network is about to change. + * 2. Clears state associated with the current network. + * 3. Creates a new network client along with a provider for the desired + * network. + * 4. Notifies subscribes that the network has changed. + * + * @param providerConfig - The provider configuration object that specifies + * the new network. + */ + _switchNetwork(providerConfig: ProviderConfiguration): void { + this.messenger.publish(NetworkControllerEventType.NetworkWillChange); + this._resetNetworkId(); + this._resetNetworkStatus(); + this._resetNetworkDetails(); + this._configureProvider(providerConfig); + this.messenger.publish(NetworkControllerEventType.NetworkDidChange); + this.lookupNetwork(); + } + + /** + * Creates a network client (a stack of middleware along with a provider and + * block tracker) to talk to a network. + * + * @param args - The arguments. + * @param args.type - The shortname of an Infura-supported network (see + * {@link NETWORK_TYPES}). + * @param args.rpcUrl - The URL of the RPC endpoint that represents the + * network. Only used for non-Infura networks. + * @param args.chainId - The chain ID of the network (as per EIP-155). Only + * used for non-Infura-supported networks (as we already know the chain ID of + * any Infura-supported network). + * @throws if the `type` if not a known Infura-supported network. + */ + _configureProvider({ type, rpcUrl, chainId }: ProviderConfiguration): void { + const isInfura = isInfuraProviderType(type); + if (isInfura) { + // infura type-based endpoints + this._configureInfuraProvider({ + type, + infuraProjectId: this._infuraProjectId, + }); + } else if (type === NETWORK_TYPES.RPC && rpcUrl) { + // url-based rpc endpoints + this._configureStandardProvider(rpcUrl, chainId); + } else { + throw new Error( + `NetworkController - _configureProvider - unknown type "${type}"`, + ); + } + } + + /** + * Creates a network client (a stack of middleware along with a provider and + * block tracker) to talk to an Infura-supported network. + * + * @param args - The arguments. + * @param args.type - The shortname of the Infura network (see + * {@link NETWORK_TYPES}). + * @param args.infuraProjectId - An Infura API key. ("Project ID" is a + * now-obsolete term we've retained for backward compatibility.) + */ + _configureInfuraProvider({ + type, + infuraProjectId, + }: { + type: BuiltInInfuraNetwork; + infuraProjectId: NetworkControllerOptions['infuraProjectId']; + }): void { + log.info('NetworkController - configureInfuraProvider', type); + const { provider, blockTracker } = createNetworkClient({ + network: type, + infuraProjectId, + type: NetworkClientType.Infura, + }); + this._setProviderAndBlockTracker({ provider, blockTracker }); + } + + /** + * Creates a network client (a stack of middleware along with a provider and + * block tracker) to talk to a non-Infura-supported network. + * + * @param rpcUrl - The URL of the RPC endpoint that represents the network. + * @param chainId - The chain ID of the network (as per EIP-155). + */ + _configureStandardProvider(rpcUrl: string, chainId: ChainId): void { + log.info('NetworkController - configureStandardProvider', rpcUrl); + const { provider, blockTracker } = createNetworkClient({ + chainId, + rpcUrl, + type: NetworkClientType.Custom, + }); + this._setProviderAndBlockTracker({ provider, blockTracker }); + } + + /** + * Given a provider and a block tracker, updates any proxies pointing to + * these objects that have been previously set, or initializes any proxies + * that have not been previously set. + * + * @param args - The arguments. + * @param args.provider - The provider. + * @param args.blockTracker - The block tracker. + */ + _setProviderAndBlockTracker({ + provider, + blockTracker, + }: { + provider: SafeEventEmitterProvider; + blockTracker: PollingBlockTracker; + }): void { + // update or initialize proxies + if (this._providerProxy) { + this._providerProxy.setTarget(provider); + } else { + this._providerProxy = createSwappableProxy(provider); + } + if (this._blockTrackerProxy) { + this._blockTrackerProxy.setTarget(blockTracker); + } else { + this._blockTrackerProxy = createEventEmitterProxy(blockTracker, { + eventFilter: 'skipInternal', + }); + } + // set new provider and blockTracker + this._provider = provider; + this._blockTracker = blockTracker; + } + + /** + * Network Configuration management functions + */ + + /** + * Updates an existing network configuration matching the same RPC URL as the + * given network configuration; otherwise adds the network configuration. + * Following the upsert, the `trackMetaMetricsEvent` callback specified + * via the NetworkController constructor will be called to (presumably) create + * a MetaMetrics event. + * + * @param networkConfiguration - The network configuration to upsert. + * @param networkConfiguration.chainId - The chain ID of the network as per + * EIP-155. + * @param networkConfiguration.ticker - The shortname of the currency used by + * the network. + * @param networkConfiguration.nickname - The user-customizable name of the + * network. + * @param networkConfiguration.rpcPrefs - User-customizable details for the + * network. + * @param networkConfiguration.rpcUrl - The URL of the RPC endpoint. + * @param additionalArgs - Additional arguments. + * @param additionalArgs.setActive - Switches to the network specified by + * the given network configuration following the upsert. + * @param additionalArgs.referrer - The site from which the call originated, + * or 'metamask' for internal calls; used for event metrics. + * @param additionalArgs.source - Where the metric event originated (i.e. from + * a dapp or from the network form); used for event metrics. + * @throws if the `chainID` does not match EIP-155 or is too large. + * @throws if `rpcUrl` is not a valid URL. + * @returns The ID for the added or updated network configuration. + */ + upsertNetworkConfiguration( + { + rpcUrl, + chainId, + ticker, + nickname, + rpcPrefs, + }: Omit, + { + setActive = false, + referrer, + source, + }: { + setActive?: boolean; + referrer: string; + source: string; + }, + ): NetworkConfigurationId { + assert.ok( + isPrefixedFormattedHexString(chainId), + `Invalid chain ID "${chainId}": invalid hex string.`, + ); + assert.ok( + isSafeChainId(parseInt(chainId, 16)), + `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 { + // eslint-disable-next-line no-new + new URL(rpcUrl); + } catch (e) { + if (isErrorWithMessage(e) && e.message.includes('Invalid URL')) { + throw new Error('rpcUrl must be a valid URL'); + } + } + + if (!ticker) { + throw new Error( + 'A ticker is required to add or update networkConfiguration', + ); + } + + const networkConfigurations = this.networkConfigurationsStore.getState(); + const newNetworkConfiguration = { + rpcUrl, + chainId, + ticker, + nickname, + rpcPrefs, + }; + + const oldNetworkConfigurationId = Object.values(networkConfigurations).find( + (networkConfiguration) => + networkConfiguration.rpcUrl?.toLowerCase() === rpcUrl?.toLowerCase(), + )?.id; + + const newNetworkConfigurationId = oldNetworkConfigurationId || uuid(); + this.networkConfigurationsStore.putState({ + ...networkConfigurations, + [newNetworkConfigurationId]: { + ...newNetworkConfiguration, + id: newNetworkConfigurationId, + }, + }); + + if (!oldNetworkConfigurationId) { + this._trackMetaMetricsEvent({ + event: 'Custom Network Added', + category: MetaMetricsEventCategory.Network, + referrer: { + url: referrer, + }, + properties: { + chain_id: chainId, + symbol: ticker, + source, + }, + }); + } + + if (setActive) { + this.setActiveNetwork(newNetworkConfigurationId); + } + + return newNetworkConfigurationId; + } + + /** + * Removes a network configuration from state. + * + * @param networkConfigurationId - The unique id for the network configuration + * to remove. + */ + removeNetworkConfiguration( + networkConfigurationId: NetworkConfigurationId, + ): void { + const networkConfigurations = { + ...this.networkConfigurationsStore.getState(), + }; + delete networkConfigurations[networkConfigurationId]; + this.networkConfigurationsStore.putState(networkConfigurations); + } +} diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index 51f2ba5a0..6fcb1bac5 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -4,7 +4,7 @@ import { ControllerMessenger } from '@metamask/base-controller'; import { TokenListController } from '@metamask/assets-controllers'; import { CHAIN_IDS } from '../../../shared/constants/network'; import PreferencesController from './preferences'; -import NetworkController from './network'; +import { NetworkController } from './network'; describe('preferences controller', function () { let preferencesController; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index c1d8216a0..7315f301f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -143,8 +143,9 @@ import createTabIdMiddleware from './lib/createTabIdMiddleware'; import createOnboardingMiddleware from './lib/createOnboardingMiddleware'; import { setupMultiplex } from './lib/stream-utils'; import EnsController from './controllers/ens'; -import NetworkController, { - NetworkControllerEventTypes, +import { + NetworkController, + NetworkControllerEventType, } from './controllers/network'; import PreferencesController from './controllers/preferences'; import AppStateController from './controllers/app-state'; @@ -263,7 +264,7 @@ export default class MetamaskController extends EventEmitter { const networkControllerMessenger = this.controllerMessenger.getRestricted({ name: 'NetworkController', - allowedEvents: Object.values(NetworkControllerEventTypes), + allowedEvents: Object.values(NetworkControllerEventType), }); this.networkController = new NetworkController({ messenger: networkControllerMessenger, @@ -310,11 +311,11 @@ export default class MetamaskController extends EventEmitter { initLangCode: opts.initLangCode, onInfuraIsBlocked: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.InfuraIsBlocked, + NetworkControllerEventType.InfuraIsBlocked, ), onInfuraIsUnblocked: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.InfuraIsUnblocked, + NetworkControllerEventType.InfuraIsUnblocked, ), tokenListController: this.tokenListController, provider: this.provider, @@ -452,7 +453,7 @@ export default class MetamaskController extends EventEmitter { preferencesStore: this.preferencesController.store, onNetworkDidChange: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, ), getNetworkIdentifier: () => { const { type, rpcUrl } = @@ -491,7 +492,7 @@ export default class MetamaskController extends EventEmitter { // onNetworkDidChange onNetworkStateChange: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, ), getCurrentNetworkEIP1559Compatibility: this.networkController.getEIP1559Compatibility.bind( @@ -609,7 +610,7 @@ export default class MetamaskController extends EventEmitter { this.networkController.store.getState().provider.chainId, onNetworkDidChange: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, ), }); @@ -621,7 +622,7 @@ export default class MetamaskController extends EventEmitter { blockTracker: this.blockTracker, onNetworkDidChange: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, ), getCurrentChainId: () => this.networkController.store.getState().provider.chainId, @@ -1106,7 +1107,7 @@ export default class MetamaskController extends EventEmitter { }); networkControllerMessenger.subscribe( - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, async () => { const { ticker } = this.networkController.store.getState().provider; try { @@ -1152,7 +1153,7 @@ export default class MetamaskController extends EventEmitter { networkController: this.networkController, onNetworkDidChange: networkControllerMessenger.subscribe.bind( networkControllerMessenger, - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, ), provider: this.provider, getProviderConfig: () => this.networkController.store.getState().provider, @@ -1195,7 +1196,7 @@ export default class MetamaskController extends EventEmitter { // ensure accountTracker updates balances after network change networkControllerMessenger.subscribe( - NetworkControllerEventTypes.NetworkDidChange, + NetworkControllerEventType.NetworkDidChange, () => { this.accountTracker._updateAccounts(); }, @@ -1203,7 +1204,7 @@ export default class MetamaskController extends EventEmitter { // clear unapproved transactions and messages when the network will change networkControllerMessenger.subscribe( - NetworkControllerEventTypes.NetworkWillChange, + NetworkControllerEventType.NetworkWillChange, () => { this.txController.txStateManager.clearUnapprovedTxs(); this.encryptionPublicKeyManager.clearUnapproved(); diff --git a/package.json b/package.json index 47cb164e9..80df52fff 100644 --- a/package.json +++ b/package.json @@ -249,7 +249,7 @@ "@metamask/message-manager": "^2.1.0", "@metamask/metamask-eth-abis": "^3.0.0", "@metamask/notification-controller": "^1.0.0", - "@metamask/obs-store": "^8.0.0", + "@metamask/obs-store": "^8.1.0", "@metamask/permission-controller": "^3.1.0", "@metamask/phishing-controller": "^2.0.0", "@metamask/post-message-stream": "^6.0.0", diff --git a/shared/constants/network.ts b/shared/constants/network.ts index 1f20538c3..ab5115bee 100644 --- a/shared/constants/network.ts +++ b/shared/constants/network.ts @@ -238,7 +238,7 @@ export const INFURA_PROVIDER_TYPES = [ NETWORK_TYPES.MAINNET, NETWORK_TYPES.GOERLI, NETWORK_TYPES.SEPOLIA, -]; +] as const; export const TEST_CHAINS = [ CHAIN_IDS.GOERLI, diff --git a/types/eth-query.d.ts b/types/eth-query.d.ts new file mode 100644 index 000000000..726300f68 --- /dev/null +++ b/types/eth-query.d.ts @@ -0,0 +1,50 @@ +declare module 'eth-query' { + // What it says on the tin. We omit `null` because confusingly, this is used + // for a successful response to indicate a lack of an error. + type EverythingButNull = + | string + | number + | boolean + | object + | symbol + | undefined; + + type ProviderSendAsyncResponse = { + error?: { message: string }; + result?: Result; + }; + + type ProviderSendAsyncCallback = ( + error: unknown, + response: ProviderSendAsyncResponse, + ) => void; + + type Provider = { + sendAsync( + payload: SendAsyncPayload, + callback: ProviderSendAsyncCallback, + ): void; + }; + + type SendAsyncPayload = { + id: number; + jsonrpc: '2.0'; + method: string; + params: Params; + }; + + type SendAsyncCallback = ( + ...args: + | [error: EverythingButNull, result: undefined] + | [error: null, result: Result] + ) => void; + + export default class EthQuery { + constructor(provider: Provider); + + sendAsync( + opts: Partial>, + callback: SendAsyncCallback, + ): void; + } +} diff --git a/yarn.lock b/yarn.lock index 28686cc9d..0921937e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4152,13 +4152,13 @@ __metadata: languageName: node linkType: hard -"@metamask/obs-store@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/obs-store@npm:8.0.0" +"@metamask/obs-store@npm:^8.1.0": + version: 8.1.0 + resolution: "@metamask/obs-store@npm:8.1.0" dependencies: "@metamask/safe-event-emitter": ^2.0.0 through2: ^2.0.3 - checksum: 232362e65a3563f0bd3299cec48f5adb37e68d4f066b7de90f2b044480d3b16c2d918c12d672c825e1d9b55344ae818fb8494d91129e4613555097653b9bb887 + checksum: 92356067fa3517526d656f2f0bdfbc4d39f65e27fb30d84240cfc9c1aa9cd5d743498952df18ed8efbb8887b6cc1bc1fab37bde3fb0fc059539e0dfcc67ff86f languageName: node linkType: hard @@ -24308,7 +24308,7 @@ __metadata: "@metamask/message-manager": ^2.1.0 "@metamask/metamask-eth-abis": ^3.0.0 "@metamask/notification-controller": ^1.0.0 - "@metamask/obs-store": ^8.0.0 + "@metamask/obs-store": ^8.1.0 "@metamask/permission-controller": ^3.1.0 "@metamask/phishing-controller": ^2.0.0 "@metamask/phishing-warning": ^2.1.0