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