diff --git a/app/scripts/controllers/network/network-controller.js b/app/scripts/controllers/network/network-controller.js index 20be4b8b3..25e90378d 100644 --- a/app/scripts/controllers/network/network-controller.js +++ b/app/scripts/controllers/network/network-controller.js @@ -125,7 +125,9 @@ export default class NetworkController extends EventEmitter { } this._infuraProjectId = infuraProjectId; - this.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, this.lookupNetwork); + this.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { + this.lookupNetwork(); + }); } /** @@ -158,6 +160,8 @@ export default class NetworkController extends EventEmitter { */ 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]; } @@ -185,6 +189,9 @@ export default class NetworkController extends EventEmitter { return; } + // NOTE: This will never happen in practice because you can't pass null or + // undefined for chainId to setRpcTarget, and all of the known networks have + // a chain ID const chainId = this.getCurrentChainId(); if (!chainId) { log.warn( diff --git a/app/scripts/controllers/network/network-controller.test.js b/app/scripts/controllers/network/network-controller.test.js index 214bbfd80..b86298692 100644 --- a/app/scripts/controllers/network/network-controller.test.js +++ b/app/scripts/controllers/network/network-controller.test.js @@ -1,24 +1,50 @@ +import { inspect, isDeepStrictEqual, promisify } from 'util'; +import { isMatch } from 'lodash'; import nock from 'nock'; -import { deferredPromise } from '../../lib/util'; -import { NETWORK_TYPES } from '../../../../shared/constants/network'; -import NetworkController, { NETWORK_EVENTS } from './network-controller'; +import sinon from 'sinon'; +import * as ethJsonRpcMiddlewareModule from 'eth-json-rpc-middleware'; +import NetworkController from './network-controller'; + +jest.mock('eth-json-rpc-middleware', () => { + return { + __esModule: true, + ...jest.requireActual('eth-json-rpc-middleware'), + }; +}); + +// Store this up front so it doesn't get lost when it is stubbed +const originalSetTimeout = global.setTimeout; /** - * Construct a successful RPC response. + * @typedef {import('nock').Scope} NockScope * - * @param {any} result - The RPC result to return. - * @returns A successful RPC response with the specified result. + * A object returned by the `nock` function which holds all of the request mocks + * for a network. */ -function constructSuccessfulRpcResponse(result) { - return { - id: 1, - jsonrpc: '2.0', - result, - }; -} -// Example block taken from Ethereum Mainnet that has been updated to match the -// pre-EIP-1559 format (i.e. it doesn't have the `baseFeePerGas` property). +/** + * @typedef {{request: MockJsonResponseBody, response: { httpStatus?: number } & MockJsonResponseBody, error?: unknown, delay?: number; times?: number, beforeCompleting: () => void | Promise}} RpcMock + * + * Arguments to `mockRpcCall` which allow for specifying a canned response for a + * particular RPC request. + */ + +/** + * @typedef {{id?: number; jsonrpc?: string, method: string, params?: unknown[]}} MockJsonRpcRequestBody + * + * A partial form of a prototypical JSON-RPC request body. + */ + +/** + * @typedef {{id?: number; jsonrpc?: string; result?: string; error?: string}} MockJsonResponseBody + * + * A partial form of a prototypical JSON-RPC response body. + */ + +/** + * A dummy block that matches the pre-EIP-1559 format (i.e. it doesn't have the + * `baseFeePerGas` property). + */ const PRE_1559_BLOCK = { difficulty: '0x0', extraData: '0x', @@ -50,224 +76,4869 @@ const PRE_1559_BLOCK = { '0x98bbdfbe1074bc3aa72a77a281f16d6ba7e723d68f15937d80954fb34d323369', uncles: [], }; -// Example block taken from Ethereum Mainnet post-EIP-1559 -const BLOCK = { + +/** + * A dummy block that matches the pre-EIP-1559 format (i.e. it has the + * `baseFeePerGas` property). + */ +const POST_1559_BLOCK = { ...PRE_1559_BLOCK, baseFeePerGas: '0x63c498a46', }; -const defaultControllerOptions = { - infuraProjectId: 'foo', +/** + * An alias for `POST_1559_BLOCK`, for tests that don't care about which kind of + * block they're looking for. + */ +const BLOCK = POST_1559_BLOCK; + +/** + * A dummy value for the `projectId` option that `createInfuraClient` needs. + * (Infura should not be hit during tests, but just in case, this should not + * refer to a real project ID.) + */ +const DEFAULT_INFURA_PROJECT_ID = 'fake-infura-project-id'; + +/** + * Despite the signature of its constructor, NetworkController must take an + * Infura project ID. This object is mixed into the options first when a + * NetworkController is instantiated in tests. + */ +const DEFAULT_CONTROLLER_OPTIONS = { + infuraProjectId: DEFAULT_INFURA_PROJECT_ID, }; +/** + * The set of properties allowed in a valid JSON-RPC response object. + */ +const JSONRPC_RESPONSE_BODY_PROPERTIES = ['id', 'jsonrpc', 'result', 'error']; + +/** + * The set of networks that, when specified, create an Infura provider as + * opposed to a "standard" provider (one suited for a custom RPC endpoint). + */ +const INFURA_NETWORKS = [ + { + networkName: 'Mainnet', + networkType: 'mainnet', + chainId: '0x1', + networkVersion: '1', + ticker: 'ETH', + }, + { + networkName: 'Goerli', + networkType: 'goerli', + chainId: '0x5', + networkVersion: '5', + ticker: 'GoerliETH', + }, + { + networkName: 'Sepolia', + networkType: 'sepolia', + chainId: '0xaa36a7', + networkVersion: '11155111', + ticker: 'SepoliaETH', + }, +]; + +/** + * Handles mocking provider requests for a particular network. + */ +class NetworkCommunications { + #networkClientOptions; + + /** + * Builds an object for mocking provider requests. + * + * @param {object} args - The arguments. + * @param {"infura" | "custom"} args.networkClientType - Specifies the + * expected middleware stack that will represent the provider: "infura" for an + * Infura network; "custom" for a custom RPC endpoint. + * @param {object} args.networkClientOptions - Details about the network + * client used to determine the base URL or URL path to mock. + * @param {string} [args.networkClientOptions.infuraNetwork] - The name of the + * Infura network being tested, assuming that `networkClientType` is "infura". + * @param {string} [args.networkClientOptions.infuraProjectId] - The project + * ID of the Infura network being tested, assuming that `networkClientType` is + * "infura". + * @param {string} [args.networkClientOptions.customRpcUrl] - The URL of the + * custom RPC endpoint, assuming that `networkClientType` is "custom". + * @returns {NockScope} The nock scope. + */ + constructor({ + networkClientType, + networkClientOptions: { + infuraNetwork, + infuraProjectId = DEFAULT_INFURA_PROJECT_ID, + customRpcUrl, + } = {}, + }) { + const networkClientOptions = { + infuraNetwork, + infuraProjectId, + customRpcUrl, + }; + this.networkClientType = networkClientType; + this.#networkClientOptions = networkClientOptions; + this.infuraProjectId = infuraProjectId; + const rpcUrl = + networkClientType === 'infura' + ? `https://${infuraNetwork}.infura.io` + : customRpcUrl; + this.nockScope = nock(rpcUrl); + } + + /** + * Constructs a new NetworkCommunications object using a different set of + * options, using the options from this instance as a base. + * + * @param args - The same arguments that NetworkCommunications takes. + */ + with(args) { + return new NetworkCommunications({ + networkClientType: this.networkClientType, + networkClientOptions: this.#networkClientOptions, + ...args, + }); + } + + /** + * Mocks the RPC calls that NetworkController makes internally. + * + * @param {object} args - The arguments. + * @param {{number: string, baseFeePerGas?: string} | null} [args.latestBlock] - The + * block object that will be used to mock `eth_blockNumber` and + * `eth_getBlockByNumber`. If null, then both `eth_blockNumber` and + * `eth_getBlockByNumber` will respond with null. + * @param {RpcMock | Partial[] | null} [args.eth_blockNumber] - + * Options for mocking the `eth_blockNumber` RPC method (see `mockRpcCall` for + * valid properties). By default, the number from the `latestBlock` will be + * used as the result. Use `null` to prevent this method from being mocked. + * @param {RpcMock | Partial[] | null} [args.eth_getBlockByNumber] - + * Options for mocking the `eth_getBlockByNumber` RPC method (see + * `mockRpcCall` for valid properties). By default, the `latestBlock` will be + * used as the result. Use `null` to prevent this method from being mocked. + * @param {RpcMock | Partial[] | null} [args.net_version] - Options + * for mocking the `net_version` RPC method (see `mockRpcCall` for valid + * properties). By default, "1" will be used as the result. Use `null` to + * prevent this method from being mocked. + */ + mockEssentialRpcCalls({ + latestBlock = BLOCK, + eth_blockNumber: ethBlockNumberMocks = [], + eth_getBlockByNumber: ethGetBlockByNumberMocks = [], + net_version: netVersionMocks = [], + } = {}) { + const latestBlockNumber = latestBlock === null ? null : latestBlock.number; + if (latestBlock && latestBlock.number === undefined) { + throw new Error('The latest block must have a `number`.'); + } + + const defaultMocksByRpcMethod = { + eth_blockNumber: { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: latestBlockNumber, + }, + // When the provider is configured for an Infura network, + // NetworkController makes a sentinel request for `eth_blockNumber`, so + // we ensure that it is mocked by default. Conversely, when the provider + // is configured for a custom RPC endpoint, we don't mock + // `eth_blockNumber` at all unless specified. Admittedly, this is a bit + // magical, but it saves us from having to think about this in tests + // if we don't have to. + times: this.networkClientType === 'infura' ? 1 : 0, + }, + eth_getBlockByNumber: { + request: { + method: 'eth_getBlockByNumber', + params: [latestBlockNumber, false], + }, + response: { + result: latestBlock, + }, + }, + net_version: { + request: { + method: 'net_version', + params: [], + }, + response: { + result: '1', + }, + }, + }; + const providedMocksByRpcMethod = { + eth_blockNumber: ethBlockNumberMocks, + eth_getBlockByNumber: ethGetBlockByNumberMocks, + net_version: netVersionMocks, + }; + + const allMocks = []; + + Object.keys(defaultMocksByRpcMethod).forEach((rpcMethod) => { + const defaultMock = defaultMocksByRpcMethod[rpcMethod]; + const providedMockOrMocks = providedMocksByRpcMethod[rpcMethod]; + const providedMocks = Array.isArray(providedMockOrMocks) + ? providedMockOrMocks + : [providedMockOrMocks]; + if (providedMocks.length > 0) { + providedMocks.forEach((providedMock) => { + allMocks.push({ ...defaultMock, ...providedMock }); + }); + } else { + allMocks.push(defaultMock); + } + }); + + // The request that the block tracker makes always occurs after any request + // that the network controller makes (because such a request goes through + // the block cache middleware and that is what spawns the block tracker). We + // don't need to customize the block tracker request; we just need to ensure + // that the block number it returns matches the same block number that + // `eth_getBlockByNumber` uses. + allMocks.push({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: latestBlockNumber, + }, + times: latestBlock === null ? 2 : 1, + }); + + allMocks.forEach((mock) => { + this.mockRpcCall(mock); + }); + } + + /** + * Mocks a JSON-RPC request sent to the provider with the given response. + * + * @param {RpcMock} args - The arguments. + * @param {MockJsonRpcRequestBody} args.request - The request data. Must + * include a `method`. Note that EthQuery's `sendAsync` method implicitly uses + * an empty array for `params` if it is not provided in the original request, + * so make sure to include this. + * @param {MockJsonResponseBody & { httpStatus?: number }} [args.response] - Information + * concerning the response that the request should have. Takes one of two + * forms. The simplest form is an object that represents the response body; + * the second form allows you to specify the HTTP status, as well as a + * potentially async function to generate the response body. + * @param {unknown} [args.error] - An error to throw while + * making the request. Takes precedence over `response`. + * @param {number} [args.delay] - The amount of time that should + * pass before the request resolves with the response. + * @param {number} [args.times] - The number of times that the + * request is expected to be made. + * @param {() => void | Promise} [args.beforeCompleting] - Sometimes it is useful to do + * something after the request is kicked off but before it ends (or, in terms + * of a `fetch` promise, when the promise is initiated but before it is + * resolved). You can pass an (async) function for this option to do this. + * @returns {NockScope | null} The nock scope object that represents all of + * the mocks for the network, or null if `times` is 0. + */ + mockRpcCall({ request, response, error, delay, times, beforeCompleting }) { + if (times === 0) { + return null; + } + + const url = + this.networkClientType === 'infura' ? `/v3/${this.infuraProjectId}` : '/'; + + const httpStatus = response?.httpStatus ?? 200; + this.#validateMockResponseBody(response); + const partialResponseBody = { jsonrpc: '2.0' }; + JSONRPC_RESPONSE_BODY_PROPERTIES.forEach((prop) => { + if (response[prop] !== undefined) { + partialResponseBody[prop] = response[prop]; + } + }); + + let nockInterceptor = this.nockScope.post(url, (actualBody) => { + const expectedPartialBody = { jsonrpc: '2.0', ...request }; + return isMatch(actualBody, expectedPartialBody); + }); + + if (delay !== undefined) { + nockInterceptor = nockInterceptor.delay(delay); + } + + if (times !== undefined) { + nockInterceptor = nockInterceptor.times(times); + } + + if (error !== undefined) { + return nockInterceptor.replyWithError(error); + } + if (response !== undefined) { + return nockInterceptor.reply(async (_uri, requestBody) => { + if (beforeCompleting !== undefined) { + await beforeCompleting(); + } + + const completeResponseBody = { + jsonrpc: '2.0', + ...(requestBody.id === undefined ? {} : { id: requestBody.id }), + ...partialResponseBody, + }; + + return [httpStatus, completeResponseBody]; + }); + } + throw new Error( + 'Neither `response` nor `error` was given. Please specify one of them.', + ); + } + + #validateMockResponseBody(mockResponseBody) { + const invalidProperties = Object.keys(mockResponseBody).filter( + (key) => + key !== 'httpStatus' && !JSONRPC_RESPONSE_BODY_PROPERTIES.includes(key), + ); + if (invalidProperties.length > 0) { + throw new Error( + `Mock response object ${inspect( + mockResponseBody, + )} has invalid properties: ${inspect(invalidProperties)}.`, + ); + } + } +} + +describe('NetworkController', () => { + let clock; + + beforeEach(() => { + // Disable all requests, even those to localhost + nock.disableNetConnect(); + // Faking timers ends up doing two things: + // 1. Halting the block tracker (which depends on `setTimeout` to + // periodically request the latest block) set up in + // `eth-json-rpc-middleware` + // 2. Halting the retry logic in `@metamask/eth-json-rpc-infura` (which + // also depends on `setTimeout`) + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + nock.enableNetConnect('localhost'); + clock.restore(); + nock.cleanAll(); + }); + + describe('constructor', () => { + const invalidInfuraIds = [undefined, null, {}, 1]; + invalidInfuraIds.forEach((invalidId) => { + it(`throws if an invalid Infura ID of "${inspect( + invalidId, + )}" is provided`, () => { + expect(() => new NetworkController({ infuraId: invalidId })).toThrow( + 'Invalid Infura project ID', + ); + }); + }); + + it('accepts initial state', async () => { + const exampleInitialState = { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + networkDetails: { + EIPS: { + 1559: false, + }, + }, + }; + + await withController( + { + state: exampleInitialState, + }, + ({ controller }) => { + expect(controller.store.getState()).toMatchInlineSnapshot(` + { + "network": "loading", + "networkDetails": { + "EIPS": { + "1559": false, + }, + }, + "previousProviderStore": { + "chainId": "0x9999", + "nickname": "Test initial state", + "rpcUrl": "http://example-custom-rpc.metamask.io", + "type": "rpc", + }, + "provider": { + "chainId": "0x9999", + "nickname": "Test initial state", + "rpcUrl": "http://example-custom-rpc.metamask.io", + "type": "rpc", + }, + } + `); + }, + ); + }); + + it('sets default state without initial state', async () => { + await withController(({ controller }) => { + expect(controller.store.getState()).toMatchInlineSnapshot(` + { + "network": "loading", + "networkDetails": { + "EIPS": { + "1559": undefined, + }, + }, + "previousProviderStore": { + "chainId": "0x539", + "nickname": "Localhost 8545", + "rpcUrl": "http://localhost:8545", + "ticker": "ETH", + "type": "rpc", + }, + "provider": { + "chainId": "0x539", + "nickname": "Localhost 8545", + "rpcUrl": "http://localhost:8545", + "ticker": "ETH", + "type": "rpc", + }, + } + `); + }); + }); + }); + + describe('destroy', () => { + it('does not throw if called before the provider is initialized', async () => { + const controller = new NetworkController(DEFAULT_CONTROLLER_OPTIONS); + + expect(await controller.destroy()).toBeUndefined(); + }); + + it('stops the block tracker for the currently selected network as long as the provider has been initialized', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + times: 1, + }, + }); + await controller.initializeProvider(); + const { blockTracker } = controller.getProviderAndBlockTracker(); + // The block tracker starts running after a listener is attached + blockTracker.addListener('latest', () => { + // do nothing + }); + expect(blockTracker.isRunning()).toBe(true); + + await controller.destroy(); + + expect(blockTracker.isRunning()).toBe(false); + }); + }); + }); + + describe('initializeProvider', () => { + it('throws if the provider configuration is invalid', async () => { + const invalidProviderConfig = {}; + await withController( + { + state: { + provider: invalidProviderConfig, + }, + }, + async ({ controller }) => { + await expect(async () => { + await controller.initializeProvider(); + }).rejects.toThrow( + 'NetworkController - _configureProvider - unknown type "undefined"', + ); + }, + ); + }); + + for (const { + networkName, + networkType, + chainId, + networkVersion, + } of INFURA_NETWORKS) { + describe(`when the type in the provider configuration is "${networkType}"`, () => { + it(`initializes a provider pointed to the ${networkName} Infura network (chainId: ${chainId})`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + await controller.initializeProvider(); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe(chainId); + }, + ); + }); + + it('emits infuraIsUnblocked (assuming that the request to eth_blockNumber responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.initializeProvider(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + ); + }); + + it(`persists "${networkVersion}" to state as the network version of ${networkName}`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + await controller.initializeProvider(); + + expect(controller.store.getState().network).toBe(networkVersion); + }, + ); + }); + + it(`persists to state whether the network supports EIP-1559 (assuming that the request for eth_getBlockByNumber responds successfully)`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + networkDetails: { + EIPS: {}, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + await controller.initializeProvider(); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(true); + }, + ); + }); + }); + } + + describe(`when the type in the provider configuration is "rpc"`, () => { + it('initializes a provider pointed to the given RPC URL whose chain ID matches the configured chain ID', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + network.mockRpcCall({ + request: { + method: 'test', + params: [], + }, + response: { + result: 'test response', + }, + }); + + await controller.initializeProvider(); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: testResult } = await promisifiedSendAsync({ + id: 99999, + jsonrpc: '2.0', + method: 'test', + params: [], + }); + expect(testResult).toBe('test response'); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe('0x1337'); + }, + ); + }); + + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.initializeProvider(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + ); + }); + + it('persists the network version to state (assuming that the request for net_version responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + net_version: { + response: { + result: '42', + }, + }, + }); + + await controller.initializeProvider(); + + expect(controller.store.getState().network).toBe('42'); + }, + ); + }); + + it('persists to state whether the network supports EIP-1559 (assuming that the request for eth_getBlockByNumber responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + await controller.initializeProvider(); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(true); + }, + ); + }); + }); + }); + + describe('getProviderAndBlockTracker', () => { + it('returns objects that proxy to the provider and block tracker as long as the provider has been initialized', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + + const { provider, blockTracker } = + controller.getProviderAndBlockTracker(); + + expect(provider).toHaveProperty('sendAsync'); + expect(blockTracker).toHaveProperty('checkForLatestBlock'); + }); + }); + + it("returns null for both the provider and block tracker if the provider hasn't been initialized yet", async () => { + await withController(async ({ controller }) => { + const { provider, blockTracker } = + controller.getProviderAndBlockTracker(); + + expect(provider).toBeNull(); + expect(blockTracker).toBeNull(); + }); + }); + + for (const { networkName, networkType, chainId } of INFURA_NETWORKS) { + describe(`when the type in the provider configuration is changed to "${networkType}"`, () => { + it(`returns a provider object that was pointed to another network before the switch and is pointed to ${networkName} afterward`, async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + const { provider } = controller.getProviderAndBlockTracker(); + + const promisifiedSendAsync1 = promisify(provider.sendAsync).bind( + provider, + ); + const { result: oldChainIdResult } = await promisifiedSendAsync1({ + method: 'eth_chainId', + }); + expect(oldChainIdResult).toBe('0x1337'); + + controller.setProviderType(networkType); + const promisifiedSendAsync2 = promisify(provider.sendAsync).bind( + provider, + ); + const { result: newChainIdResult } = await promisifiedSendAsync2({ + method: 'eth_chainId', + }); + expect(newChainIdResult).toBe(chainId); + }, + ); + }); + }); + } + + describe('when the type in the provider configuration is changed to "rpc"', () => { + it('returns a provider object that was pointed to another network before the switch and is pointed to the new network', async () => { + await withController( + { + state: { + provider: { + type: 'goerli', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + const { provider } = controller.getProviderAndBlockTracker(); + + const promisifiedSendAsync1 = promisify(provider.sendAsync).bind( + provider, + ); + const { result: oldChainIdResult } = await promisifiedSendAsync1({ + method: 'eth_chainId', + }); + expect(oldChainIdResult).toBe('0x5'); + + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + const promisifiedSendAsync2 = promisify(provider.sendAsync).bind( + provider, + ); + const { result: newChainIdResult } = await promisifiedSendAsync2({ + method: 'eth_chainId', + }); + expect(newChainIdResult).toBe('0x1337'); + }, + ); + }); + }); + }); + + describe('getEIP1559Compatibility', () => { + describe('when the latest block has a baseFeePerGas property', () => { + it('persists to state that the network supports EIP-1559', async () => { + await withController( + { + state: { + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + await controller.initializeProvider(); + + await controller.getEIP1559Compatibility(); + + expect(controller.store.getState().networkDetails.EIPS[1559]).toBe( + true, + ); + }, + ); + }); + + it('returns true', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + await controller.initializeProvider(); + + const supportsEIP1559 = await controller.getEIP1559Compatibility(); + + expect(supportsEIP1559).toBe(true); + }); + }); + }); + + describe('when the latest block does not have a baseFeePerGas property', () => { + it('persists to state that the network does not support EIP-1559', async () => { + await withController( + { + state: { + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + await controller.initializeProvider(); + + await controller.getEIP1559Compatibility(); + + expect(controller.store.getState().networkDetails.EIPS[1559]).toBe( + false, + ); + }, + ); + }); + + it('returns false', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + await controller.initializeProvider(); + + const supportsEIP1559 = await controller.getEIP1559Compatibility(); + + expect(supportsEIP1559).toBe(false); + }); + }); + }); + + describe('when the request for the latest block responds with null', () => { + it('persists null to state as whether the network supports EIP-1559', async () => { + await withController( + { + state: { + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: null, + }); + await controller.initializeProvider(); + + await controller.getEIP1559Compatibility(); + + expect(controller.store.getState().networkDetails.EIPS[1559]).toBe( + null, + ); + }, + ); + }); + + it('returns null', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: null, + }); + await controller.initializeProvider(); + + const supportsEIP1559 = await controller.getEIP1559Compatibility(); + + expect(supportsEIP1559).toBe(null); + }); + }); + }); + + it('does not make multiple requests to eth_getBlockByNumber when called multiple times and the request to eth_getBlockByNumber succeeded the first time', async () => { + await withController(async ({ controller, network }) => { + // This mocks eth_getBlockByNumber once by default + network.mockEssentialRpcCalls(); + await withoutCallingGetEIP1559Compatibility({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await controller.getEIP1559Compatibility(); + await controller.getEIP1559Compatibility(); + + expect(network.nockScope.isDone()).toBe(true); + }); + }); + }); + + describe('lookupNetwork', () => { + describe('if the provider has not been initialized', () => { + it('does not update state in any way', async () => { + const providerConfig = { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }; + const initialState = { + provider: providerConfig, + networkDetails: { + EIPS: { + 1559: true, + }, + }, + }; + + await withController( + { + state: initialState, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + const stateAfterConstruction = controller.store.getState(); + + await controller.lookupNetwork(); + + expect(controller.store.getState()).toStrictEqual( + stateAfterConstruction, + ); + }, + ); + }); + + it('does not emit infuraIsUnblocked', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const promiseForInfuraIsUnblocked = waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForInfuraIsUnblocked).toNeverResolve(); + }); + }); + + it('does not emit infuraIsBlocked', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const promiseForInfuraIsBlocked = waitForEvent({ + controller, + eventName: 'infuraIsBlocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForInfuraIsBlocked).toNeverResolve(); + }); + }); + }); + + for (const { + networkName, + networkType, + networkVersion, + } of INFURA_NETWORKS) { + describe(`when the type in the provider configuration is "${networkType}"`, () => { + describe('if the request for eth_blockNumber responds successfully', () => { + it('emits infuraIsUnblocked as long as the network has not changed by the time the request ends', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + response: { + result: '0x42', + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + ); + }); + + it('does not emit infuraIsUnblocked if the network has changed by the time the request ends', async () => { + const anotherNetwork = INFURA_NETWORKS.find( + (network) => network.networkType !== networkType, + ); + /* eslint-disable-next-line jest/no-if */ + if (!anotherNetwork) { + throw new Error( + "Could not find another network to use. You've probably commented out all INFURA_NETWORKS but one. Please uncomment another one.", + ); + } + + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + eth_blockNumber: { + response: { + result: '0x42', + }, + beforeCompleting: async () => { + await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: async () => { + await withoutCallingLookupNetwork({ + controller, + operation: () => { + controller.setProviderType( + anotherNetwork.networkType, + ); + }, + }); + }, + }); + }, + }, + }); + const network2 = network1.with({ + networkClientOptions: { + infuraNetwork: anotherNetwork.networkType, + }, + }); + network2.mockEssentialRpcCalls(); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + const promiseForInfuraIsUnblocked = waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForInfuraIsUnblocked).toNeverResolve(); + }, + ); + }); + }); + + describe('if the request for eth_blockNumber responds with a "countryBlocked" error', () => { + it('emits infuraIsBlocked as long as the network has not changed by the time the request ends', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + response: { + httpStatus: 500, + error: 'countryBlocked', + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + const infuraIsBlocked = await waitForEvent({ + controller, + eventName: 'infuraIsBlocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(infuraIsBlocked).toBe(true); + }, + ); + }); + + it('does not emit infuraIsBlocked if the network has changed by the time the request ends', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + eth_blockNumber: { + response: { + httpStatus: 500, + error: 'countryBlocked', + }, + beforeCompleting: async () => { + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.setRpcTarget( + 'http://some-rpc-url', + '0x1337', + ); + }, + }); + }, + }); + }, + }, + }); + const network2 = new NetworkCommunications({ + networkClientType: 'rpc', + networkClientOptions: { + customRpcUrl: 'http://some-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + const promiseForInfuraIsBlocked = waitForEvent({ + controller, + eventName: 'infuraIsBlocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForInfuraIsBlocked).toNeverResolve(); + }, + ); + }); + }); + + describe('if the request for eth_blockNumber responds with a generic error', () => { + it('does not emit infuraIsUnblocked', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + response: { + httpStatus: 500, + error: 'oops', + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + const promiseForInfuraIsUnblocked = waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForInfuraIsUnblocked).toNeverResolve(); + }, + ); + }); + }); + + it(`persists "${networkVersion}" to state as the network version of ${networkName}`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(controller.store.getState().network).toBe(networkVersion); + }, + ); + }); + + it(`does not update the network state if it is already set to "${networkVersion}"`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + times: 2, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + const promiseForStateChanges = waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForStateChanges).toNeverResolve(); + }, + ); + }); + + describe('if the request for eth_getBlockByNumber responds successfully', () => { + it('persists to state that the network supports EIP-1559 when baseFeePerGas is in the block header', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + await controller.initializeProvider(); + + await controller.getEIP1559Compatibility(); + + expect( + controller.store.getState().networkDetails.EIPS[1559], + ).toBe(true); + }, + ); + }); + + it('persists to state that the network does not support EIP-1559 when baseFeePerGas is not in the block header', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + await controller.initializeProvider(); + + await controller.getEIP1559Compatibility(); + + expect( + controller.store.getState().networkDetails.EIPS[1559], + ).toBe(false); + }, + ); + }); + }); + + describe('if the request for eth_getBlockByNumber responds with an error', () => { + it('does not update the network details in any way', async () => { + const intentionalErrorMessage = + 'intentional error from eth_getBlockByNumber'; + + await withController( + { + state: { + provider: { + type: networkType, + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_getBlockByNumber: { + response: { + error: intentionalErrorMessage, + }, + }, + }); + await withoutCallingGetEIP1559Compatibility({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + count: 0, + operation: async () => { + try { + await controller.getEIP1559Compatibility(); + } catch (error) { + if (error !== intentionalErrorMessage) { + console.error(error); + } + } + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + }, + ); + }); + }); + + describe('if the network was switched after the net_version request started but before it completed', () => { + it(`persists to state the network version of the newly switched network, not the initial one for ${networkName}`, async () => { + const oldNetworkVersion = networkVersion; + const newNetworkName = 'goerli'; + const newNetworkVersion = '5'; + + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller }) => { + let netVersionCallCount = 0; + + const fakeProviders = [ + { + sendAsync(request, callback) { + if (request.method === 'net_version') { + netVersionCallCount += 1; + if (netVersionCallCount === 1) { + waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.setProviderType(newNetworkName); + }, + }) + .then(() => { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: oldNetworkVersion, + }); + }) + .catch((error) => { + throw error; + }); + return; + } + + throw new Error( + "net_version shouldn't be called more than once", + ); + } + + if (request.method === 'eth_getBlockByNumber') { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: BLOCK, + }); + return; + } + + throw new Error( + `Mock not found for method ${request.method}`, + ); + }, + }, + { + sendAsync(request, callback) { + if (request.method === 'net_version') { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: newNetworkVersion, + }); + return; + } + + if (request.method === 'eth_getBlockByNumber') { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: BLOCK, + }); + return; + } + + throw new Error( + `Mock not found for method ${request.method}`, + ); + }, + }, + ]; + jest + .spyOn(ethJsonRpcMiddlewareModule, 'providerFromEngine') + .mockImplementationOnce(() => fakeProviders[0]) + .mockImplementationOnce(() => fakeProviders[1]); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(controller.store.getState().network).toBe( + newNetworkVersion, + ); + }, + ); + }); + + it(`persists to state the EIP-1559 support for the newly switched network, not the initial one for ${networkName}`, async () => { + const oldNetworkVersion = networkVersion; + const newNetworkName = 'goerli'; + const newNetworkVersion = '5'; + + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller }) => { + let netVersionCallCount = 0; + + const fakeProviders = [ + { + sendAsync(request, callback) { + if (request.method === 'net_version') { + netVersionCallCount += 1; + if (netVersionCallCount === 1) { + waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.setProviderType(newNetworkName); + }, + }) + .then(() => { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: oldNetworkVersion, + }); + }) + .catch((error) => { + throw error; + }); + return; + } + + throw new Error( + "net_version shouldn't be called more than once", + ); + } + + if (request.method === 'eth_getBlockByNumber') { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: POST_1559_BLOCK, + }); + return; + } + + throw new Error( + `Mock not found for method ${request.method}`, + ); + }, + }, + { + sendAsync(request, callback) { + if (request.method === 'net_version') { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: newNetworkVersion, + }); + return; + } + + if (request.method === 'eth_getBlockByNumber') { + callback(null, { + id: request.id, + jsonrpc: '2.0', + result: PRE_1559_BLOCK, + }); + return; + } + + throw new Error( + `Mock not found for method ${request.method}`, + ); + }, + }, + ]; + jest + .spyOn(ethJsonRpcMiddlewareModule, 'providerFromEngine') + .mockImplementationOnce(() => fakeProviders[0]) + .mockImplementationOnce(() => fakeProviders[1]); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(false); + }, + ); + }); + }); + }); + } + + describe(`when the type in the provider configuration is "rpc"`, () => { + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + ); + }); + + describe('if the request for net_version responds successfully', () => { + it('persists the network version to state', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + net_version: { + response: { + result: '42', + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(controller.store.getState().network).toBe('42'); + }, + ); + }); + + it('does not persist the result of net_version if it matches what is already in state', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + net_version: { + times: 2, + }, + eth_blockNumber: { + times: 2, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + const promiseForStateChanges = waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + await expect(promiseForStateChanges).toNeverResolve(); + }, + ); + }); + + describe('if the request for eth_getBlockByNumber responds successfully', () => { + it('persists to state that the network supports EIP-1559 when baseFeePerGas is in the block header', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await controller.lookupNetwork(); + + expect( + controller.store.getState().networkDetails.EIPS[1559], + ).toBe(true); + }, + ); + }); + + it('persists to state that the network does not support EIP-1559 when baseFeePerGas is not in the block header', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await controller.lookupNetwork(); + + expect( + controller.store.getState().networkDetails.EIPS[1559], + ).toBe(false); + }, + ); + }); + }); + + describe('if the request for eth_getBlockByNumber responds with an error', () => { + it('does not update the network details in any way', async () => { + const intentionalErrorMessage = + 'intentional error from eth_getBlockByNumber'; + + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_getBlockByNumber: { + response: { + error: intentionalErrorMessage, + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + count: 0, + operation: async () => { + try { + await controller.lookupNetwork(); + } catch (error) { + if ( + !('data' in error) || + error.data !== intentionalErrorMessage + ) { + console.error(error); + } + } + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + }, + ); + }); + }); + }); + + describe('if the request for net_version responds with an error', () => { + it('resets the network status to "loading"', async () => { + const intentionalErrorMessage = 'intentional error from net_version'; + + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + net_version: [ + { + response: { + result: '42', + }, + }, + { + response: { + error: intentionalErrorMessage, + }, + }, + ], + }); + await controller.initializeProvider(); + expect(controller.store.getState().network).toBe('42'); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + try { + await controller.lookupNetwork(); + } catch (error) { + if (error !== intentionalErrorMessage) { + console.error(error); + } + } + }, + }); + + expect(controller.store.getState().network).toBe('loading'); + }, + ); + }); + + it('removes from state whether the network supports EIP-1559', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + net_version: [ + { + response: { + result: '42', + }, + }, + { + response: { + error: 'oops', + }, + }, + ], + }); + await controller.initializeProvider(); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + ); + }); + }); + + describe('if the network was switched after the net_version request started but before it completed', () => { + it('persists to state the network version of the newly switched network, not the initial network', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url-1', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + net_version: { + response: { + result: '111', + }, + beforeCompleting: async () => { + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.setRpcTarget( + 'https://mock-rpc-url-2', + '0x9999', + ); + }, + }); + }, + }, + }); + const network2 = network1.with({ + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url-2', + }, + }); + network2.mockEssentialRpcCalls({ + net_version: { + response: { + result: '222', + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect(controller.store.getState().network).toBe('222'); + }, + ); + }); + + it('persists to state the EIP-1559 support for the newly switched network, not the initial one', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url-1', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + net_version: { + response: { + result: '111', + }, + beforeCompleting: async () => { + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.setRpcTarget( + 'https://mock-rpc-url-2', + '0x9999', + ); + }, + }); + }, + }, + eth_getBlockByNumber: { + response: { + result: POST_1559_BLOCK, + }, + }, + }); + const network2 = network1.with({ + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url-2', + }, + }); + network2.mockEssentialRpcCalls({ + net_version: { + response: { + result: '222', + }, + }, + eth_getBlockByNumber: { + response: { + result: PRE_1559_BLOCK, + }, + }, + }); + await withoutCallingLookupNetwork({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + operation: async () => { + await controller.lookupNetwork(); + }, + }); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(false); + }, + ); + }); + }); + }); + }); + + describe('setRpcTarget', () => { + it('throws if the given chain ID is not a 0x-prefixed hex number', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + expect(() => + controller.setRpcTarget('http://some.url', 'not a chain id'), + ).toThrow( + new Error('Invalid chain ID "not a chain id": invalid hex string.'), + ); + }); + }); + + it('throws if the given chain ID is greater than the maximum allowed ID', async () => { + await withController(async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + expect(() => + controller.setRpcTarget('http://some.url', '0xFFFFFFFFFFFFFFFF'), + ).toThrow( + new Error( + 'Invalid chain ID "0xFFFFFFFFFFFFFFFF": numerical value greater than max safe value.', + ), + ); + }); + }); + + it('captures the current provider configuration before overwriting it', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + + expect( + controller.store.getState().previousProviderStore, + ).toStrictEqual({ + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }); + }, + ); + }); + + it('overwrites the provider configuration given a minimal set of arguments, filling in ticker, nickname, and rpcPrefs with default values', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + + expect(controller.store.getState().provider).toStrictEqual({ + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + ticker: 'ETH', + nickname: '', + rpcPrefs: undefined, + }); + }, + ); + }); + + it('overwrites the provider configuration completely given a maximal set of arguments', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + + controller.setRpcTarget( + 'https://mock-rpc-url', + '0x1337', + 'DAI', + 'Cool network', + 'RPC prefs', + ); + + expect(controller.store.getState().provider).toStrictEqual({ + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + ticker: 'DAI', + nickname: 'Cool network', + rpcPrefs: 'RPC prefs', + }); + }, + ); + }); + + it('emits networkWillChange before making any changes to the network store', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + net_version: { + response: { + result: '42', + }, + }, + }); + const network2 = network1.with({ + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls({ + net_version: { + response: { + result: '99', + }, + }, + }); + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + await controller.initializeProvider(); + }, + }); + const initialNetwork = controller.store.getState().network; + expect(initialNetwork).toBe('42'); + + const networkWillChange = await waitForEvent({ + controller, + eventName: 'networkWillChange', + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + beforeResolving: () => { + expect(controller.store.getState().network).toBe(initialNetwork); + }, + }); + expect(networkWillChange).toBe(true); + }, + ); + }); + + it('resets the network state to "loading" before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://mock-rpc-url-1', + chainId: '0xFF', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + net_version: { + response: { + result: '255', + }, + }, + }); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url-2', + }, + }); + network2.mockEssentialRpcCalls({ + net_version: { + response: { + result: "this got used when it shouldn't", + }, + }, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().network).toBe('255'); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + // We only care about the first state change, because it happens + // before networkDidChange + count: 1, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url-2', '0xCC'); + }, + }); + expect(controller.store.getState().network).toBe('loading'); + }, + ); + }); + + it('resets EIP support for the network before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://mock-rpc-url-1', + chainId: '0xFF', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url-2', + }, + }); + network2.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + // We only care about the first state change, because it happens + // before networkDidChange + count: 1, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url-2', '0xCC'); + }, + }); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + ); + }); + + it('initializes a provider pointed to the given RPC URL whose chain ID matches the configured chain ID', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + network.mockRpcCall({ + request: { + method: 'test', + params: [], + }, + response: { + result: 'test response', + }, + }); + + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: testResult } = await promisifiedSendAsync({ + id: 99999, + jsonrpc: '2.0', + method: 'test', + params: [], + }); + expect(testResult).toBe('test response'); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe('0x1337'); + }); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + + expect(providerBefore).toBe(providerAfter); + }); + }); + + it('emits networkDidChange', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + + const networkDidChange = await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + expect(networkDidChange).toBe(true); + }); + }); + + it('emits infuraIsUnblocked', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls(); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }); + }); + + it('persists the network version to state (assuming that the request for net_version responds successfully)', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls({ + net_version: { + response: { + result: '42', + }, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + expect(controller.store.getState().network).toBe('42'); + }); + }); + + it('persists to state whether the network supports EIP-1559 (assuming that the request for eth_getBlockByNumber responds successfully)', async () => { + await withController( + { + state: { + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + count: 2, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + expect(controller.store.getState().networkDetails.EIPS['1559']).toBe( + true, + ); + }, + ); + }); + }); + + describe('setProviderType', () => { + for (const { + networkName, + networkType, + chainId, + networkVersion, + ticker, + } of INFURA_NETWORKS) { + describe(`given a type of "${networkType}"`, () => { + it('captures the current provider configuration before overwriting it', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + controller.setProviderType(networkType); + + expect( + controller.store.getState().previousProviderStore, + ).toStrictEqual({ + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }); + }, + ); + }); + + it(`overwrites the provider configuration using type: "${networkType}", chainId: "${chainId}", and ticker "${ticker}", clearing rpcUrl and nickname, and removing rpcPrefs`, async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://example-custom-rpc.metamask.io', + chainId: '0x9999', + nickname: 'Test initial state', + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + controller.setProviderType(networkType); + + expect(controller.store.getState().provider).toStrictEqual({ + type: networkType, + rpcUrl: '', + chainId, + ticker, + nickname: '', + }); + }, + ); + }); + + it('emits networkWillChange', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + const networkWillChange = await waitForEvent({ + controller, + eventName: 'networkWillChange', + operation: () => { + controller.setProviderType(networkType); + }, + }); + + expect(networkWillChange).toBe(true); + }); + }); + + it('resets the network state to "loading" before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://mock-rpc-url', + chainId: '0xFF', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + net_version: { + response: { + result: '255', + }, + }, + }); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network2.mockEssentialRpcCalls(); + + await controller.initializeProvider(); + expect(controller.store.getState().network).toBe('255'); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.setProviderType(networkType); + }, + }); + expect(controller.store.getState().network).toBe('loading'); + }, + ); + }); + + it('resets EIP support for the network before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'http://mock-rpc-url-1', + chainId: '0xFF', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network2.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.setProviderType(networkType); + }, + }); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + ); + }); + + it(`initializes a provider pointed to the ${networkName} Infura network (chainId: ${chainId})`, async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + controller.setProviderType(networkType); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe(chainId); + }); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + controller.setProviderType(networkType); + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + + expect(providerBefore).toBe(providerAfter); + }); + }); + + it('emits networkDidChange', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + const networkDidChange = await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.setProviderType(networkType); + }, + }); + + expect(networkDidChange).toBe(true); + }); + }); + + it('emits infuraIsUnblocked (assuming that the request for eth_blockNumber responds successfully)', async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: () => { + controller.setProviderType(networkType); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }); + }); + + it(`persists "${networkVersion}" to state as the network version of ${networkName}`, async () => { + await withController(async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls(); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.setProviderType(networkType); + }, + }); + + expect(controller.store.getState().network).toBe(networkVersion); + }); + }); + + it('persists to state whether the network supports EIP-1559 (assuming that the request for eth_getBlockByNumber responds successfully)', async () => { + await withController( + { + state: { + networkDetails: { + EIPS: {}, + }, + }, + }, + async ({ controller }) => { + const network = new NetworkCommunications({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: networkType, + }, + }); + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + count: 2, + operation: () => { + controller.setProviderType(networkType); + }, + }); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(true); + }, + ); + }); + }); + } + + describe('given a type of "rpc"', () => { + it('throws', async () => { + await withController(async ({ controller }) => { + expect(() => controller.setProviderType('rpc')).toThrow( + new Error( + 'NetworkController - cannot call "setProviderType" with type "rpc". Use "setRpcTarget"', + ), + ); + }); + }); + }); + + describe('given an invalid Infura network name', () => { + it('throws', async () => { + await withController(async ({ controller }) => { + expect(() => controller.setProviderType('sadlflaksdj')).toThrow( + new Error('Unknown Infura provider type "sadlflaksdj".'), + ); + }); + }); + }); + }); + + describe('resetConnection', () => { + for (const { + networkName, + networkType, + chainId, + networkVersion, + } of INFURA_NETWORKS) { + describe(`when the type in the provider configuration is "${networkType}"`, () => { + it('emits networkWillChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const networkWillChange = await waitForEvent({ + controller, + eventName: 'networkWillChange', + operation: () => { + controller.resetConnection(); + }, + }); + + expect(networkWillChange).toBe(true); + }, + ); + }); + + it('resets the network state to "loading" before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + times: 2, + }, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().network).toBe(networkVersion); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.resetConnection(); + }, + }); + expect(controller.store.getState().network).toBe('loading'); + }, + ); + }); + + it('resets EIP support for the network before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + eth_blockNumber: { + times: 2, + }, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.resetConnection(); + }, + }); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + ); + }); + + it(`initializes a new provider object pointed to the current Infura network (name: ${networkName}, chain ID: ${chainId})`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + controller.resetConnection(); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe(chainId); + }, + ); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + controller.resetConnection(); + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + + expect(providerBefore).toBe(providerAfter); + }, + ); + }); + + it('emits networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const networkDidChange = await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.resetConnection(); + }, + }); + + expect(networkDidChange).toBe(true); + }, + ); + }); + + it('emits infuraIsUnblocked (assuming that the request for eth_blockNumber responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: () => { + controller.resetConnection(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + ); + }); + + it(`ensures that the network version in state is set to "${networkVersion}"`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.resetConnection(); + }, + }); + + expect(controller.store.getState().network).toBe(networkVersion); + }, + ); + }); + + it('does not ensure that EIP-1559 support for the current network is up to date', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + + controller.resetConnection(); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + }, + ); + }); + }); + } + + describe(`when the type in the provider configuration is "rpc"`, () => { + it('emits networkWillChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const networkWillChange = await waitForEvent({ + controller, + eventName: 'networkWillChange', + operation: () => { + controller.resetConnection(); + }, + }); + + expect(networkWillChange).toBe(true); + }, + ); + }); + + it('resets the network state to "loading" before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0xFF', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + eth_blockNumber: { + times: 2, + }, + net_version: { + response: { + result: '255', + }, + }, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().network).toBe('255'); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + // We only care about the first state change, because it happens + // before networkDidChange + count: 1, + operation: () => { + controller.resetConnection(); + }, + }); + expect(controller.store.getState().network).toBe('loading'); + }, + ); + }); + + it('resets EIP support for the network before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0xFF', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + eth_blockNumber: { + times: 2, + }, + }); + + await controller.initializeProvider(); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + // We only care about the first state change, because it happens + // before networkDidChange + count: 1, + operation: () => { + controller.resetConnection(); + }, + }); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + ); + }); + + it('initializes a new provider object pointed to the same RPC URL as the current network and using the same chain ID', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + controller.resetConnection(); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe('0x1337'); + }, + ); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + await controller.initializeProvider(); + + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.resetConnection(); + }, + }); + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + + expect(providerBefore).toBe(providerAfter); + }, + ); + }); + + it('emits networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const networkDidChange = await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.resetConnection(); + }, + }); + + expect(networkDidChange).toBe(true); + }, + ); + }); + + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls(); + + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: () => { + controller.resetConnection(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + ); + }); + + it('ensures that the network version in state is up to date', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + net_version: { + response: { + result: '42', + }, + }, + }); + + await waitForStateChanges({ + controller, + propertyPath: ['network'], + operation: () => { + controller.resetConnection(); + }, + }); + + expect(controller.store.getState().network).toBe('42'); + }, + ); + }); + + it('does not ensure that EIP-1559 support for the current network is up to date', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network }) => { + network.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + + controller.resetConnection(); + + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBeUndefined(); + }, + ); + }); + }); + }); + + describe('rollbackToPreviousProvider', () => { + for (const { + networkName, + networkType, + chainId, + networkVersion, + } of INFURA_NETWORKS) { + describe(`if the previous provider configuration had a type of "${networkType}"`, () => { + it('merges the previous configuration into the current provider configuration', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + expect(controller.store.getState().provider).toStrictEqual({ + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + ticker: 'ETH', + nickname: '', + rpcPrefs: undefined, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(controller.store.getState().provider).toStrictEqual({ + type: networkType, + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + ticker: 'ETH', + nickname: '', + rpcPrefs: undefined, + }); + }, + ); + }); + + it('emits networkWillChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + const networkWillChange = await waitForEvent({ + controller, + eventName: 'networkWillChange', + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + + expect(networkWillChange).toBe(true); + }, + }); + }, + ); + }); + + it('resets the network state to "loading" before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls({ + net_version: { + response: { + result: '255', + }, + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + expect(controller.store.getState().network).toBe('255'); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + await waitForStateChanges({ + controller, + propertyPath: ['network'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(controller.store.getState().network).toBe('loading'); + }, + }); + }, + ); + }); + + it('resets EIP support for the network before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect( + controller.store.getState().networkDetails, + ).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + }); + }, + ); + }); + + it(`initializes a provider pointed to the ${networkName} Infura network (chainId: ${chainId})`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe(chainId); + }, + ); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + + expect(providerBefore).toBe(providerAfter); + }, + ); + }); + + it('emits networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + const networkDidChange = await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(networkDidChange).toBe(true); + }, + }); + }, + ); + }); + + it('emits infuraIsUnblocked (assuming that the request for eth_blockNumber responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + }); + }, + ); + }); + + it(`persists "${networkVersion}" to state as the network version of ${networkName}`, async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls({ + net_version: { + response: { + result: '255', + }, + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + expect(controller.store.getState().network).toBe('255'); + + await waitForLookupNetworkToComplete({ + controller, + numberOfNetworkDetailsChanges: 2, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(controller.store.getState().network).toBe(networkVersion); + }, + ); + }); + + it('persists to state whether the network supports EIP-1559 (assuming that the request for eth_getBlockByNumber responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: networkType, + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + const network2 = network1.with({ + networkClientType: 'custom', + networkClientOptions: { + customRpcUrl: 'https://mock-rpc-url', + }, + }); + network2.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + net_version: { + response: { + result: '99999', + }, + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setRpcTarget('https://mock-rpc-url', '0x1337'); + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(false); + + await waitForLookupNetworkToComplete({ + controller, + numberOfNetworkDetailsChanges: 2, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(true); + }, + ); + }); + }); + } + + describe(`if the previous provider configuration had a type of "rpc"`, () => { + it('merges the previous configuration into the current provider configuration', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + expect(controller.store.getState().provider).toStrictEqual({ + type: 'goerli', + rpcUrl: '', + chainId: '0x5', + ticker: 'GoerliETH', + nickname: '', + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(controller.store.getState().provider).toStrictEqual({ + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + ticker: 'GoerliETH', + nickname: '', + }); + }, + ); + }); + + it('emits networkWillChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + const networkWillChange = await waitForEvent({ + controller, + eventName: 'networkWillChange', + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + + expect(networkWillChange).toBe(true); + }, + }); + }, + ); + }); + + it('resets the network state to "loading" before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + expect(controller.store.getState().network).toBe('5'); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + await waitForStateChanges({ + controller, + propertyPath: ['network'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(controller.store.getState().network).toBe('loading'); + }, + }); + }, + ); + }); + + it('resets EIP support for the network before emitting networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + expect(controller.store.getState().networkDetails).toStrictEqual({ + EIPS: { + 1559: true, + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect( + controller.store.getState().networkDetails, + ).toStrictEqual({ + EIPS: { + 1559: undefined, + }, + }); + }, + }); + }, + ); + }); + + it('initializes a provider pointed to the given RPC URL whose chain ID matches the previously configured chain ID', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + + const { provider } = controller.getProviderAndBlockTracker(); + const promisifiedSendAsync = promisify(provider.sendAsync).bind( + provider, + ); + const { result: chainIdResult } = await promisifiedSendAsync({ + method: 'eth_chainId', + }); + expect(chainIdResult).toBe('0x1337'); + }, + ); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + + expect(providerBefore).toBe(providerAfter); + }, + ); + }); + + it('emits networkDidChange', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + const networkDidChange = await waitForEvent({ + controller, + eventName: 'networkDidChange', + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(networkDidChange).toBe(true); + }, + }); + }, + ); + }); + + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls(); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: async () => { + const infuraIsUnblocked = await waitForEvent({ + controller, + eventName: 'infuraIsUnblocked', + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + + expect(infuraIsUnblocked).toBe(true); + }, + }); + }, + ); + }); + + it('persists the network version to state (assuming that the request for net_version responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + net_version: { + response: { + result: '42', + }, + }, + }); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls(); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + expect(controller.store.getState().network).toBe('5'); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect(controller.store.getState().network).toBe('42'); + }, + ); + }); + + it('persists to state whether the network supports EIP-1559 (assuming that the request for eth_getBlockByNumber responds successfully)', async () => { + await withController( + { + state: { + provider: { + type: 'rpc', + rpcUrl: 'https://mock-rpc-url', + chainId: '0x1337', + }, + }, + }, + async ({ controller, network: network1 }) => { + network1.mockEssentialRpcCalls({ + latestBlock: POST_1559_BLOCK, + net_version: { + response: { + result: '99999', + }, + }, + }); + const network2 = network1.with({ + networkClientType: 'infura', + networkClientOptions: { + infuraNetwork: 'goerli', + }, + }); + network2.mockEssentialRpcCalls({ + latestBlock: PRE_1559_BLOCK, + }); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.setProviderType('goerli'); + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(false); + + await waitForLookupNetworkToComplete({ + controller, + operation: () => { + controller.rollbackToPreviousProvider(); + }, + }); + expect( + controller.store.getState().networkDetails.EIPS['1559'], + ).toBe(true); + }, + ); + }); + }); + }); +}); + /** * Builds a controller based on the given options, and calls the given function * with that controller. * - * @param args - Either a function, or constructor options + a function. The - * function will be called with the built controller. + * @param args - Either a function, or an options bag + a function. The options + * bag is the same that NetworkController takes; the function will be called + * with the built controller as well as an object that can be used to mock + * requests. * @returns Whatever the callback returns. */ async function withController(...args) { - const [constructorArgs, fn] = args.length === 2 ? args : [{}, args[0]]; - const controller = new NetworkController({ - ...defaultControllerOptions, - ...constructorArgs, + const [givenConstructorOptions, fn] = + args.length === 2 ? args : [{}, args[0]]; + const constructorOptions = { + ...DEFAULT_CONTROLLER_OPTIONS, + ...givenConstructorOptions, + }; + const controller = new NetworkController(constructorOptions); + + const providerConfig = controller.store.getState().provider; + const networkClientType = providerConfig.type === 'rpc' ? 'custom' : 'infura'; + const { infuraProjectId } = constructorOptions; + const infuraNetwork = + networkClientType === 'infura' ? providerConfig.type : undefined; + const customRpcUrl = + networkClientType === 'custom' ? providerConfig.rpcUrl : undefined; + const network = new NetworkCommunications({ + networkClientType, + networkClientOptions: { infuraProjectId, infuraNetwork, customRpcUrl }, }); + try { - return await fn({ controller }); + return await fn({ controller, network }); } finally { await controller.destroy(); } } /** - * Setup Nock mocks for the block tracker. + * For each kind of way that the provider can be set, `lookupNetwork` is always + * called. This can cause difficulty when testing the behavior of + * `lookupNetwork` itself, as extra requests then have to be mocked. + * This function takes a function that presumably sets the provider, + * stubbing `lookupNetwork` before the function and releasing the stub + * afterward. * - * @param {object} options - Options. - * @param {NETWORK_TYPES} options.networkType - The network type to mock. - * @param {object} options.block - The mock block to return. - * @param {Record} options.requests - A set of methods to setup - * mocks for, mapped to the number of times to expect each request. + * @param {object} args - The arguments. + * @param {NetworkController} args.controller - The network controller. + * @param {() => void | Promise} args.operation - The function that + * presumably involves `lookupNetwork`. */ -function setupMockRpcBlockResponses({ - networkType = NetworkController.defaultProviderConfig.type, - block = BLOCK, - requests = { eth_getBlockByNumber: 1, eth_blockNumber: 1 }, -} = {}) { - const rpcUrl = - networkType === NETWORK_TYPES.RPC - ? 'http://localhost:8545/' - : `https://${networkType}.infura.io/v3/${defaultControllerOptions.infuraProjectId}`; - const { origin, pathname } = new URL(rpcUrl); - - const scope = nock(origin); - - if (requests.eth_getBlockByNumber) { - scope - .post( - pathname, - (body) => - body.method === 'eth_getBlockByNumber' && - body.params[0] === block.number, - ) - .times(requests.eth_getBlockByNumber) - .reply(200, () => JSON.stringify(constructSuccessfulRpcResponse(block))); - } - if (requests.eth_blockNumber) { - scope - .post(pathname, (body) => body.method === 'eth_blockNumber') - .times(requests.eth_blockNumber) - .reply(200, () => - JSON.stringify(constructSuccessfulRpcResponse(block.number)), - ); - } +async function withoutCallingLookupNetwork({ controller, operation }) { + const spy = jest + .spyOn(controller, 'lookupNetwork') + .mockResolvedValue(undefined); + await operation(); + spy.mockRestore(); } -describe('NetworkController', () => { - describe('controller', () => { - afterEach(() => { - nock.cleanAll(); - }); +/** + * For each kind of way that the provider can be set, `getEIP1559Compatibility` + * is always called. This can cause difficulty when testing the behavior of + * `getEIP1559Compatibility` itself, as extra requests then have to be + * mocked. This function takes a function that presumably sets the provider, + * stubbing `getEIP1559Compatibility` before the function and releasing the stub + * afterward. + * + * @param {object} args - The arguments. + * @param {NetworkController} args.controller - The network controller. + * @param {() => void | Promise} args.operation - The function that + * presumably involves `getEIP1559Compatibility`. + */ +async function withoutCallingGetEIP1559Compatibility({ + controller, + operation, +}) { + const spy = jest + .spyOn(controller, 'getEIP1559Compatibility') + .mockResolvedValue(undefined); + await operation(); + spy.mockRestore(); +} - describe('#provider', () => { - it('provider should be updatable without reassignment', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses(); - await controller.initializeProvider(); - const providerProxy = - controller.getProviderAndBlockTracker().provider; - expect(providerProxy.test).toBeUndefined(); +/** + * Waits for changes to the primary observable store of a controller to occur + * before proceeding. May be called with a function, in which case waiting will + * occur after the function is called; or may be called standalone if you want + * to assert that no state changes occurred. + * + * @param {object} [args] - The arguments. + * @param {NetworkController} args.controller - The network controller. + * @param {string[]} [args.propertyPath] - The path of the property you + * expect the state changes to concern. + * @param {number | null} [args.count] - The number of events you expect to + * occur. If null, this function will wait until no events have occurred in + * `wait` number of milliseconds. Default: 1. + * @param {number} [args.duration] - The amount of time in milliseconds to + * wait for the expected number of filtered state changes to occur before + * resolving the promise that this function returns (default: 150). + * @param {() => void | Promise} [args.operation] - A function to run + * that will presumably produce the state changes in question. + * @returns A promise that resolves to an array of state objects (that is, the + * contents of the store) when the specified number of filtered state changes + * have occurred, or all of them if no number has been specified. + */ +async function waitForStateChanges({ + controller, + propertyPath, + count: expectedInterestingStateCount = 1, + duration: timeBeforeAssumingNoMoreStateChanges = 150, + operation = () => { + // do nothing + }, +}) { + const initialState = { ...controller.store.getState() }; + let isTimerRunning = false; - providerProxy.setTarget({ test: true }); + const getPropertyFrom = (state) => { + return propertyPath === undefined + ? state + : propertyPath.reduce((finalValue, part) => finalValue[part], state); + }; - expect(providerProxy.test).toStrictEqual(true); - }); - }); - }); + const isStateChangeInteresting = (newState, prevState) => { + return !isDeepStrictEqual( + getPropertyFrom(newState, propertyPath), + getPropertyFrom(prevState, propertyPath), + ); + }; - describe('destroy', () => { - it('should not throw if called before initialization', async () => { - const controller = new NetworkController(defaultControllerOptions); - await expect(controller.destroy()).resolves.toBe(undefined); - }); + const promiseForStateChanges = new Promise((resolve, reject) => { + // We need to declare this variable first, then assign it later, so that + // ESLint won't complain that resetTimer is referring to this variable + // before it's declared. And we need to use let so that we can assign it + // below. + /* eslint-disable-next-line prefer-const */ + let eventListener; + let timer; + const allStates = []; + const interestingStates = []; - it('should stop the block tracker for the current selected network', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses(); - await controller.initializeProvider(); - const { blockTracker } = controller.getProviderAndBlockTracker(); - // The block tracker starts running after a listener is attached - blockTracker.addListener('latest', () => { - // do nothing - }); - expect(blockTracker.isRunning()).toBe(true); + const stopTimer = () => { + if (timer) { + clearTimeout(timer); + } + isTimerRunning = false; + }; - await controller.destroy(); + const end = () => { + stopTimer(); - expect(blockTracker.isRunning()).toBe(false); - }); - }); - }); + controller.store.unsubscribe(eventListener); - describe('#getNetworkState', () => { - it('should return "loading" when uninitialized', async () => { - await withController(async ({ controller }) => { - const networkState = controller.getNetworkState(); - expect(networkState).toStrictEqual('loading'); - }); - }); - }); + const shouldEnd = + expectedInterestingStateCount === null + ? interestingStates.length > 0 + : interestingStates.length === expectedInterestingStateCount; - describe('#setProviderType', () => { - it('should update provider.type', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses({ networkType: NETWORK_TYPES.RPC }); - setupMockRpcBlockResponses({ networkType: NETWORK_TYPES.MAINNET }); - await controller.initializeProvider(); - controller.setProviderType('mainnet'); - const { type } = controller.getProviderConfig(); - expect(type).toStrictEqual('mainnet'); - }); - }); + if (shouldEnd) { + resolve(interestingStates); + } else { + // Using a string instead of an Error leads to better backtraces. + /* eslint-disable-next-line prefer-promise-reject-errors */ + const expectedInterestingStateCountFragment = + expectedInterestingStateCount === null + ? 'any number of' + : expectedInterestingStateCount; + const propertyPathFragment = + propertyPath === undefined ? '' : ` on \`${propertyPath.join('.')}\``; + const actualInterestingStateCountFragment = + expectedInterestingStateCount === null + ? 'none' + : interestingStates.length; + const primaryMessage = `Expected to receive ${expectedInterestingStateCountFragment} state change(s)${propertyPathFragment}, but received ${actualInterestingStateCountFragment} after ${timeBeforeAssumingNoMoreStateChanges}ms.`; + reject( + [ + primaryMessage, + 'Initial state:', + inspect(initialState, { depth: null }), + 'All state changes (without filtering):', + inspect(allStates, { depth: null }), + 'Filtered state changes:', + inspect(interestingStates, { depth: null }), + ].join('\n\n'), + ); + } + }; - it('should set the network to loading', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses({ networkType: NETWORK_TYPES.RPC }); - setupMockRpcBlockResponses({ networkType: NETWORK_TYPES.MAINNET }); - await controller.initializeProvider(); + const resetTimer = () => { + stopTimer(); + timer = originalSetTimeout(() => { + if (isTimerRunning) { + end(); + } + }, timeBeforeAssumingNoMoreStateChanges); + isTimerRunning = true; + }; - controller.setProviderType('mainnet'); - const { promise: networkIdChanged, resolve } = deferredPromise(); - controller.networkStore.subscribe(resolve); + eventListener = (newState) => { + const isInteresting = isStateChangeInteresting( + newState, + allStates.length > 0 ? allStates[allStates.length - 1] : initialState, + ); - expect(controller.networkStore.getState()).toBe('loading'); - await networkIdChanged; - expect(controller.networkStore.getState()).toBe('1'); - }); - }); - }); + allStates.push({ ...newState }); - describe('#getEIP1559Compatibility', () => { - it('should return false when baseFeePerGas is not in the block header', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses({ block: PRE_1559_BLOCK }); - await controller.initializeProvider(); - const supportsEIP1559 = await controller.getEIP1559Compatibility(); - expect(supportsEIP1559).toStrictEqual(false); - }); - }); + if (isInteresting) { + interestingStates.push(newState); + if (interestingStates.length === expectedInterestingStateCount) { + end(); + } else { + resetTimer(); + } + } + }; - it('should return true when baseFeePerGas is in block header', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses(); - await controller.initializeProvider(); - const supportsEIP1559 = await controller.getEIP1559Compatibility(); - expect(supportsEIP1559).toStrictEqual(true); - }); - }); + controller.store.subscribe(eventListener); + resetTimer(); + }); - it('should store EIP1559 support in state to reduce calls to _getLatestBlock', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses(); - await controller.initializeProvider(); - await controller.getEIP1559Compatibility(); - const supportsEIP1559 = await controller.getEIP1559Compatibility(); - expect(supportsEIP1559).toStrictEqual(true); - }); - }); + await operation(); - it('should clear stored EIP1559 support when changing networks', async () => { - await withController(async ({ controller }) => { - setupMockRpcBlockResponses({ networkType: NETWORK_TYPES.RPC }); - setupMockRpcBlockResponses({ - networkType: NETWORK_TYPES.MAINNET, - block: PRE_1559_BLOCK, - requests: { - eth_blockNumber: 2, - eth_getBlockByNumber: 1, - }, - }); - await controller.initializeProvider(); - await controller.getEIP1559Compatibility(); - expect(controller.networkDetails.getState().EIPS[1559]).toStrictEqual( - true, - ); - await new Promise((resolve) => { - controller.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { - resolve(); - }); - controller.setProviderType('mainnet'); - }); - expect( - controller.networkDetails.getState().EIPS[1559], - ).toBeUndefined(); - await controller.getEIP1559Compatibility(); - expect(controller.networkDetails.getState().EIPS[1559]).toStrictEqual( - false, - ); - }); + return await promiseForStateChanges; +} + +/** + * Waits for an event to occur on the controller before proceeding. + * + * @param {{controller: NetworkController, eventName: string, operation: (() => void | Promise), beforeResolving?: (() => void | Promise)}} args - The arguments. + * @param {NetworkController} args.controller - The network controller + * @param {string} args.eventName - The name of the event. + * @param {() => void | Promise} args.operation - A function that will + * presumably produce the event in question. + * @param {() => void | Promise} [args.beforeResolving] - In some tests, + * state updates happen so fast, we need to make an assertion immediately after + * the event in question occurs. However, if we wait until the promise this + * function returns resolves to do so, some other state update to the same + * property may have happened. This option allows you to make an assertion + * _before_ the promise resolves. This has the added benefit of allowing you to + * maintain the "arrange, act, assert" ordering in your test, meaning that + * you can still call the method that kicks off the event and then make the + * assertion afterward instead of the other way around. + * @returns {Promise} + */ +async function waitForEvent({ + controller, + eventName, + operation, + beforeResolving = async () => { + // do nothing + }, +}) { + const promise = new Promise((resolve) => { + controller.once(eventName, () => { + Promise.resolve(beforeResolving()).then(() => { + resolve(true); }); }); }); -}); + + await operation(); + + return await promise; +} + +/** + * `lookupNetwork` is a method in NetworkController which is called internally + * by a few methods. `lookupNetwork` is asynchronous as it makes network + * requests under the hood, but unfortunately, the method is not awaited after + * being called. Hence, if it is called during a test, even if the network + * requests are initiated within the test, they may complete after that test + * ends. This is a problem because it may cause Nock mocks set up in a later + * test to get used up prematurely, causing failures. + * + * To fix this, we need to wait for `lookupNetwork` to fully finish before + * continuing. Since the latest thing that happens in `lookupNetwork` is to + * update EIP-1559 compatibility in state, we can wait for the `networkDetails` + * state to get updated specifically. Unfortunately, we don't know how many + * times this will happen, so this function does incur some time when it's used. + * To speed up tests, you can pass `numberOfNetworkDetailsChanges`. + * + * + * @param {object} args - The arguments. + * @param {NetworkController} args.controller - The network controller. + * @param {count} [args.numberOfNetworkDetailsChanges] - The number of times + * that `networkDetails` is expected to be updated. + * @param {() => void | Promise} [args.operation] - The function that + * presumably involves `lookupNetwork`. + */ +async function waitForLookupNetworkToComplete({ + controller, + numberOfNetworkDetailsChanges = null, + operation, +}) { + await waitForStateChanges({ + controller, + propertyPath: ['networkDetails'], + operation, + count: numberOfNetworkDetailsChanges, + }); +} diff --git a/test/jest/setup.js b/test/jest/setup.js index c08520920..d1ba51e61 100644 --- a/test/jest/setup.js +++ b/test/jest/setup.js @@ -11,7 +11,80 @@ jest.mock('webextension-polyfill', () => { }; }); +const UNRESOLVED = Symbol('timedOut'); + +// Store this in case it gets stubbed later +const originalSetTimeout = global.setTimeout; + +const TIME_TO_WAIT_UNTIL_UNRESOLVED = 100; + +/** + * Produces a sort of dummy promise which can be used in conjunction with a + * "real" promise to determine whether the "real" promise was ever resolved. If + * the promise that is produced by this function resolves first, then the other + * one must be unresolved. + * + * @param {number} duration - How long to wait before resolving the promise returned by + * this function. + * @returns A promise that resolves to a symbol. + */ +function treatUnresolvedAfter(duration) { + return new Promise((resolve) => { + originalSetTimeout(resolve, duration, UNRESOLVED); + }); +} + /* eslint-disable-next-line jest/require-top-level-describe */ beforeEach(() => { nock.cleanAll(); }); + +expect.extend({ + /** + * Tests that the given promise is never fulfilled or rejected past a certain + * amount of time (which is the default time that Jest tests wait before + * timing out as configured in the Jest configuration file). + * + * Inspired by . + * + * @param {Promise} promise - The promise to test. + * @returns The result of the matcher. + */ + async toNeverResolve(promise) { + if (this.isNot) { + throw new Error( + 'Using `.not.toNeverResolve(...)` is not supported. ' + + 'You probably want to either `await` the promise and test its ' + + 'resolution value or use `.rejects` to test its rejection value instead.', + ); + } + + let resolutionValue; + let rejectionValue; + try { + resolutionValue = await Promise.race([ + promise, + treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), + ]); + } catch (e) { + rejectionValue = e; + } + + return resolutionValue === UNRESOLVED + ? { + message: () => + `Expected promise to resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it did not`, + pass: true, + } + : { + message: () => { + return `Expected promise to never resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ + rejectionValue + ? `was rejected with ${rejectionValue}` + : `resolved with ${resolutionValue}` + }`; + }, + pass: false, + }; + }, +});