From 663fe9a61bb7ea8bdb28e4017d7c533cee385917 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Aug 2025 13:16:54 -0600 Subject: [PATCH 1/3] chore: Don't wait for net_version before starting UI When starting, the UI creates a StreamProvider that wraps the MetaMask provider and can be used to make internal and external requests through the core JSON-RPC pipeline via the background connection. This StreamProvider must be initialized in order for the UI to complete the start sequence and in order for users to see anything on screen. When the StreamProvider initializes, it will make a background request for `metamask_getProviderState` to populate its internal state. As a part of its logic, this RPC method will retrieve the network version from whichever RPC endpoint is currently selected. However, this creates a problem, because if the network is slow, this request won't resolve in a timely manner, causing the UI will be stuck in a loading state and for users to see a spinner in the meantime. To address this, this commit changes the `metamask_getProviderState` handler to accept a parameter, `isInitializingStreamProvider`. It expects that StreamProvider will call the RPC method and pass `true` for this parameter. When this happens, the request for `net_version` will not be made and the initial network version will be set to "loading". This allows the UI to complete its start sequence. Once the UI is loaded, it will update the `memStore` state, and `metamask_getProviderState` will be requested again, but this time, `isInitializingStreamProvider` will be set to `false`. This means the network version will be properly retrieved and propagated to all of the places that need it. --- .../handlers/get-provider-state.test.ts | 8 +- .../handlers/get-provider-state.ts | 4 +- .../rpc-method-middleware/handlers/types.ts | 3 +- app/scripts/metamask-controller.js | 43 +++++++++-- package.json | 2 +- test/e2e/tests/network/slow-network.spec.ts | 75 +++++++++++++++++++ yarn.lock | 10 +-- 7 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 test/e2e/tests/network/slow-network.spec.ts diff --git a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts index 078bd7866a31..7b9c98c2c08d 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts @@ -23,7 +23,9 @@ describe('getProviderState', () => { it('should call getProviderState when the handler is invoked', async () => { const req: HandlerRequestType = { origin: 'testOrigin', - params: [], + params: { + isInitializingStreamProvider: true, + }, id: '22', jsonrpc: '2.0', method: 'metamask_getProviderState', @@ -44,7 +46,9 @@ describe('getProviderState', () => { getProviderState: mockGetProviderState, }); - expect(mockGetProviderState).toHaveBeenCalledWith(req.origin); + expect(mockGetProviderState).toHaveBeenCalledWith(req.origin, { + isInitializingStreamProvider: true, + }); expect(res.result).toStrictEqual({ chainId: '0x539', isUnlocked: true, diff --git a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts index 514f8af6dfa7..716e3324aa66 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts @@ -28,6 +28,7 @@ export type ProviderStateHandlerResult = { export type GetProviderState = ( origin: string, + options?: { isInitializingStreamProvider?: boolean }, ) => Promise; type GetProviderStateConstraint = @@ -73,8 +74,9 @@ async function getProviderStateHandler< end: JsonRpcEngineEndCallback, { getProviderState: _getProviderState }: Record, ): Promise { + const isInitializingStreamProvider = req.params?.isInitializingStreamProvider; res.result = { - ...(await _getProviderState(req.origin)), + ...(await _getProviderState(req.origin, { isInitializingStreamProvider })), }; return end(); } diff --git a/app/scripts/lib/rpc-method-middleware/handlers/types.ts b/app/scripts/lib/rpc-method-middleware/handlers/types.ts index 54ca7c0307cc..b4ee6223d976 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/types.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/types.ts @@ -24,8 +24,9 @@ export type HandlerWrapper = { }; export type HandlerRequestType = - Required> & { + Required, 'params'>> & { origin: string; + params?: { isInitializingStreamProvider?: boolean }; }; export type GetAccounts = MetamaskController['getPermittedAccounts']; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 37721dcf5d9c..162c6d597cc3 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3297,6 +3297,7 @@ export default class MetamaskController extends EventEmitter { // setup memStore subscription hooks this.on('update', updatePublicConfigStore); + // Update the store asynchronously, out-of-band updatePublicConfigStore(this.getState()); return publicConfigStore; @@ -3306,10 +3307,18 @@ export default class MetamaskController extends EventEmitter { * Gets relevant state for the provider of an external origin. * * @param {string} origin - The origin to get the provider state for. + * @param {object} [options] - Options. + * @param {boolean} [options.isInitializingStreamProvider] - Whether this method is being used to initialize the StreamProvider (default: false). * @returns {Promise<{ isUnlocked: boolean, networkVersion: string, chainId: string, accounts: string[], extensionId: string | undefined }>} An object with relevant state properties. */ - async getProviderState(origin) { - const providerNetworkState = await this.getProviderNetworkState(origin); + async getProviderState( + origin, + { isInitializingStreamProvider = false } = {}, + ) { + const providerNetworkState = await this.getProviderNetworkState({ + origin, + isInitializingStreamProvider, + }); const metadata = {}; if (isManifestV3) { const { chrome } = globalThis; @@ -3330,10 +3339,15 @@ export default class MetamaskController extends EventEmitter { /** * Retrieves network state information relevant for external providers. * - * @param {string} origin - The origin identifier for which network state is requested (default: 'metamask'). + * @param {object} [args] - The arguments to this function. + * @param {string} [args.origin] - The origin identifier for which network state is requested (default: 'metamask'). + * @param {boolean} [args.isInitializingStreamProvider] - Whether this method is being used to initialize the StreamProvider (default: false). * @returns {object} An object containing important network state properties, including chainId and networkVersion. */ - async getProviderNetworkState(origin = METAMASK_DOMAIN) { + async getProviderNetworkState({ + origin = METAMASK_DOMAIN, + isInitializingStreamProvider = false, + } = {}) { const networkClientId = this.controllerMessenger.call( 'SelectedNetworkController:getNetworkClientIdForDomain', origin, @@ -3349,7 +3363,22 @@ export default class MetamaskController extends EventEmitter { const { completedOnboarding } = this.onboardingController.state; let networkVersion = this.deprecatedNetworkVersions[networkClientId]; - if (networkVersion === undefined && completedOnboarding) { + // We use `metamask_getProviderState` to set the initial state of the + // StreamProvider. The StreamProvider allows the UI to make network requests + // through the background connection, and it must be initialized before we + // can show the UI. However, this creates a problem if the selected network + // is slow or unresponsive, because then the network request will hang and + // thus we will be unable to show the UI. To get around this, we prevent a + // request from occurring during initialization + // (`isInitializingStreamProvider` = true). `metamask_getProviderState` is + // called each time the memState is updated, so eventually we _will_ make + // this request (`isInitializingStreamProvider` = false), and if the network + // recovers, the network version will be properly retrieved at that time. + if ( + networkVersion === undefined && + completedOnboarding && + !isInitializingStreamProvider + ) { try { const result = await networkClient.provider.request({ method: 'net_version', @@ -8980,7 +9009,7 @@ export default class MetamaskController extends EventEmitter { this.notifyAllConnections( async (origin) => ({ method: NOTIFICATION_NAMES.chainChanged, - params: await this.getProviderNetworkState(origin), + params: await this.getProviderNetworkState({ origin }), }), API_TYPE.EIP1193, ); @@ -8989,7 +9018,7 @@ export default class MetamaskController extends EventEmitter { async _notifyChainChangeForConnection(connection, origin) { this.notifyConnection(connection, { method: NOTIFICATION_NAMES.chainChanged, - params: await this.getProviderNetworkState(origin), + params: await this.getProviderNetworkState({ origin }), }); } diff --git a/package.json b/package.json index 222c45946e40..002df4ad8776 100644 --- a/package.json +++ b/package.json @@ -333,7 +333,7 @@ "@metamask/ppom-validator": "0.36.0", "@metamask/preinstalled-example-snap": "^0.7.1", "@metamask/profile-sync-controller": "^23.0.0", - "@metamask/providers": "^22.1.0", + "@metamask/providers": "^22.1.1", "@metamask/rate-limit-controller": "^6.0.3", "@metamask/remote-feature-flag-controller": "^1.6.0", "@metamask/rpc-errors": "^7.0.0", diff --git a/test/e2e/tests/network/slow-network.spec.ts b/test/e2e/tests/network/slow-network.spec.ts new file mode 100644 index 000000000000..ba9f9f80d347 --- /dev/null +++ b/test/e2e/tests/network/slow-network.spec.ts @@ -0,0 +1,75 @@ +import { Suite } from 'mocha'; +import { Mockttp } from 'mockttp'; +import { NetworkStatus } from '@metamask/network-controller'; +import { withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import LoginPage from '../../page-objects/pages/login-page'; + +describe('Slow network', function (this: Suite) { + it('does not show the spinner while attempting to determine the network status', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder({ inputChainId: '0x539' }) + .withNetworkController({ + networkConfigurations: { + 'AAAA-AAAA-AAAA-AAAA': { + id: 'AAAA-AAAA-AAAA-AAAA', + chainId: '0x9999', + nickname: 'A Custom Network', + rpcUrl: 'https://customnetwork.test', + ticker: 'ETH', + blockExplorerUrl: undefined, + }, + }, + networksMetadata: { + 'AAAA-AAAA-AAAA-AAAA': { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + providerConfig: { + id: 'AAAA-AAAA-AAAA-AAAA', + }, + }) + .withEnabledNetworks({ + eip155: { + '0x9999': true, + }, + }) + .build(), + localNodeOptions: 'none', + title: this.test?.fullTitle(), + testSpecificMock: async (mockServer: Mockttp) => { + // Cause requests to the network to be pending indefinitely + return await mockServer + .forPost('https://customnetwork.test') + .thenPassThrough({ + beforeRequest: async () => { + // Wait long enough to cause a timeout + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + }); + return undefined; + }, + }); + }, + }, + async ({ driver }) => { + await driver.navigate(); + const loginPage = new LoginPage(driver); + await loginPage.checkPageIsLoaded(); + await loginPage.loginToHomepage(); + + // Ensure that the connection spinner shows up. + // This wouldn't happen if we waited for the net_version (mocked above) + // to fully resolve. + await driver.waitForSelector({ + text: 'Connecting to A Custom Network', + }); + }, + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2e46f0afe6e8..8183ee44570c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7028,9 +7028,9 @@ __metadata: languageName: node linkType: hard -"@metamask/providers@npm:^22.1.0": - version: 22.1.0 - resolution: "@metamask/providers@npm:22.1.0" +"@metamask/providers@npm:^22.1.0, @metamask/providers@npm:^22.1.1": + version: 22.1.1 + resolution: "@metamask/providers@npm:22.1.1" dependencies: "@metamask/json-rpc-engine": "npm:^10.0.2" "@metamask/json-rpc-middleware-stream": "npm:^8.0.6" @@ -7045,7 +7045,7 @@ __metadata: readable-stream: "npm:^3.6.2" peerDependencies: webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/d6dc969296e3d478a904228f27adae3b6dcbfdbf49eb6d571c9d73d7506df3c6e3bf3c3464f1e69e4c0acb6f6072d12d1c8182348e626ca1572f4f22f9c585e6 + checksum: 10/50194c608fb308cee268c6eefb8c8d439a9d07aa41d07cb5ddcd7e706819aea7e746150f32688fe06d20309b49346440855b5d56aacf286b343da6fcb217cc12 languageName: node linkType: hard @@ -31815,7 +31815,7 @@ __metadata: "@metamask/preferences-controller": "npm:^17.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.1" "@metamask/profile-sync-controller": "npm:^23.0.0" - "@metamask/providers": "npm:^22.1.0" + "@metamask/providers": "npm:^22.1.1" "@metamask/rate-limit-controller": "npm:^6.0.3" "@metamask/remote-feature-flag-controller": "npm:^1.6.0" "@metamask/rpc-errors": "npm:^7.0.0" From 6b272731b3f6f4f8b46b05c9212ba0f36d737a3b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 9 Sep 2025 14:33:24 -0600 Subject: [PATCH 2/3] Fix type errors --- .../handlers/get-provider-state.test.ts | 4 +- .../handlers/get-provider-state.ts | 42 ++++++++----------- .../rpc-method-middleware/handlers/types.ts | 3 +- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts index 7b9c98c2c08d..2dfda7728c3a 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.test.ts @@ -2,9 +2,9 @@ import { PendingJsonRpcResponse } from '@metamask/utils'; import { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import getProviderState, { GetProviderState, + ProviderStateHandlerRequest, ProviderStateHandlerResult, } from './get-provider-state'; -import { HandlerRequestType } from './types'; describe('getProviderState', () => { let mockEnd: JsonRpcEngineEndCallback; @@ -21,7 +21,7 @@ describe('getProviderState', () => { }); it('should call getProviderState when the handler is invoked', async () => { - const req: HandlerRequestType = { + const req: ProviderStateHandlerRequest = { origin: 'testOrigin', params: { isInitializingStreamProvider: true, diff --git a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts index 716e3324aa66..d70a9dd93541 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts @@ -2,16 +2,13 @@ import type { JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, } from '@metamask/json-rpc-engine'; -import type { - PendingJsonRpcResponse, - JsonRpcParams, - Hex, -} from '@metamask/utils'; +import type { PendingJsonRpcResponse, Hex } from '@metamask/utils'; import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; -import { - HandlerWrapper, - HandlerRequestType as ProviderStateHandlerRequest, -} from './types'; +import { HandlerWrapper, HandlerRequestType } from './types'; + +export type ProviderStateHandlerRequest = HandlerRequestType<{ + isInitializingStreamProvider?: boolean; +}>; /** * @property chainId - The current chain ID. @@ -31,16 +28,15 @@ export type GetProviderState = ( options?: { isInitializingStreamProvider?: boolean }, ) => Promise; -type GetProviderStateConstraint = - { - implementation: ( - _req: ProviderStateHandlerRequest, - res: PendingJsonRpcResponse, - _next: JsonRpcEngineNextCallback, - end: JsonRpcEngineEndCallback, - { _getProviderState }: Record, - ) => Promise; - } & HandlerWrapper; +type GetProviderStateConstraint = { + implementation: ( + _req: ProviderStateHandlerRequest, + res: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { _getProviderState }: Record, + ) => Promise; +} & HandlerWrapper; /** * This RPC method gets background state relevant to the provider. @@ -65,16 +61,14 @@ export default getProviderState; * @param options * @param options.getProviderState - An async function that gets the current provider state. */ -async function getProviderStateHandler< - Params extends JsonRpcParams = JsonRpcParams, ->( - req: ProviderStateHandlerRequest, +async function getProviderStateHandler( + req: ProviderStateHandlerRequest, res: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, { getProviderState: _getProviderState }: Record, ): Promise { - const isInitializingStreamProvider = req.params?.isInitializingStreamProvider; + const { isInitializingStreamProvider } = req.params; res.result = { ...(await _getProviderState(req.origin, { isInitializingStreamProvider })), }; diff --git a/app/scripts/lib/rpc-method-middleware/handlers/types.ts b/app/scripts/lib/rpc-method-middleware/handlers/types.ts index b4ee6223d976..54ca7c0307cc 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/types.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/types.ts @@ -24,9 +24,8 @@ export type HandlerWrapper = { }; export type HandlerRequestType = - Required, 'params'>> & { + Required> & { origin: string; - params?: { isInitializingStreamProvider?: boolean }; }; export type GetAccounts = MetamaskController['getPermittedAccounts']; From aa5d78118a2d40fac66a2532fa7e02550a66274b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 9 Sep 2025 15:30:39 -0600 Subject: [PATCH 3/3] Fix e2e tests --- .../handlers/get-provider-state.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts index d70a9dd93541..407ca43d4734 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/get-provider-state.ts @@ -2,13 +2,19 @@ import type { JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, } from '@metamask/json-rpc-engine'; -import type { PendingJsonRpcResponse, Hex } from '@metamask/utils'; +import type { + PendingJsonRpcResponse, + Hex, + JsonRpcRequest, +} from '@metamask/utils'; import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; -import { HandlerWrapper, HandlerRequestType } from './types'; +import { HandlerWrapper } from './types'; -export type ProviderStateHandlerRequest = HandlerRequestType<{ +export type ProviderStateHandlerRequest = JsonRpcRequest<{ isInitializingStreamProvider?: boolean; -}>; +}> & { + origin: string; +}; /** * @property chainId - The current chain ID. @@ -68,7 +74,7 @@ async function getProviderStateHandler( end: JsonRpcEngineEndCallback, { getProviderState: _getProviderState }: Record, ): Promise { - const { isInitializingStreamProvider } = req.params; + const isInitializingStreamProvider = req.params?.isInitializingStreamProvider; res.result = { ...(await _getProviderState(req.origin, { isInitializingStreamProvider })), };