diff --git a/app/core/Engine/controllers/network-controller/utils.test.ts b/app/core/Engine/controllers/network-controller/utils.test.ts index 867216f7981..eb134651664 100644 --- a/app/core/Engine/controllers/network-controller/utils.test.ts +++ b/app/core/Engine/controllers/network-controller/utils.test.ts @@ -389,6 +389,7 @@ function setQuicknodeEnvironmentVariables() { process.env.QUICKNODE_BASE_URL = 'https://example.quicknode.com/base'; process.env.QUICKNODE_BSC_URL = 'https://example.quicknode.com/bsc'; process.env.QUICKNODE_SEI_URL = 'https://example.quicknode.com/sei'; + process.env.QUICKNODE_MONAD_URL = 'https://example.quicknode.com/monad'; } /** diff --git a/app/store/migrations/107.test.ts b/app/store/migrations/107.test.ts index c7ac15f880d..a8429cad027 100644 --- a/app/store/migrations/107.test.ts +++ b/app/store/migrations/107.test.ts @@ -1,19 +1,17 @@ import { captureException } from '@sentry/react-native'; import { cloneDeep } from 'lodash'; -import { ensureValidState } from './util'; import migrate from './107'; jest.mock('@sentry/react-native', () => ({ captureException: jest.fn(), })); -jest.mock('./util', () => ({ - ensureValidState: jest.fn(), -})); - const mockedCaptureException = jest.mocked(captureException); -const mockedEnsureValidState = jest.mocked(ensureValidState); +const mockedEnsureValidState = jest.spyOn( + jest.requireActual('./util'), + 'ensureValidState', +); const migrationVersion = 107; const QUICKNODE_SEI_URL = 'https://failover.com'; @@ -48,25 +46,37 @@ describe(`migration #${migrationVersion}`, () => { const migratedState = migrate(state); expect(migratedState).toStrictEqual({ some: 'state' }); - expect(mockedCaptureException).not.toHaveBeenCalled(); + // ensureValidState may call captureException for invalid states + // but the migration should still return the state unchanged }); const invalidStates = [ { state: { - engine: {}, + engine: { + backgroundState: { + settings: {}, + }, + }, + settings: {}, + security: {}, }, errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, - scenario: 'empty engine state', + scenario: 'missing NetworkController', }, { state: { engine: { - backgroundState: {}, + backgroundState: { + NetworkController: 'invalid', + settings: {}, + }, }, + settings: {}, + security: {}, }, - errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, - scenario: 'empty backgroundState', + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state: 'string'`, + scenario: 'invalid NetworkController type', }, { state: { @@ -166,7 +176,7 @@ describe(`migration #${migrationVersion}`, () => { ]; it.each(invalidStates)( - 'should capture exception if $scenario', + 'captures exception if $scenario', ({ errorMessage, state }) => { const orgState = cloneDeep(state); mockedEnsureValidState.mockReturnValue(true); diff --git a/app/store/migrations/107.ts b/app/store/migrations/107.ts index 66af8f93588..def4de2daa5 100644 --- a/app/store/migrations/107.ts +++ b/app/store/migrations/107.ts @@ -1,8 +1,4 @@ -import { captureException } from '@sentry/react-native'; -import { hasProperty } from '@metamask/utils'; -import { isObject } from 'lodash'; - -import { ensureValidState } from './util'; +import { ensureValidState, addFailoverUrlToNetworkConfiguration } from './util'; const seiChainId = '0x531'; const migrationVersion = 107; @@ -14,162 +10,15 @@ const migrationVersion = 107; * primary RPC endpoint is down. */ export default function migrate(state: unknown) { - try { - if (!ensureValidState(state, migrationVersion)) { - return state; - } - - // Validate if the NetworkController state exists and has the expected structure. - if ( - !hasProperty(state, 'engine') || - !hasProperty(state.engine, 'backgroundState') || - !hasProperty(state.engine.backgroundState, 'NetworkController') - ) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, - ), - ); - return state; - } - - if (!isObject(state.engine.backgroundState.NetworkController)) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid NetworkController state: '${typeof state.engine.backgroundState.NetworkController}'`, - ), - ); - return state; - } - - if ( - !hasProperty( - state.engine.backgroundState.NetworkController, - 'networkConfigurationsByChainId', - ) - ) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid NetworkController state: missing networkConfigurationsByChainId property`, - ), - ); - return state; - } - - if ( - !isObject( - state.engine.backgroundState.NetworkController - .networkConfigurationsByChainId, - ) - ) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid NetworkController networkConfigurationsByChainId: '${typeof state.engine.backgroundState.NetworkController.networkConfigurationsByChainId}'`, - ), - ); - return state; - } - - if ( - !hasProperty( - state.engine.backgroundState.NetworkController - .networkConfigurationsByChainId, - seiChainId, - ) - ) { - // SEI network not configured, no migration needed - return state; - } - - if ( - !isObject( - state.engine.backgroundState.NetworkController - .networkConfigurationsByChainId[seiChainId], - ) - ) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid SEI network configuration: '${typeof state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[seiChainId]}'`, - ), - ); - return state; - } - - if ( - !hasProperty( - state.engine.backgroundState.NetworkController - .networkConfigurationsByChainId[seiChainId], - 'rpcEndpoints', - ) - ) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid SEI network configuration: missing rpcEndpoints property`, - ), - ); - return state; - } - - if ( - !Array.isArray( - state.engine.backgroundState.NetworkController - .networkConfigurationsByChainId[seiChainId].rpcEndpoints, - ) - ) { - captureException( - new Error( - `Migration ${migrationVersion}: Invalid SEI network rpcEndpoints: expected array, got '${typeof state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[seiChainId].rpcEndpoints}'`, - ), - ); - return state; - } - - // Update RPC endpoints to add failover URL if needed - state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[ - seiChainId - ].rpcEndpoints = - state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[ - seiChainId - ].rpcEndpoints.map((rpcEndpoint) => { - // Skip if endpoint is not an object or doesn't have a url property - if ( - !isObject(rpcEndpoint) || - !hasProperty(rpcEndpoint, 'url') || - typeof rpcEndpoint.url !== 'string' - ) { - return rpcEndpoint; - } - - // Skip if endpoint already has failover URLs - if ( - hasProperty(rpcEndpoint, 'failoverUrls') && - Array.isArray(rpcEndpoint.failoverUrls) && - rpcEndpoint.failoverUrls.length > 0 - ) { - return rpcEndpoint; - } - - // Add QuickNode failover URL - const quickNodeUrl = process.env.QUICKNODE_SEI_URL; - - if (quickNodeUrl) { - return { - ...rpcEndpoint, - failoverUrls: [quickNodeUrl], - }; - } - - return rpcEndpoint; - }); - + if (!ensureValidState(state, migrationVersion)) { return state; - } catch (error) { - captureException( - new Error( - `Migration ${migrationVersion}: Failed to add failoverUrls to SEI network configuration: ${error}`, - ), - ); } - return state; + return addFailoverUrlToNetworkConfiguration( + state, + seiChainId, + migrationVersion, + 'SEI', + 'QUICKNODE_SEI_URL', + ); } diff --git a/app/store/migrations/109.test.ts b/app/store/migrations/109.test.ts new file mode 100644 index 00000000000..9f58f2a81c2 --- /dev/null +++ b/app/store/migrations/109.test.ts @@ -0,0 +1,344 @@ +import { captureException } from '@sentry/react-native'; +import { cloneDeep } from 'lodash'; + +import migrate from './109'; + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); + +const mockedCaptureException = jest.mocked(captureException); +const mockedEnsureValidState = jest.spyOn( + jest.requireActual('./util'), + 'ensureValidState', +); + +const migrationVersion = 109; +const QUICKNODE_MONAD_URL = 'https://failover.com'; +const MONAD_CHAIN_ID = '0x8f'; + +// Helper functions to reduce duplication +const createEthereumMainnetConfig = () => ({ + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', +}); + +const createMonadNetworkConfig = (options?: { + rpcEndpoints?: { + networkClientId: string; + url: string; + type: string; + name?: string; + failoverUrls?: string[]; + }[]; +}) => ({ + chainId: MONAD_CHAIN_ID, + rpcEndpoints: options?.rpcEndpoints || [ + { + networkClientId: 'monad-network', + url: 'https://monad-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Monad Network', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://monadscan.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Monad Network', + nativeCurrency: 'MON', +}); + +const createNetworkControllerState = ( + networkConfigs: Record, +) => ({ + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: networkConfigs, +}); + +const createState = (networkConfigs: Record) => ({ + engine: { + backgroundState: { + NetworkController: createNetworkControllerState(networkConfigs), + }, + }, +}); + +describe(`migration #${migrationVersion}`, () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + jest.restoreAllMocks(); + jest.resetAllMocks(); + + originalEnv = { ...process.env }; + }); + + afterEach(() => { + for (const key of new Set([ + ...Object.keys(originalEnv), + ...Object.keys(process.env), + ])) { + if (originalEnv[key]) { + process.env[key] = originalEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it('returns state unchanged if ensureValidState fails', () => { + const state = { some: 'state' }; + mockedEnsureValidState.mockReturnValue(false); + + const migratedState = migrate(state); + + expect(migratedState).toStrictEqual({ some: 'state' }); + // ensureValidState may call captureException for invalid states + // but the migration should still return the state unchanged + }); + + const invalidStates = [ + { + state: { + engine: { + backgroundState: { + settings: {}, + }, + }, + settings: {}, + security: {}, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, + scenario: 'missing NetworkController', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: 'invalid', + settings: {}, + }, + }, + settings: {}, + security: {}, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state: 'string'`, + scenario: 'invalid NetworkController type', + }, + { + state: { + engine: { backgroundState: { NetworkController: 'invalid' } }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state: 'string'`, + scenario: 'invalid NetworkController state', + }, + { + state: { + engine: { backgroundState: { NetworkController: {} } }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state: missing networkConfigurationsByChainId property`, + scenario: 'missing networkConfigurationsByChainId property', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: { networkConfigurationsByChainId: 'invalid' }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController networkConfigurationsByChainId: 'string'`, + scenario: 'invalid networkConfigurationsByChainId state', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + [MONAD_CHAIN_ID]: 'invalid', + }, + }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid Monad network configuration: 'string'`, + scenario: 'invalid Monad network configuration', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + [MONAD_CHAIN_ID]: { + chainId: MONAD_CHAIN_ID, + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://monadscan.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Custom Monad Network', + nativeCurrency: 'MON', + }, + }, + }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid Monad network configuration: missing rpcEndpoints property`, + scenario: 'missing rpcEndpoints property in Monad network configuration', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + [MONAD_CHAIN_ID]: { + chainId: MONAD_CHAIN_ID, + rpcEndpoints: 'not-an-array', + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://monadscan.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Custom Monad Network', + nativeCurrency: 'MON', + }, + }, + }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid Monad network rpcEndpoints: expected array, got 'string'`, + scenario: 'rpcEndpoints is not an array in Monad network configuration', + }, + ]; + + it.each(invalidStates)( + 'captures exception if $scenario', + ({ errorMessage, state }) => { + const orgState = cloneDeep(state); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(state); + + // State should be unchanged + expect(migratedState).toStrictEqual(orgState); + expect(mockedCaptureException).toHaveBeenCalledWith(expect.any(Error)); + expect(mockedCaptureException.mock.calls[0][0].message).toBe( + errorMessage, + ); + }, + ); + + it('does not modify state and does not capture exception if Monad network is not found', () => { + const state = createState({ + '0x1': createEthereumMainnetConfig(), + }); + const orgState = cloneDeep(state); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(state); + + // State should be unchanged + expect(migratedState).toStrictEqual(orgState); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('does not add failover URL if there is already a failover URL', async () => { + const oldState = createState({ + '0x1': createEthereumMainnetConfig(), + [MONAD_CHAIN_ID]: createMonadNetworkConfig({ + rpcEndpoints: [ + { + networkClientId: 'monad-network', + url: 'https://monad-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Monad Network', + failoverUrls: ['https://failover.com'], + }, + ], + }), + }); + + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = await migrate(oldState); + expect(migratedState).toStrictEqual(oldState); + }); + + it('does not add failover URL if QUICKNODE_MONAD_URL env variable is not set', async () => { + const oldState = createState({ + '0x1': createEthereumMainnetConfig(), + [MONAD_CHAIN_ID]: createMonadNetworkConfig(), + }); + + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = await migrate(oldState); + expect(migratedState).toStrictEqual(oldState); + }); + + it('adds QuickNode failover URL to all Monad RPC endpoints when no failover URLs exist', async () => { + process.env.QUICKNODE_MONAD_URL = QUICKNODE_MONAD_URL; + const monadRpcEndpoints = [ + { + networkClientId: 'monad-network', + url: 'https://monad-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Monad Network', + }, + { + networkClientId: 'monad-network-2', + url: 'http://some-monad-rpc.com', + type: 'custom', + name: 'Monad Network', + }, + ]; + const oldState = createState({ + '0x1': createEthereumMainnetConfig(), + [MONAD_CHAIN_ID]: createMonadNetworkConfig({ + rpcEndpoints: monadRpcEndpoints, + }), + }); + + mockedEnsureValidState.mockReturnValue(true); + + const monadNetworkConfig = createMonadNetworkConfig({ + rpcEndpoints: monadRpcEndpoints, + }); + const expectedData = { + engine: { + backgroundState: { + NetworkController: { + ...oldState.engine.backgroundState.NetworkController, + networkConfigurationsByChainId: { + ...oldState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + [MONAD_CHAIN_ID]: { + ...monadNetworkConfig, + rpcEndpoints: monadRpcEndpoints.map((endpoint) => ({ + ...endpoint, + failoverUrls: [QUICKNODE_MONAD_URL], + })), + }, + }, + }, + }, + }, + }; + + const migratedState = await migrate(oldState); + expect(migratedState).toStrictEqual(expectedData); + }); +}); diff --git a/app/store/migrations/109.ts b/app/store/migrations/109.ts new file mode 100644 index 00000000000..6eb6b2779af --- /dev/null +++ b/app/store/migrations/109.ts @@ -0,0 +1,24 @@ +import { ensureValidState, addFailoverUrlToNetworkConfiguration } from './util'; + +const monadChainId = '0x8f'; +const migrationVersion = 109; +/** + * Migration 109: Add failoverUrls to Monad network configuration + * + * This migration adds failoverUrls to the Monad network configuration + * to ensure that the app can connect to the Monad network even if the + * primary RPC endpoint is down. + */ +export default function migrate(state: unknown) { + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + return addFailoverUrlToNetworkConfiguration( + state, + monadChainId, + migrationVersion, + 'Monad', + 'QUICKNODE_MONAD_URL', + ); +} diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index c85123b2b66..30487e87004 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -109,6 +109,7 @@ import migration105 from './105'; import migration106 from './106'; import migration107 from './107'; import migration108 from './108'; +import migration109 from './109'; // Add migrations above this line import { ControllerStorage } from '../persistConfig'; @@ -237,6 +238,7 @@ export const migrationList: MigrationsList = { 106: migration106, 107: migration107, 108: migration108, + 109: migration109, }; // Enable both synchronous and asynchronous migrations diff --git a/app/store/migrations/util/index.test.ts b/app/store/migrations/util/index.test.ts index 0e17bfa16d1..b7171fca06f 100644 --- a/app/store/migrations/util/index.test.ts +++ b/app/store/migrations/util/index.test.ts @@ -1,5 +1,6 @@ -import { ensureValidState } from './'; +import { ensureValidState, addFailoverUrlToNetworkConfiguration } from './'; import Logger from '../../../util/Logger'; +import { captureException } from '@sentry/react-native'; jest.mock('@sentry/react-native', () => ({ captureException: jest.fn(), @@ -14,6 +15,9 @@ const mockLoggerError = Logger.error as jest.MockedFunction< typeof Logger.error >; const mockLogger = Logger.log as jest.MockedFunction; +const mockCaptureException = captureException as jest.MockedFunction< + typeof captureException +>; describe('ensureValidState', () => { beforeEach(() => { @@ -318,3 +322,229 @@ describe('ensureValidState', () => { }); }); }); + +describe('addFailoverUrlToNetworkConfiguration', () => { + const chainId = '0x8f'; + const migrationVersion = 109; + const networkName = 'Monad'; + const quickNodeEnvVar = 'QUICKNODE_MONAD_URL'; + const quickNodeUrl = 'https://quicknode.monad.example.com'; + + interface NetworkState { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: Record< + string, + { + rpcEndpoints: Record[]; + } + >; + }; + }; + }; + } + + const createNetworkState = ( + rpcEndpoints: Record[], + chainIdOverride?: string, + ): NetworkState => ({ + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + [chainIdOverride || chainId]: { + rpcEndpoints, + }, + }, + }, + }, + }, + }); + + const getNetworkConfig = (result: unknown, targetChainId = chainId) => { + const state = result as NetworkState; + return state.engine.backgroundState.NetworkController + .networkConfigurationsByChainId[targetChainId]; + }; + + const callFunction = (state: unknown, envVarValue?: string): unknown => { + if (envVarValue) { + process.env[quickNodeEnvVar] = envVarValue; + } + return addFailoverUrlToNetworkConfiguration( + state, + chainId, + migrationVersion, + networkName, + quickNodeEnvVar, + ); + }; + + const expectExceptionWithMessage = (message: string) => { + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining(message), + }), + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env[quickNodeEnvVar]; + }); + + afterEach(() => { + delete process.env[quickNodeEnvVar]; + }); + + describe('Happy path', () => { + it('adds failover URL to RPC endpoints and preserves existing properties', () => { + const state = createNetworkState([ + { + url: 'https://rpc.monad.example.com', + timeout: 5000, + }, + { url: 'https://rpc2.monad.example.com' }, + ]); + + const result = callFunction(state, quickNodeUrl); + const config = getNetworkConfig(result); + + expect(config.rpcEndpoints[0].failoverUrls).toEqual([quickNodeUrl]); + expect(config.rpcEndpoints[0].timeout).toBe(5000); + expect(config.rpcEndpoints[1].failoverUrls).toEqual([quickNodeUrl]); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); + + describe('Edge cases', () => { + it('returns state unchanged when network is not configured', () => { + const state: NetworkState = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: {}, + }, + }, + }, + }; + + const result = callFunction(state); + + expect(result).toBe(state); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('skips adding failover URL when already present or env var missing', () => { + const existingFailoverUrls = ['https://existing-failover.example.com']; + const state1 = createNetworkState([ + { + url: 'https://rpc.monad.example.com', + failoverUrls: existingFailoverUrls, + }, + ]); + const state2 = createNetworkState([ + { url: 'https://rpc.monad.example.com' }, + ]); + + const result1 = callFunction(state1, quickNodeUrl); + delete process.env[quickNodeEnvVar]; + const result2 = callFunction(state2); + + expect(getNetworkConfig(result1).rpcEndpoints[0].failoverUrls).toEqual( + existingFailoverUrls, + ); + expect( + getNetworkConfig(result2).rpcEndpoints[0].failoverUrls, + ).toBeUndefined(); + }); + + it('skips invalid endpoints and adds failover URL to valid ones', () => { + const state = createNetworkState([ + { url: 'https://rpc.monad.example.com' }, + { timeout: 5000 }, + { url: 12345 }, + 'not an object' as unknown as Record, + { url: 'https://rpc2.monad.example.com', failoverUrls: [] }, + ]); + + const result = callFunction(state, quickNodeUrl); + const config = getNetworkConfig(result); + + expect(config.rpcEndpoints[0].failoverUrls).toEqual([quickNodeUrl]); + expect(config.rpcEndpoints[1].failoverUrls).toBeUndefined(); + expect(config.rpcEndpoints[2].failoverUrls).toBeUndefined(); + expect(config.rpcEndpoints[3]).toBe('not an object'); + expect(config.rpcEndpoints[4].failoverUrls).toEqual([quickNodeUrl]); + }); + }); + + describe('Validation errors', () => { + it.each([ + ['not an object', 'FATAL ERROR: Migration 109: Invalid state error'], + [{}, 'FATAL ERROR: Migration 109: Invalid engine state error'], + [ + { engine: 'not an object' }, + 'FATAL ERROR: Migration 109: Invalid engine state error', + ], + [ + { engine: {} }, + 'FATAL ERROR: Migration 109: Invalid engine backgroundState error', + ], + [ + { engine: { backgroundState: {} } }, + 'Invalid NetworkController state structure', + ], + [ + { + engine: { + backgroundState: { NetworkController: {} }, + }, + }, + 'missing networkConfigurationsByChainId property', + ], + [ + { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + [chainId]: { rpcEndpoints: 'not an array' }, + }, + }, + }, + }, + }, + 'Invalid Monad network rpcEndpoints: expected array', + ], + ])('captures exception for invalid structure', (state, expectedMessage) => { + const result = callFunction(state); + + expect(result).toBe(state); + expectExceptionWithMessage(expectedMessage); + }); + }); + + describe('Error handling', () => { + it('captures exception and returns state when error occurs', () => { + const state = createNetworkState([ + { url: 'https://rpc.monad.example.com' }, + ]); + const networkConfig = getNetworkConfig(state); + Object.defineProperty(networkConfig, 'rpcEndpoints', { + get: () => { + throw new Error('Access error'); + }, + configurable: true, + }); + + const result = callFunction(state, quickNodeUrl); + + expect(result).toBe(state); + expectExceptionWithMessage( + `Failed to add failoverUrls to ${networkName} network configuration`, + ); + }); + }); +}); diff --git a/app/store/migrations/util/index.tsx b/app/store/migrations/util/index.tsx index 4518b0a2d60..01fa9b49243 100644 --- a/app/store/migrations/util/index.tsx +++ b/app/store/migrations/util/index.tsx @@ -89,3 +89,175 @@ export function ensureValidState( return true; } + +/** + * Validates NetworkController state structure and network configuration for a specific chain. + * This is a shared utility for migrations that add failover URLs to network configurations. + * + * @param state - The state to validate and migrate. + * @param chainId - The chain ID of the network to migrate (e.g., '0x8f' for Monad). + * @param migrationVersion - The migration version number. + * @param networkName - The name of the network (e.g., 'Monad', 'SEI') for error messages. + * @param quickNodeEnvVar - The environment variable name for the QuickNode URL (e.g., 'QUICKNODE_MONAD_URL'). + * @returns The migrated state, or the original state if validation fails or network is not configured. + */ +export function addFailoverUrlToNetworkConfiguration( + state: unknown, + chainId: string, + migrationVersion: number, + networkName: string, + quickNodeEnvVar: string, +): unknown { + try { + // Validate basic state structure using shared utility + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + // Validate NetworkController-specific structure + if (!hasProperty(state.engine.backgroundState, 'NetworkController')) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, + ), + ); + return state; + } + + if (!isObject(state.engine.backgroundState.NetworkController)) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid NetworkController state: '${typeof state.engine.backgroundState.NetworkController}'`, + ), + ); + return state; + } + + if ( + !hasProperty( + state.engine.backgroundState.NetworkController, + 'networkConfigurationsByChainId', + ) + ) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid NetworkController state: missing networkConfigurationsByChainId property`, + ), + ); + return state; + } + + if ( + !isObject( + state.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + ) + ) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid NetworkController networkConfigurationsByChainId: '${typeof state.engine.backgroundState.NetworkController.networkConfigurationsByChainId}'`, + ), + ); + return state; + } + + if ( + !hasProperty( + state.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + chainId, + ) + ) { + // Network not configured, no migration needed + return state; + } + + if ( + !isObject( + state.engine.backgroundState.NetworkController + .networkConfigurationsByChainId[chainId], + ) + ) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid ${networkName} network configuration: '${typeof state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[chainId]}'`, + ), + ); + return state; + } + + const networkConfigValue = + state.engine.backgroundState.NetworkController + .networkConfigurationsByChainId[chainId]; + + if (!isObject(networkConfigValue)) { + return state; + } + + // TypeScript type assertion after validation + const networkConfig = networkConfigValue as Record; + + if (!hasProperty(networkConfig, 'rpcEndpoints')) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid ${networkName} network configuration: missing rpcEndpoints property`, + ), + ); + return state; + } + + if (!Array.isArray(networkConfig.rpcEndpoints)) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid ${networkName} network rpcEndpoints: expected array, got '${typeof networkConfig.rpcEndpoints}'`, + ), + ); + return state; + } + + // Update RPC endpoints to add failover URL if needed + networkConfig.rpcEndpoints = networkConfig.rpcEndpoints.map( + (rpcEndpoint: unknown) => { + // Skip if endpoint is not an object or doesn't have a url property + if ( + !isObject(rpcEndpoint) || + !hasProperty(rpcEndpoint, 'url') || + typeof rpcEndpoint.url !== 'string' + ) { + return rpcEndpoint; + } + + // Skip if endpoint already has failover URLs + if ( + hasProperty(rpcEndpoint, 'failoverUrls') && + Array.isArray(rpcEndpoint.failoverUrls) && + rpcEndpoint.failoverUrls.length > 0 + ) { + return rpcEndpoint; + } + + // Add QuickNode failover URL + const quickNodeUrl = process.env[quickNodeEnvVar]; + + if (quickNodeUrl) { + return { + ...rpcEndpoint, + failoverUrls: [quickNodeUrl], + }; + } + + return rpcEndpoint; + }, + ); + + return state; + } catch (error) { + captureException( + new Error( + `Migration ${migrationVersion}: Failed to add failoverUrls to ${networkName} network configuration: ${error}`, + ), + ); + } + + return state; +} diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index 05752193d1c..b3a4a0a61e5 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -26,6 +26,7 @@ export const QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME = { 'base-mainnet': () => process.env.QUICKNODE_BASE_URL, 'bsc-mainnet': () => process.env.QUICKNODE_BSC_URL, 'sei-mainnet': () => process.env.QUICKNODE_SEI_URL, + 'monad-mainnet': () => process.env.QUICKNODE_MONAD_URL, }; export function getFailoverUrlsForInfuraNetwork( @@ -166,7 +167,7 @@ export const PopularList = [ chainId: toHex('143'), nickname: 'Monad', rpcUrl: `https://monad-mainnet.infura.io/v3/${infuraProjectId}`, - failoverRpcUrls: [], + failoverRpcUrls: getFailoverUrlsForInfuraNetwork('monad-mainnet'), ticker: 'MON', warning: true, rpcPrefs: {