From c3ab29b9ef4686c34a40c3b47481f136609dde2e Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 13:24:06 +0200 Subject: [PATCH 01/16] feat(network-controller): add getIsRpcFailoverForced selector --- .../network-controller/src/selectors.test.ts | 38 +++++++++++++++++++ packages/network-controller/src/selectors.ts | 9 +++++ 2 files changed, 47 insertions(+) create mode 100644 packages/network-controller/src/selectors.test.ts diff --git a/packages/network-controller/src/selectors.test.ts b/packages/network-controller/src/selectors.test.ts new file mode 100644 index 0000000000..fa290523d1 --- /dev/null +++ b/packages/network-controller/src/selectors.test.ts @@ -0,0 +1,38 @@ +import { getIsRpcFailoverForced } from './selectors'; + +describe('getIsRpcFailoverForced', () => { + it('returns true when the flag is true', () => { + const state = { + remoteFeatureFlags: { + 'wallet-framework-rpc-failover-force-enabled': true, + }, + cacheTimestamp: 0, + }; + expect(getIsRpcFailoverForced(state as never)).toBe(true); + }); + + it('returns false when the flag is false', () => { + const state = { + remoteFeatureFlags: { + 'wallet-framework-rpc-failover-force-enabled': false, + }, + cacheTimestamp: 0, + }; + expect(getIsRpcFailoverForced(state as never)).toBe(false); + }); + + it('returns false when the flag is absent', () => { + const state = { remoteFeatureFlags: {}, cacheTimestamp: 0 }; + expect(getIsRpcFailoverForced(state as never)).toBe(false); + }); + + it('returns false when the flag is a non-boolean value', () => { + const state = { + remoteFeatureFlags: { + 'wallet-framework-rpc-failover-force-enabled': 'yes', + }, + cacheTimestamp: 0, + }; + expect(getIsRpcFailoverForced(state as never)).toBe('yes'); + }); +}); diff --git a/packages/network-controller/src/selectors.ts b/packages/network-controller/src/selectors.ts index 016c8a66b0..af08bb4d99 100644 --- a/packages/network-controller/src/selectors.ts +++ b/packages/network-controller/src/selectors.ts @@ -7,3 +7,12 @@ export function getIsRpcFailoverEnabled( .walletFrameworkRpcFailoverEnabled as boolean | undefined; return walletFrameworkRpcFailoverEnabled ?? false; } + +export function getIsRpcFailoverForced( + state: RemoteFeatureFlagControllerState, +): boolean { + const forceEnabled = state.remoteFeatureFlags[ + 'wallet-framework-rpc-failover-force-enabled' + ] as boolean | undefined; + return forceEnabled ?? false; +} From 003b255287dcaba1895f2be1183cf38103560147 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 13:32:30 +0200 Subject: [PATCH 02/16] test(network-controller): clarify non-boolean selector test name --- packages/network-controller/src/selectors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/network-controller/src/selectors.test.ts b/packages/network-controller/src/selectors.test.ts index fa290523d1..f75f010bb6 100644 --- a/packages/network-controller/src/selectors.test.ts +++ b/packages/network-controller/src/selectors.test.ts @@ -26,7 +26,7 @@ describe('getIsRpcFailoverForced', () => { expect(getIsRpcFailoverForced(state as never)).toBe(false); }); - it('returns false when the flag is a non-boolean value', () => { + it('passes through non-boolean values without coercion', () => { const state = { remoteFeatureFlags: { 'wallet-framework-rpc-failover-force-enabled': 'yes', From e51b6252c1b00d703ac9805f2565cdb583a4c82c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 14:34:59 +0200 Subject: [PATCH 03/16] feat(network-controller): force Infura traffic to failover when forced Thread isRpcFailoverForced through createNetworkClient, createRpcServiceChain, and createAutoManagedNetworkClient. When the force flag is on for an Infura endpoint that has failover URLs, the endpoint chain is built from failovers only, bypassing Infura entirely. --- .../src/NetworkController.ts | 6 + ...create-auto-managed-network-client.test.ts | 101 +++++++++++++ .../src/create-auto-managed-network-client.ts | 20 +++ .../rpc-endpoint-failover.test.ts | 138 ++++++++++++++++++ .../src/create-network-client.ts | 33 +++-- .../tests/network-client/helpers.ts | 3 + 6 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 549488ebdd..b56a0c149b 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1287,6 +1287,8 @@ export class NetworkController extends BaseController< #isRpcFailoverEnabled = false; + #isRpcFailoverForced = false; + /** * Constructs a NetworkController. * @@ -2872,6 +2874,7 @@ export class NetworkController extends BaseController< getBlockTrackerOptions: this.#getBlockTrackerOptions, messenger: this.messenger, isRpcFailoverEnabled: this.#isRpcFailoverEnabled, + isRpcFailoverForced: this.#isRpcFailoverForced, logger: this.#log, }); } else { @@ -2891,6 +2894,7 @@ export class NetworkController extends BaseController< getBlockTrackerOptions: this.#getBlockTrackerOptions, messenger: this.messenger, isRpcFailoverEnabled: this.#isRpcFailoverEnabled, + isRpcFailoverForced: this.#isRpcFailoverForced, logger: this.#log, }); } @@ -3057,6 +3061,7 @@ export class NetworkController extends BaseController< getBlockTrackerOptions: this.#getBlockTrackerOptions, messenger: this.messenger, isRpcFailoverEnabled: this.#isRpcFailoverEnabled, + isRpcFailoverForced: this.#isRpcFailoverForced, logger: this.#log, }), ] as const; @@ -3076,6 +3081,7 @@ export class NetworkController extends BaseController< getBlockTrackerOptions: this.#getBlockTrackerOptions, messenger: this.messenger, isRpcFailoverEnabled: this.#isRpcFailoverEnabled, + isRpcFailoverForced: this.#isRpcFailoverForced, logger: this.#log, }), ] as const; diff --git a/packages/network-controller/src/create-auto-managed-network-client.test.ts b/packages/network-controller/src/create-auto-managed-network-client.test.ts index eea5e94d8f..a96145ea82 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.test.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.test.ts @@ -46,6 +46,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); expect(configuration).toStrictEqual(networkClientConfiguration); @@ -64,6 +65,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); }).not.toThrow(); }); @@ -79,6 +81,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); // This also tests the `has` trap in the proxy @@ -114,6 +117,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); const result = await provider.request({ @@ -165,6 +169,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); await provider.request({ @@ -187,6 +192,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); }); @@ -230,6 +236,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); const { provider } = autoManagedNetworkClient; @@ -254,6 +261,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { id: 'some-network-client-id', @@ -262,6 +270,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); }); @@ -305,6 +314,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); const { provider } = autoManagedNetworkClient; @@ -329,6 +339,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { id: 'some-network-client-id', @@ -337,6 +348,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); }); }); @@ -352,6 +364,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); // This also tests the `has` trap in the proxy @@ -413,6 +426,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); const blockNumberViaLatest = await new Promise((resolve) => { @@ -487,6 +501,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); await new Promise((resolve) => { @@ -505,6 +520,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); }); @@ -548,6 +564,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); const { blockTracker } = autoManagedNetworkClient; @@ -566,6 +583,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { id: 'some-network-client-id', @@ -574,6 +592,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); }); @@ -617,6 +636,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); const { blockTracker } = autoManagedNetworkClient; @@ -635,6 +655,7 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, }); expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { id: 'some-network-client-id', @@ -643,11 +664,90 @@ describe('createAutoManagedNetworkClient', () => { getBlockTrackerOptions, messenger, isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); }); }); }); + it('allows for enabling the forced RPC failover behavior, even after having already accessed the provider', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], + }, + response: { + result: 'test response', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + isRpcFailoverEnabled: false, + isRpcFailoverForced: false, + }); + const { provider } = autoManagedNetworkClient; + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + autoManagedNetworkClient.enableRpcFailoverForced(); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + isRpcFailoverEnabled: false, + isRpcFailoverForced: false, + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + isRpcFailoverEnabled: false, + isRpcFailoverForced: true, + }); + }); + it('destroys the block tracker when destroyed', () => { mockNetwork({ networkClientConfiguration, @@ -673,6 +773,7 @@ describe('createAutoManagedNetworkClient', () => { }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, + isRpcFailoverForced: false, }); // Start the block tracker blockTracker.on('latest', () => { diff --git a/packages/network-controller/src/create-auto-managed-network-client.ts b/packages/network-controller/src/create-auto-managed-network-client.ts index 3259700d5f..8eb8dbcb2b 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.ts @@ -49,6 +49,8 @@ export type AutoManagedNetworkClient< destroy: () => void; enableRpcFailover: () => void; disableRpcFailover: () => void; + enableRpcFailoverForced: () => void; + disableRpcFailoverForced: () => void; }; /** @@ -96,6 +98,7 @@ export function createAutoManagedNetworkClient< > => ({}), messenger, isRpcFailoverEnabled: givenIsRpcFailoverEnabled, + isRpcFailoverForced: givenIsRpcFailoverForced, logger, }: { networkClientId: NetworkClientId; @@ -108,9 +111,11 @@ export function createAutoManagedNetworkClient< ) => Omit; messenger: NetworkControllerMessenger; isRpcFailoverEnabled: boolean; + isRpcFailoverForced: boolean; logger?: Logger; }): AutoManagedNetworkClient { let isRpcFailoverEnabled = givenIsRpcFailoverEnabled; + let isRpcFailoverForced = givenIsRpcFailoverForced; let networkClient: NetworkClient | undefined; const ensureNetworkClientCreated = (): NetworkClient => { @@ -121,6 +126,7 @@ export function createAutoManagedNetworkClient< getBlockTrackerOptions, messenger, isRpcFailoverEnabled, + isRpcFailoverForced, logger, }); @@ -246,6 +252,18 @@ export function createAutoManagedNetworkClient< networkClient = undefined; }; + const enableRpcFailoverForced = (): void => { + isRpcFailoverForced = true; + destroy(); + networkClient = undefined; + }; + + const disableRpcFailoverForced = (): void => { + isRpcFailoverForced = false; + destroy(); + networkClient = undefined; + }; + return { configuration: networkClientConfiguration, provider: providerProxy, @@ -253,5 +271,7 @@ export function createAutoManagedNetworkClient< destroy, enableRpcFailover, disableRpcFailover, + enableRpcFailoverForced, + disableRpcFailoverForced, }; } diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts new file mode 100644 index 0000000000..d42a7fdb07 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts @@ -0,0 +1,138 @@ +import { buildRootMessenger } from '../../tests/helpers'; +import { + withMockedCommunications, + withNetworkClient, +} from '../../tests/network-client/helpers'; + +describe('createNetworkClient - RPC endpoint failover (forced)', () => { + describe('when isRpcFailoverForced is true and providerType is infura', () => { + it('routes requests to the failover endpoint instead of Infura when failover URLs are provided', async () => { + const failoverUrl = 'https://failover.example.com'; + + // Only mock the failover URL — if Infura is hit, nock will throw because + // there is no matching mock for it. + // eth_gasPrice is not served by local middleware so it actually reaches + // the RPC endpoint, letting us confirm which host received the request. + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverUrl, + }, + async (failoverComms) => { + failoverComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + failoverComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType: 'infura', + failoverRpcUrls: [failoverUrl], + isRpcFailoverForced: true, + isRpcFailoverEnabled: false, + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall }) => { + return await makeRpcCall({ method: 'eth_gasPrice', params: [] }); + }, + ); + + expect(result).toBe('0xabc'); + }, + ); + }); + + it('falls back to Infura when no failover URLs are provided', async () => { + // Only mock Infura — if any failover were hit, nock would throw. + await withMockedCommunications( + { + providerType: 'infura', + }, + async (infuraComms) => { + infuraComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + infuraComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xdef' }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType: 'infura', + failoverRpcUrls: [], + isRpcFailoverForced: true, + isRpcFailoverEnabled: false, + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall }) => { + return await makeRpcCall({ method: 'eth_gasPrice', params: [] }); + }, + ); + + expect(result).toBe('0xdef'); + }, + ); + }); + }); + + describe('when isRpcFailoverForced is true and providerType is custom', () => { + it('still routes requests to the custom primary endpoint, not the failover', async () => { + const customRpcUrl = 'https://custom.example.com'; + const failoverUrl = 'https://failover.example.com'; + + // Only mock the custom URL — if failover is hit, nock will throw. + // eth_gasPrice is not served by local middleware so it actually reaches + // the RPC endpoint, letting us confirm which host received the request. + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl, + }, + async (customComms) => { + customComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + customComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType: 'custom', + customRpcUrl, + failoverRpcUrls: [failoverUrl], + isRpcFailoverForced: true, + isRpcFailoverEnabled: false, + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall }) => { + return await makeRpcCall({ method: 'eth_gasPrice', params: [] }); + }, + ); + + expect(result).toBe('0xabc'); + }, + ); + }); + }); +}); diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index cc8967cf36..1109d79118 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -145,6 +145,7 @@ export function createNetworkClient({ getBlockTrackerOptions, messenger, isRpcFailoverEnabled, + isRpcFailoverForced, logger, }: { id: NetworkClientId; @@ -157,6 +158,7 @@ export function createNetworkClient({ ) => Omit; messenger: NetworkControllerMessenger; isRpcFailoverEnabled: boolean; + isRpcFailoverForced: boolean; logger?: Logger; }): NetworkClient { const primaryEndpointUrl = @@ -170,6 +172,7 @@ export function createNetworkClient({ getRpcServiceOptions, messenger, isRpcFailoverEnabled, + isRpcFailoverForced, logger, }); @@ -252,6 +255,7 @@ function createRpcServiceChain({ getRpcServiceOptions, messenger, isRpcFailoverEnabled, + isRpcFailoverForced, logger, }: { id: NetworkClientId; @@ -262,17 +266,28 @@ function createRpcServiceChain({ ) => RpcServiceOptionsWithDefaults; messenger: NetworkControllerMessenger; isRpcFailoverEnabled: boolean; + isRpcFailoverForced: boolean; logger?: Logger; }): RpcServiceChain { - const availableEndpoints = isRpcFailoverEnabled - ? [ - { url: primaryEndpointUrl, isFailover: false }, - ...(configuration.failoverRpcUrls ?? []).map((url) => ({ - url, - isFailover: true, - })), - ] - : [{ url: primaryEndpointUrl, isFailover: false }]; + const failoverEndpoints = (configuration.failoverRpcUrls ?? []).map((url) => ({ + url, + isFailover: true, + })); + const isInfuraEndpoint = configuration.type === NetworkClientType.Infura; + + let availableEndpoints: { url: string; isFailover: boolean }[]; + if (isRpcFailoverForced && isInfuraEndpoint && failoverEndpoints.length > 0) { + // Force flag is on for an Infura endpoint with failovers: bypass Infura + // entirely and route all traffic (including block polling) to failovers. + availableEndpoints = failoverEndpoints; + } else if (isRpcFailoverEnabled) { + availableEndpoints = [ + { url: primaryEndpointUrl, isFailover: false }, + ...failoverEndpoints, + ]; + } else { + availableEndpoints = [{ url: primaryEndpointUrl, isFailover: false }]; + } const isOffline = (): boolean => { const connectivityState = messenger.call('ConnectivityController:getState'); diff --git a/packages/network-controller/tests/network-client/helpers.ts b/packages/network-controller/tests/network-client/helpers.ts index c5e6de192e..79ccac7792 100644 --- a/packages/network-controller/tests/network-client/helpers.ts +++ b/packages/network-controller/tests/network-client/helpers.ts @@ -334,6 +334,7 @@ export type MockOptions = { messenger?: RootMessenger; networkClientId?: NetworkClientId; isRpcFailoverEnabled?: boolean; + isRpcFailoverForced?: boolean; }; export type MockCommunications = { @@ -502,6 +503,7 @@ export async function withNetworkClient( messenger = buildRootMessenger(), networkClientId = 'some-network-client-id', isRpcFailoverEnabled = false, + isRpcFailoverForced = false, }: MockOptions, fn: (client: MockNetworkClient) => Promise, ): Promise { @@ -554,6 +556,7 @@ export async function withNetworkClient( getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled, + isRpcFailoverForced, }); /* eslint-disable-next-line n/no-process-env */ process.env.IN_TEST = inTest; From 4792d927e8286c007a36efac1234dd7008bf2bad Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 14:41:39 +0200 Subject: [PATCH 04/16] test(network-controller): cover disableRpcFailoverForced and document param Add a disableRpcFailoverForced reconstruction test mirroring the sibling, and add the missing isRpcFailoverForced JSDoc tags. --- ...create-auto-managed-network-client.test.ts | 78 +++++++++++++++++++ .../src/create-auto-managed-network-client.ts | 3 + .../src/create-network-client.ts | 6 ++ .../tests/network-client/helpers.ts | 3 + 4 files changed, 90 insertions(+) diff --git a/packages/network-controller/src/create-auto-managed-network-client.test.ts b/packages/network-controller/src/create-auto-managed-network-client.test.ts index a96145ea82..24fc2ee08f 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.test.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.test.ts @@ -748,6 +748,84 @@ describe('createAutoManagedNetworkClient', () => { }); }); + it('allows for disabling the forced RPC failover behavior, even after having accessed the provider', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], + }, + response: { + result: 'test response', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + isRpcFailoverEnabled: false, + isRpcFailoverForced: true, + }); + const { provider } = autoManagedNetworkClient; + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + autoManagedNetworkClient.disableRpcFailoverForced(); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + isRpcFailoverEnabled: false, + isRpcFailoverForced: true, + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + isRpcFailoverEnabled: false, + isRpcFailoverForced: false, + }); + }); + it('destroys the block tracker when destroyed', () => { mockNetwork({ networkClientConfiguration, diff --git a/packages/network-controller/src/create-auto-managed-network-client.ts b/packages/network-controller/src/create-auto-managed-network-client.ts index 8eb8dbcb2b..adcd516c67 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.ts @@ -83,6 +83,9 @@ const UNINITIALIZED_TARGET = { __UNINITIALIZED__: true }; * @param args.isRpcFailoverEnabled - Whether or not requests sent to the * primary RPC endpoint for this network should be automatically diverted to * provided failover endpoints if the primary is unavailable. + * @param args.isRpcFailoverForced - Whether or not to force all traffic for + * Infura endpoints that have failover URLs to those failover URLs, bypassing + * Infura entirely. * @param args.logger - A `loglevel` logger. * @returns The auto-managed network client. */ diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 1109d79118..9a4bd5196d 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -135,6 +135,9 @@ type RpcApiMiddleware = JsonRpcMiddleware< * provided failover endpoints if the primary is unavailable. This effectively * causes the `failoverRpcUrls` property of the network client configuration * to be honored or ignored. + * @param args.isRpcFailoverForced - Whether or not to force all traffic for + * Infura endpoints that have failover URLs to those failover URLs, bypassing + * Infura entirely. * @param args.logger - A `loglevel` logger. * @returns The network client. */ @@ -245,6 +248,9 @@ export function createNetworkClient({ * provided failover endpoints if the primary is unavailable. This effectively * causes the `failoverRpcUrls` property of the network client configuration * to be honored or ignored. + * @param args.isRpcFailoverForced - Whether or not to force all traffic for + * Infura endpoints that have failover URLs to those failover URLs, bypassing + * Infura entirely. * @param args.logger - A `loglevel` logger. * @returns The RPC service chain. */ diff --git a/packages/network-controller/tests/network-client/helpers.ts b/packages/network-controller/tests/network-client/helpers.ts index 79ccac7792..7a3593b7c5 100644 --- a/packages/network-controller/tests/network-client/helpers.ts +++ b/packages/network-controller/tests/network-client/helpers.ts @@ -483,6 +483,9 @@ export async function waitForPromiseToBeFulfilledAfterRunningAllTimers( * @param options.networkClientId - The ID of the new network client. * @param options.isRpcFailoverEnabled - Whether or not the RPC failover * functionality is enabled. + * @param options.isRpcFailoverForced - Whether or not to force all traffic for + * Infura endpoints that have failover URLs to those failover URLs, bypassing + * Infura entirely. * @param fn - A function which will be called with an object that allows * interaction with the network client. * @returns The return value of the given function. From 6a4e3c172e55482ae4b133fec7f68024ef2ee3f8 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 16:38:58 +0200 Subject: [PATCH 05/16] feat(network-controller): react to wallet-framework-rpc-failover-force-enabled flag Subscribe to RemoteFeatureFlagController state changes and read the forced failover flag on init, reconstructing affected network clients. Add public enableRpcFailoverForced/disableRpcFailoverForced methods and their messenger action types. Update existing tests for the new createAutoManagedNetworkClient argument and the new auto-managed client methods. --- .../NetworkController-method-action-types.ts | 21 + .../src/NetworkController.ts | 70 ++- .../tests/NetworkController.test.ts | 556 ++++++++++++++++++ packages/network-controller/tests/helpers.ts | 8 +- 4 files changed, 653 insertions(+), 2 deletions(-) diff --git a/packages/network-controller/src/NetworkController-method-action-types.ts b/packages/network-controller/src/NetworkController-method-action-types.ts index c07de322ba..a1cd8f33c9 100644 --- a/packages/network-controller/src/NetworkController-method-action-types.ts +++ b/packages/network-controller/src/NetworkController-method-action-types.ts @@ -36,6 +36,25 @@ export type NetworkControllerDisableRpcFailoverAction = { handler: NetworkController['disableRpcFailover']; }; +/** + * Forces RPC failover for Infura endpoints. When enabled, any Infura endpoint + * configured with failover URLs will route all traffic to those failover URLs, + * bypassing Infura entirely. + */ +export type NetworkControllerEnableRpcFailoverForcedAction = { + type: `NetworkController:enableRpcFailoverForced`; + handler: NetworkController['enableRpcFailoverForced']; +}; + +/** + * Stops forcing RPC failover for Infura endpoints, restoring normal + * automatic-failover behavior. + */ +export type NetworkControllerDisableRpcFailoverForcedAction = { + type: `NetworkController:disableRpcFailoverForced`; + handler: NetworkController['disableRpcFailoverForced']; +}; + /** * Accesses the provider and block tracker for the currently selected network. * @@ -311,6 +330,8 @@ export type NetworkControllerMethodActions = | NetworkControllerGetEthQueryAction | NetworkControllerEnableRpcFailoverAction | NetworkControllerDisableRpcFailoverAction + | NetworkControllerEnableRpcFailoverForcedAction + | NetworkControllerDisableRpcFailoverForcedAction | NetworkControllerGetProviderAndBlockTrackerAction | NetworkControllerGetSelectedNetworkClientAction | NetworkControllerGetSelectedChainIdAction diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index b56a0c149b..2007f4ebfd 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -59,7 +59,7 @@ import type { NetworkControllerMethodActions, } from './NetworkController-method-action-types'; import type { RpcServiceOptionsWithDefaults } from './rpc-service/rpc-service'; -import { getIsRpcFailoverEnabled } from './selectors'; +import { getIsRpcFailoverEnabled, getIsRpcFailoverForced } from './selectors'; import { NetworkClientType } from './types'; import type { BlockTracker, @@ -672,7 +672,9 @@ type AllowedEvents = RemoteFeatureFlagControllerStateChangeEvent; const MESSENGER_EXPOSED_METHODS = [ 'addNetwork', 'disableRpcFailover', + 'disableRpcFailoverForced', 'enableRpcFailover', + 'enableRpcFailoverForced', 'findNetworkClientIdByChainId', 'get1559CompatibilityWithNetworkClientId', 'getEIP1559Compatibility', @@ -1392,6 +1394,15 @@ export class NetworkController extends BaseController< }, getIsRpcFailoverEnabled, ); + + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + (isRpcFailoverForced) => { + this.#updateRpcFailoverForced(isRpcFailoverForced); + }, + getIsRpcFailoverForced, + ); } /** @@ -1422,6 +1433,24 @@ export class NetworkController extends BaseController< this.#updateRpcFailoverEnabled(false); } + /** + * Forces RPC failover for Infura endpoints. When enabled, any Infura endpoint + * configured with failover URLs will route all traffic to those failover URLs, + * bypassing Infura entirely. Infura endpoints without failover URLs continue to + * use Infura. Custom endpoints are unaffected. + */ + enableRpcFailoverForced(): void { + this.#updateRpcFailoverForced(true); + } + + /** + * Stops forcing RPC failover for Infura endpoints, restoring the normal + * automatic-failover behavior governed by {@link enableRpcFailover}. + */ + disableRpcFailoverForced(): void { + this.#updateRpcFailoverForced(false); + } + /** * Enables or disables the RPC failover functionality, depending on the * boolean given. This is done by reconstructing all network clients that were @@ -1463,6 +1492,44 @@ export class NetworkController extends BaseController< this.#isRpcFailoverEnabled = newIsRpcFailoverEnabled; } + /** + * Enables or disables forced RPC failover, depending on the boolean given. + * This reconstructs all network clients that were configured with failover + * URLs so the new value takes effect. Network client IDs are preserved. + * + * @param newIsRpcFailoverForced - Whether or not to force RPC failover. + */ + #updateRpcFailoverForced(newIsRpcFailoverForced: boolean): void { + if (this.#isRpcFailoverForced === newIsRpcFailoverForced) { + return; + } + + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + + for (const networkClientsById of Object.values( + autoManagedNetworkClientRegistry, + )) { + for (const networkClientId of Object.keys(networkClientsById)) { + // Type assertion: We can assume that `networkClientId` is valid here. + const networkClient = + networkClientsById[ + networkClientId as keyof typeof networkClientsById + ]; + if ( + networkClient.configuration.failoverRpcUrls && + networkClient.configuration.failoverRpcUrls.length > 0 + ) { + newIsRpcFailoverForced + ? networkClient.enableRpcFailoverForced() + : networkClient.disableRpcFailoverForced(); + } + } + } + + this.#isRpcFailoverForced = newIsRpcFailoverForced; + } + /** * Accesses the provider and block tracker for the currently selected network. * @@ -1630,6 +1697,7 @@ export class NetworkController extends BaseController< init(): void { const state = this.messenger.call('RemoteFeatureFlagController:getState'); this.#updateRpcFailoverEnabled(getIsRpcFailoverEnabled(state)); + this.#updateRpcFailoverForced(getIsRpcFailoverForced(state)); this.#applyNetworkSelection(this.state.selectedNetworkClientId); } diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index e715e650aa..2586183f61 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1113,6 +1113,496 @@ describe('NetworkController', () => { }); }); + describe('enableRpcFailoverForced', () => { + describe('if the controller was initialized with isRpcFailoverForced = false', () => { + it('calls enableRpcFailoverForced on only the network clients whose RPC endpoints have configured failover URLs', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'enableRpcFailoverForced'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + isRpcFailoverForced: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + '0x300': buildCustomNetworkConfiguration({ + chainId: '0x300', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + failoverUrls: ['https://failover.endpoint/2'], + }), + ], + }), + }, + }, + }, + async ({ controller }) => { + controller.enableRpcFailoverForced(); + + expect(autoManagedNetworkClients).toHaveLength(3); + expect( + autoManagedNetworkClients[0].enableRpcFailoverForced, + ).not.toHaveBeenCalled(); + expect( + autoManagedNetworkClients[1].enableRpcFailoverForced, + ).toHaveBeenCalled(); + expect( + autoManagedNetworkClients[2].enableRpcFailoverForced, + ).toHaveBeenCalled(); + }, + ); + }); + }); + + describe('if the controller was initialized with isRpcFailoverForced = true', () => { + it('does not call createAutoManagedNetworkClient at all', async () => { + await withController( + { + isRpcFailoverForced: true, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + '0x300': buildCustomNetworkConfiguration({ + chainId: '0x300', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + failoverUrls: ['https://failover.endpoint/2'], + }), + ], + }), + }, + }, + }, + async ({ controller }) => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'enableRpcFailoverForced'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + controller.enableRpcFailoverForced(); + + expect(autoManagedNetworkClients).toHaveLength(0); + }, + ); + }); + }); + }); + + describe('disableRpcFailoverForced', () => { + describe('if the controller was initialized with isRpcFailoverForced = true', () => { + it('calls disableRpcFailoverForced on only the network clients whose RPC endpoints have configured failover URLs', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'disableRpcFailoverForced'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + isRpcFailoverForced: true, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + '0x300': buildCustomNetworkConfiguration({ + chainId: '0x300', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + failoverUrls: ['https://failover.endpoint/2'], + }), + ], + }), + }, + }, + }, + async ({ controller }) => { + controller.disableRpcFailoverForced(); + + expect(autoManagedNetworkClients).toHaveLength(3); + expect( + autoManagedNetworkClients[0].disableRpcFailoverForced, + ).not.toHaveBeenCalled(); + expect( + autoManagedNetworkClients[1].disableRpcFailoverForced, + ).toHaveBeenCalled(); + expect( + autoManagedNetworkClients[2].disableRpcFailoverForced, + ).toHaveBeenCalled(); + }, + ); + }); + }); + + describe('if the controller was initialized with isRpcFailoverForced = false', () => { + it('does not call createAutoManagedNetworkClient at all', async () => { + await withController( + { + isRpcFailoverForced: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + '0x300': buildCustomNetworkConfiguration({ + chainId: '0x300', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + failoverUrls: ['https://failover.endpoint/2'], + }), + ], + }), + }, + }, + }, + async ({ controller }) => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn( + autoManagedNetworkClient, + 'disableRpcFailoverForced', + ); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + controller.disableRpcFailoverForced(); + + expect(autoManagedNetworkClients).toHaveLength(0); + }, + ); + }); + }); + }); + + describe('RemoteFeatureFlagController:stateChange (isRpcFailoverForced)', () => { + it('calls enableRpcFailoverForced on clients with failover URLs when the flag turns true', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'enableRpcFailoverForced'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + isRpcFailoverForced: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + }, + }, + }, + async ({ messenger }) => { + messenger.publish( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + walletFrameworkRpcFailoverEnabled: false, + 'wallet-framework-rpc-failover-force-enabled': true, + }, + cacheTimestamp: 0, + }, + [], + ); + + expect(autoManagedNetworkClients).toHaveLength(2); + expect( + autoManagedNetworkClients[0].enableRpcFailoverForced, + ).not.toHaveBeenCalled(); + expect( + autoManagedNetworkClients[1].enableRpcFailoverForced, + ).toHaveBeenCalled(); + }, + ); + }); + + it('picks up the initial forced value during init()', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'enableRpcFailoverForced'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + isRpcFailoverForced: true, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: [], + }), + ], + }), + }, + }, + }, + async () => { + expect(autoManagedNetworkClients).toHaveLength(2); + expect( + autoManagedNetworkClients[0].enableRpcFailoverForced, + ).toHaveBeenCalled(); + expect( + autoManagedNetworkClients[1].enableRpcFailoverForced, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('calls enableRpcFailoverForced but not enableRpcFailover when only the forced flag is true', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'enableRpcFailover'); + jest.spyOn(autoManagedNetworkClient, 'enableRpcFailoverForced'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + isRpcFailoverEnabled: false, + isRpcFailoverForced: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + }, + }, + }, + async ({ messenger }) => { + messenger.publish( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + walletFrameworkRpcFailoverEnabled: false, + 'wallet-framework-rpc-failover-force-enabled': true, + }, + cacheTimestamp: 0, + }, + [], + ); + + expect(autoManagedNetworkClients).toHaveLength(1); + expect( + autoManagedNetworkClients[0].enableRpcFailoverForced, + ).toHaveBeenCalled(); + expect( + autoManagedNetworkClients[0].enableRpcFailover, + ).not.toHaveBeenCalled(); + }, + ); + }); + }); + describe('destroy', () => { it('does not throw if called before the provider is initialized', async () => { await withController(async ({ controller }) => { @@ -1677,6 +2167,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'base-mainnet': { blockTracker: expect.anything(), @@ -1692,6 +2184,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'bsc-mainnet': { blockTracker: expect.anything(), @@ -1707,6 +2201,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'linea-mainnet': { blockTracker: expect.anything(), @@ -1722,6 +2218,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'linea-sepolia': { blockTracker: expect.anything(), @@ -1737,6 +2235,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, mainnet: { blockTracker: expect.anything(), @@ -1752,6 +2252,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'megaeth-testnet': { blockTracker: expect.anything(), @@ -1766,6 +2268,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'megaeth-testnet-v2': { blockTracker: expect.anything(), @@ -1780,6 +2284,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'monad-mainnet': { blockTracker: expect.anything(), @@ -1795,6 +2301,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'monad-testnet': { blockTracker: expect.anything(), @@ -1809,6 +2317,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'optimism-mainnet': { blockTracker: expect.anything(), @@ -1824,6 +2334,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'polygon-mainnet': { blockTracker: expect.anything(), @@ -1839,6 +2351,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, sepolia: { blockTracker: expect.anything(), @@ -1854,6 +2368,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, }); }, @@ -1910,6 +2426,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, 'BBBB-BBBB-BBBB-BBBB': { blockTracker: expect.anything(), @@ -1924,6 +2442,8 @@ describe('NetworkController', () => { destroy: expect.any(Function), enableRpcFailover: expect.any(Function), disableRpcFailover: expect.any(Function), + enableRpcFailoverForced: expect.any(Function), + disableRpcFailoverForced: expect.any(Function), }, }); }, @@ -4568,6 +5088,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( @@ -4585,6 +5107,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( @@ -4602,6 +5126,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); const networkConfigurationsByNetworkClientId = @@ -6069,6 +6595,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); const networkConfigurationsByNetworkClientId = @@ -6385,6 +6913,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); expect( createAutoManagedNetworkClientSpy, @@ -6401,6 +6931,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); const networkConfigurationsByNetworkClientId = @@ -7378,6 +7910,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); const networkConfigurationsByNetworkClientId = getNetworkConfigurationsByNetworkClientId( @@ -8253,6 +8787,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( @@ -8270,6 +8806,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); @@ -9260,6 +9798,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); expect( getNetworkConfigurationsByNetworkClientId( @@ -10421,6 +10961,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); expect( createAutoManagedNetworkClientSpy, @@ -10437,6 +10979,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); const networkConfigurationsByNetworkClientId = @@ -11143,6 +11687,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ networkClientId: 'DDDD-DDDD-DDDD-DDDD', @@ -11157,6 +11703,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); expect( @@ -11878,6 +12426,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); expect( createAutoManagedNetworkClientSpy, @@ -11894,6 +12444,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }); const networkConfigurationsByChainId = @@ -12581,6 +13133,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( @@ -12598,6 +13152,8 @@ describe('NetworkController', () => { getBlockTrackerOptions, messenger: networkControllerMessenger, isRpcFailoverEnabled: true, + isRpcFailoverForced: false, + logger: undefined, }, ); diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index adfa80aeb9..f2f715054b 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -90,14 +90,17 @@ export const TESTNET = { * @param options.connectivityStatus - The connectivity status to return by default. * If not provided, defaults to Online. * @param options.isRpcFailoverEnabled - The RPC failover feature flag to return, defaults to false. + * @param options.isRpcFailoverForced - The forced RPC failover feature flag to return, defaults to false. * @returns The messenger. */ export function buildRootMessenger({ connectivityStatus = CONNECTIVITY_STATUSES.Online, isRpcFailoverEnabled = false, + isRpcFailoverForced = false, }: { connectivityStatus?: ConnectivityStatus; isRpcFailoverEnabled?: boolean; + isRpcFailoverForced?: boolean; } = {}): RootMessenger { const rootMessenger = new Messenger< MockAnyNamespace, @@ -117,6 +120,7 @@ export function buildRootMessenger({ () => ({ remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: isRpcFailoverEnabled, + 'wallet-framework-rpc-failover-force-enabled': isRpcFailoverForced, }, cacheTimestamp: 0, }), @@ -632,6 +636,7 @@ type WithControllerCallback = ({ type WithControllerOptions = Partial & { isRpcFailoverEnabled?: boolean; + isRpcFailoverForced?: boolean; initializeController?: boolean; }; @@ -655,10 +660,11 @@ export async function withController( const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; const { isRpcFailoverEnabled, + isRpcFailoverForced, initializeController = true, ...controllerOptions } = rest; - const messenger = buildRootMessenger({ isRpcFailoverEnabled }); + const messenger = buildRootMessenger({ isRpcFailoverEnabled, isRpcFailoverForced }); const networkControllerMessenger = buildNetworkControllerMessenger(messenger); const controller = new NetworkController({ messenger: networkControllerMessenger, From 9beb97d5da8b844487e226330769aab409d6efd2 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 18:45:53 +0200 Subject: [PATCH 06/16] fix(network-controller): export forced failover action types Export NetworkControllerEnableRpcFailoverForcedAction and NetworkControllerDisableRpcFailoverForcedAction, and update the init JSDoc to mention both failover flags. --- packages/network-controller/src/NetworkController.ts | 5 +++-- packages/network-controller/src/index.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 2007f4ebfd..86d22b555f 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1691,8 +1691,9 @@ export class NetworkController extends BaseController< } /** - * Initialize the NetworkController, updating the RPC failover feature flag - * and applying the network selection. + * Initialize the NetworkController, updating the RPC failover feature flags + * (`isRpcFailoverEnabled` and `isRpcFailoverForced`) and applying the network + * selection. */ init(): void { const state = this.messenger.call('RemoteFeatureFlagController:getState'); diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 9b9179fc8a..6cf60d6a52 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -71,6 +71,8 @@ export type { NetworkControllerUpdateNetworkAction, NetworkControllerEnableRpcFailoverAction, NetworkControllerDisableRpcFailoverAction, + NetworkControllerEnableRpcFailoverForcedAction, + NetworkControllerDisableRpcFailoverForcedAction, NetworkControllerGetProviderAndBlockTrackerAction, NetworkControllerGetNetworkClientRegistryAction, NetworkControllerLookupNetworkAction, From 938e1bde5387cf434922cdd73baf9f8ec47c67f7 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 18:47:49 +0200 Subject: [PATCH 07/16] docs(network-controller): changelog for forced RPC failover --- packages/network-controller/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index a203ae6398..5d829cb9e0 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default `retryTimeout` for the block tracker is now `20` seconds. - Add `failoverUrls` constructor argument ([#9140](https://github.com/MetaMask/core/pull/9140)) - These will override `failoverUrls` from state during network client creation. +- Add forced RPC failover for Infura endpoints, driven by the `wallet-framework-rpc-failover-force-enabled` remote feature flag ([#9170](https://github.com/MetaMask/core/pull/9170)) + - When enabled, Infura endpoints configured with failover URLs route all traffic to those failover URLs, bypassing Infura entirely. Infura endpoints without failover URLs continue to use Infura, and custom RPC endpoints are unaffected. + - Adds the `NetworkController.enableRpcFailoverForced` and `NetworkController.disableRpcFailoverForced` methods, along with the `NetworkControllerEnableRpcFailoverForcedAction` and `NetworkControllerDisableRpcFailoverForcedAction` messenger actions. ### Changed From ba7ad247bc57e573a73e486961573bfd55d6c31d Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 19:08:33 +0200 Subject: [PATCH 08/16] docs(network-controller): point changelog at the real PR number --- packages/network-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 5d829cb9e0..b4a59af0ef 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default `retryTimeout` for the block tracker is now `20` seconds. - Add `failoverUrls` constructor argument ([#9140](https://github.com/MetaMask/core/pull/9140)) - These will override `failoverUrls` from state during network client creation. -- Add forced RPC failover for Infura endpoints, driven by the `wallet-framework-rpc-failover-force-enabled` remote feature flag ([#9170](https://github.com/MetaMask/core/pull/9170)) +- Add forced RPC failover for Infura endpoints, driven by the `wallet-framework-rpc-failover-force-enabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) - When enabled, Infura endpoints configured with failover URLs route all traffic to those failover URLs, bypassing Infura entirely. Infura endpoints without failover URLs continue to use Infura, and custom RPC endpoints are unaffected. - Adds the `NetworkController.enableRpcFailoverForced` and `NetworkController.disableRpcFailoverForced` methods, along with the `NetworkControllerEnableRpcFailoverForcedAction` and `NetworkControllerDisableRpcFailoverForcedAction` messenger actions. From 51ec73529bfaf448130cb679f016b2d75b7e4e23 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 19:18:08 +0200 Subject: [PATCH 09/16] docs(network-controller): note positional primary under forced failover --- packages/network-controller/src/create-network-client.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 9a4bd5196d..2ed0b99f64 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -285,6 +285,9 @@ function createRpcServiceChain({ if (isRpcFailoverForced && isInfuraEndpoint && failoverEndpoints.length > 0) { // Force flag is on for an Infura endpoint with failovers: bypass Infura // entirely and route all traffic (including block polling) to failovers. + // The first failover becomes the positional primary of the chain, so + // availability/degraded events will report that failover URL as the + // primary endpoint (there is no Infura primary in this mode). availableEndpoints = failoverEndpoints; } else if (isRpcFailoverEnabled) { availableEndpoints = [ From 4bc51fef9caf14eac34fd93f42fff8ae833efdf5 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 19:27:28 +0200 Subject: [PATCH 10/16] fix: run lint:misc --- .../network-controller/src/create-network-client.ts | 10 ++++++---- packages/network-controller/tests/helpers.ts | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 2ed0b99f64..078ebea5d6 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -275,10 +275,12 @@ function createRpcServiceChain({ isRpcFailoverForced: boolean; logger?: Logger; }): RpcServiceChain { - const failoverEndpoints = (configuration.failoverRpcUrls ?? []).map((url) => ({ - url, - isFailover: true, - })); + const failoverEndpoints = (configuration.failoverRpcUrls ?? []).map( + (url) => ({ + url, + isFailover: true, + }), + ); const isInfuraEndpoint = configuration.type === NetworkClientType.Infura; let availableEndpoints: { url: string; isFailover: boolean }[]; diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index f2f715054b..5982b6646f 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -664,7 +664,10 @@ export async function withController( initializeController = true, ...controllerOptions } = rest; - const messenger = buildRootMessenger({ isRpcFailoverEnabled, isRpcFailoverForced }); + const messenger = buildRootMessenger({ + isRpcFailoverEnabled, + isRpcFailoverForced, + }); const networkControllerMessenger = buildNetworkControllerMessenger(messenger); const controller = new NetworkController({ messenger: networkControllerMessenger, From 18ef4dcb4423ee4beb180cd377213c63809c5473 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 19:32:55 +0200 Subject: [PATCH 11/16] fix: messenger types --- .../src/NetworkController-method-action-types.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/network-controller/src/NetworkController-method-action-types.ts b/packages/network-controller/src/NetworkController-method-action-types.ts index a1cd8f33c9..45d5e94499 100644 --- a/packages/network-controller/src/NetworkController-method-action-types.ts +++ b/packages/network-controller/src/NetworkController-method-action-types.ts @@ -39,7 +39,8 @@ export type NetworkControllerDisableRpcFailoverAction = { /** * Forces RPC failover for Infura endpoints. When enabled, any Infura endpoint * configured with failover URLs will route all traffic to those failover URLs, - * bypassing Infura entirely. + * bypassing Infura entirely. Infura endpoints without failover URLs continue to + * use Infura. Custom endpoints are unaffected. */ export type NetworkControllerEnableRpcFailoverForcedAction = { type: `NetworkController:enableRpcFailoverForced`; @@ -47,8 +48,8 @@ export type NetworkControllerEnableRpcFailoverForcedAction = { }; /** - * Stops forcing RPC failover for Infura endpoints, restoring normal - * automatic-failover behavior. + * Stops forcing RPC failover for Infura endpoints, restoring the normal + * automatic-failover behavior governed by {@link enableRpcFailover}. */ export type NetworkControllerDisableRpcFailoverForcedAction = { type: `NetworkController:disableRpcFailoverForced`; From fe94fab2b117aed5889c35a2cdd1fe0d027007da Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 19:41:39 +0200 Subject: [PATCH 12/16] fix: remove no-restricted-syntax --- packages/network-controller/tests/NetworkController.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 2586183f61..c006436493 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1456,7 +1456,6 @@ describe('NetworkController', () => { }, async ({ messenger }) => { messenger.publish( - // eslint-disable-next-line no-restricted-syntax 'RemoteFeatureFlagController:stateChange', { remoteFeatureFlags: { @@ -1579,7 +1578,6 @@ describe('NetworkController', () => { }, async ({ messenger }) => { messenger.publish( - // eslint-disable-next-line no-restricted-syntax 'RemoteFeatureFlagController:stateChange', { remoteFeatureFlags: { From 0bd55665a0fbcc8f1458075a922dc92d0b1ca162 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 17 Jun 2026 21:38:38 +0200 Subject: [PATCH 13/16] refactor(network-controller): rename force-failover flag to core-platform prefix Rename the remote flag key from wallet-framework-rpc-failover-force-enabled to core-platform-rpc-failover-force-enabled to match the team's new name. --- packages/network-controller/CHANGELOG.md | 2 +- packages/network-controller/src/selectors.test.ts | 6 +++--- packages/network-controller/src/selectors.ts | 2 +- packages/network-controller/tests/NetworkController.test.ts | 4 ++-- packages/network-controller/tests/helpers.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index b4a59af0ef..9653ce90a4 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default `retryTimeout` for the block tracker is now `20` seconds. - Add `failoverUrls` constructor argument ([#9140](https://github.com/MetaMask/core/pull/9140)) - These will override `failoverUrls` from state during network client creation. -- Add forced RPC failover for Infura endpoints, driven by the `wallet-framework-rpc-failover-force-enabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) +- Add forced RPC failover for Infura endpoints, driven by the `core-platform-rpc-failover-force-enabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) - When enabled, Infura endpoints configured with failover URLs route all traffic to those failover URLs, bypassing Infura entirely. Infura endpoints without failover URLs continue to use Infura, and custom RPC endpoints are unaffected. - Adds the `NetworkController.enableRpcFailoverForced` and `NetworkController.disableRpcFailoverForced` methods, along with the `NetworkControllerEnableRpcFailoverForcedAction` and `NetworkControllerDisableRpcFailoverForcedAction` messenger actions. diff --git a/packages/network-controller/src/selectors.test.ts b/packages/network-controller/src/selectors.test.ts index f75f010bb6..232d82c41a 100644 --- a/packages/network-controller/src/selectors.test.ts +++ b/packages/network-controller/src/selectors.test.ts @@ -4,7 +4,7 @@ describe('getIsRpcFailoverForced', () => { it('returns true when the flag is true', () => { const state = { remoteFeatureFlags: { - 'wallet-framework-rpc-failover-force-enabled': true, + 'core-platform-rpc-failover-force-enabled': true, }, cacheTimestamp: 0, }; @@ -14,7 +14,7 @@ describe('getIsRpcFailoverForced', () => { it('returns false when the flag is false', () => { const state = { remoteFeatureFlags: { - 'wallet-framework-rpc-failover-force-enabled': false, + 'core-platform-rpc-failover-force-enabled': false, }, cacheTimestamp: 0, }; @@ -29,7 +29,7 @@ describe('getIsRpcFailoverForced', () => { it('passes through non-boolean values without coercion', () => { const state = { remoteFeatureFlags: { - 'wallet-framework-rpc-failover-force-enabled': 'yes', + 'core-platform-rpc-failover-force-enabled': 'yes', }, cacheTimestamp: 0, }; diff --git a/packages/network-controller/src/selectors.ts b/packages/network-controller/src/selectors.ts index af08bb4d99..8c6afa6f12 100644 --- a/packages/network-controller/src/selectors.ts +++ b/packages/network-controller/src/selectors.ts @@ -12,7 +12,7 @@ export function getIsRpcFailoverForced( state: RemoteFeatureFlagControllerState, ): boolean { const forceEnabled = state.remoteFeatureFlags[ - 'wallet-framework-rpc-failover-force-enabled' + 'core-platform-rpc-failover-force-enabled' ] as boolean | undefined; return forceEnabled ?? false; } diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index c006436493..c22f0671c2 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1460,7 +1460,7 @@ describe('NetworkController', () => { { remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: false, - 'wallet-framework-rpc-failover-force-enabled': true, + 'core-platform-rpc-failover-force-enabled': true, }, cacheTimestamp: 0, }, @@ -1582,7 +1582,7 @@ describe('NetworkController', () => { { remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: false, - 'wallet-framework-rpc-failover-force-enabled': true, + 'core-platform-rpc-failover-force-enabled': true, }, cacheTimestamp: 0, }, diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index 5982b6646f..45a233e547 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -120,7 +120,7 @@ export function buildRootMessenger({ () => ({ remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: isRpcFailoverEnabled, - 'wallet-framework-rpc-failover-force-enabled': isRpcFailoverForced, + 'core-platform-rpc-failover-force-enabled': isRpcFailoverForced, }, cacheTimestamp: 0, }), From bc8dce770b08098cc15ea23a21ae6ad68269538e Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 22 Jun 2026 12:28:23 +0200 Subject: [PATCH 14/16] fix: changelog --- packages/network-controller/CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index a03f475e42..d5b2ac79a0 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add forced RPC failover for Infura endpoints, driven by the `core-platform-rpc-failover-force-enabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) + - When enabled, Infura endpoints configured with failover URLs route all traffic to those failover URLs, bypassing Infura entirely. Infura endpoints without failover URLs continue to use Infura, and custom RPC endpoints are unaffected. + - Adds the `NetworkController.enableRpcFailoverForced` and `NetworkController.disableRpcFailoverForced` methods, along with the `NetworkControllerEnableRpcFailoverForcedAction` and `NetworkControllerDisableRpcFailoverForcedAction` messenger actions. + ## [33.0.0] ### Added @@ -20,9 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default `retryTimeout` for the block tracker is now `20` seconds. - Add `failoverUrls` constructor argument ([#9140](https://github.com/MetaMask/core/pull/9140)) - These will override `failoverUrls` from state during network client creation. -- Add forced RPC failover for Infura endpoints, driven by the `core-platform-rpc-failover-force-enabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) - - When enabled, Infura endpoints configured with failover URLs route all traffic to those failover URLs, bypassing Infura entirely. Infura endpoints without failover URLs continue to use Infura, and custom RPC endpoints are unaffected. - - Adds the `NetworkController.enableRpcFailoverForced` and `NetworkController.disableRpcFailoverForced` methods, along with the `NetworkControllerEnableRpcFailoverForcedAction` and `NetworkControllerDisableRpcFailoverForcedAction` messenger actions. ### Changed @@ -1223,7 +1226,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release + - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: + - Everything in `src/network` (minus `NetworkType` and `NetworksChainId`, which were placed in `@metamask/controller-utils`) All changes listed after this point were applied to this package following the monorepo conversion. From 2a256aff24916c7c641b35c27c47ee6ea0f15d10 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 22 Jun 2026 12:39:57 +0200 Subject: [PATCH 15/16] fix: lint:misc --- packages/network-controller/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index d5b2ac79a0..3bcecdfc30 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -1226,9 +1226,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release - - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - - Everything in `src/network` (minus `NetworkType` and `NetworksChainId`, which were placed in `@metamask/controller-utils`) All changes listed after this point were applied to this package following the monorepo conversion. From 1aec6fea9aa6b63982eb630ec175529485827b7b Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 23 Jun 2026 18:12:50 +0200 Subject: [PATCH 16/16] fix(network-controller): read forced failover flag as camelCase key The ClientConfigAPI delivers remote feature flags to clients in camelCase, so the selector must read corePlatformRpcFailoverForceEnabled (matching the sibling walletFrameworkRpcFailoverEnabled), not the kebab-case LaunchDarkly name. --- packages/network-controller/CHANGELOG.md | 2 +- packages/network-controller/src/selectors.test.ts | 6 +++--- packages/network-controller/src/selectors.ts | 7 +++---- .../network-controller/tests/NetworkController.test.ts | 4 ++-- packages/network-controller/tests/helpers.ts | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 3bcecdfc30..f86b17a74d 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add forced RPC failover for Infura endpoints, driven by the `core-platform-rpc-failover-force-enabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) +- Add forced RPC failover for Infura endpoints, driven by the `corePlatformRpcFailoverForceEnabled` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) - When enabled, Infura endpoints configured with failover URLs route all traffic to those failover URLs, bypassing Infura entirely. Infura endpoints without failover URLs continue to use Infura, and custom RPC endpoints are unaffected. - Adds the `NetworkController.enableRpcFailoverForced` and `NetworkController.disableRpcFailoverForced` methods, along with the `NetworkControllerEnableRpcFailoverForcedAction` and `NetworkControllerDisableRpcFailoverForcedAction` messenger actions. diff --git a/packages/network-controller/src/selectors.test.ts b/packages/network-controller/src/selectors.test.ts index 232d82c41a..d5a7c9261a 100644 --- a/packages/network-controller/src/selectors.test.ts +++ b/packages/network-controller/src/selectors.test.ts @@ -4,7 +4,7 @@ describe('getIsRpcFailoverForced', () => { it('returns true when the flag is true', () => { const state = { remoteFeatureFlags: { - 'core-platform-rpc-failover-force-enabled': true, + corePlatformRpcFailoverForceEnabled: true, }, cacheTimestamp: 0, }; @@ -14,7 +14,7 @@ describe('getIsRpcFailoverForced', () => { it('returns false when the flag is false', () => { const state = { remoteFeatureFlags: { - 'core-platform-rpc-failover-force-enabled': false, + corePlatformRpcFailoverForceEnabled: false, }, cacheTimestamp: 0, }; @@ -29,7 +29,7 @@ describe('getIsRpcFailoverForced', () => { it('passes through non-boolean values without coercion', () => { const state = { remoteFeatureFlags: { - 'core-platform-rpc-failover-force-enabled': 'yes', + corePlatformRpcFailoverForceEnabled: 'yes', }, cacheTimestamp: 0, }; diff --git a/packages/network-controller/src/selectors.ts b/packages/network-controller/src/selectors.ts index 8c6afa6f12..9dc157dfa5 100644 --- a/packages/network-controller/src/selectors.ts +++ b/packages/network-controller/src/selectors.ts @@ -11,8 +11,7 @@ export function getIsRpcFailoverEnabled( export function getIsRpcFailoverForced( state: RemoteFeatureFlagControllerState, ): boolean { - const forceEnabled = state.remoteFeatureFlags[ - 'core-platform-rpc-failover-force-enabled' - ] as boolean | undefined; - return forceEnabled ?? false; + const corePlatformRpcFailoverForceEnabled = state.remoteFeatureFlags + .corePlatformRpcFailoverForceEnabled as boolean | undefined; + return corePlatformRpcFailoverForceEnabled ?? false; } diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 0d3175f0cb..d763cbace0 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1442,7 +1442,7 @@ describe('NetworkController', () => { { remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: false, - 'core-platform-rpc-failover-force-enabled': true, + corePlatformRpcFailoverForceEnabled: true, }, cacheTimestamp: 0, }, @@ -1564,7 +1564,7 @@ describe('NetworkController', () => { { remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: false, - 'core-platform-rpc-failover-force-enabled': true, + corePlatformRpcFailoverForceEnabled: true, }, cacheTimestamp: 0, }, diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index 45a233e547..f3a95e58e6 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -120,7 +120,7 @@ export function buildRootMessenger({ () => ({ remoteFeatureFlags: { walletFrameworkRpcFailoverEnabled: isRpcFailoverEnabled, - 'core-platform-rpc-failover-force-enabled': isRpcFailoverForced, + corePlatformRpcFailoverForceEnabled: isRpcFailoverForced, }, cacheTimestamp: 0, }),