diff --git a/app/components/Views/AddressSelector/AddressSelector.test.tsx b/app/components/Views/AddressSelector/AddressSelector.test.tsx index b2ad4cc64e5c..6aff0617f4fb 100644 --- a/app/components/Views/AddressSelector/AddressSelector.test.tsx +++ b/app/components/Views/AddressSelector/AddressSelector.test.tsx @@ -19,7 +19,6 @@ import { MAINNET_DISPLAY_NAME, OPTIMISM_DISPLAY_NAME, POLYGON_DISPLAY_NAME, - SEI_DISPLAY_NAME, } from '../../../core/Engine/constants'; jest.mock('../../../core/Engine', () => ({ @@ -138,7 +137,6 @@ describe('AccountSelector', () => { expect(networkNames).toEqual([ MAINNET_DISPLAY_NAME, BNB_DISPLAY_NAME, - SEI_DISPLAY_NAME, POLYGON_DISPLAY_NAME, OPTIMISM_DISPLAY_NAME, ARBITRUM_DISPLAY_NAME, diff --git a/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap b/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap index 3fde75c8127a..e1c145701835 100644 --- a/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap +++ b/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap @@ -1075,187 +1075,6 @@ exports[`AccountSelector renders correctly and matches snapshot 1`] = ` "flexDirection": "row", } } - > - - - - - - - - Sei - - - 0x4FeC2...fdcB5 - - - - - - - - - - - - ({ + captureException: jest.fn(), +})); + +jest.mock('./util', () => ({ + ensureValidState: jest.fn(), +})); + +const mockedCaptureException = jest.mocked(captureException); +const mockedEnsureValidState = jest.mocked(ensureValidState); + +const migrationVersion = 107; +const QUICKNODE_SEI_URL = 'https://failover.com'; + +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' }); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + const invalidStates = [ + { + state: { + engine: {}, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, + scenario: 'empty engine state', + }, + { + state: { + engine: { + backgroundState: {}, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid NetworkController state structure: missing required properties`, + scenario: 'empty backgroundState', + }, + { + 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: { + '0x531': 'invalid', + }, + }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid SEI network configuration: 'string'`, + scenario: 'invalid SEI network configuration', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x531': { + chainId: '0x531', + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://seitrace.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Custom Sei Network', + nativeCurrency: 'SEI', + }, + }, + }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid SEI network configuration: missing rpcEndpoints property`, + scenario: 'missing rpcEndpoints property in SEI network configuration', + }, + { + state: { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x531': { + chainId: '0x531', + rpcEndpoints: 'not-an-array', + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://seitrace.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Custom Sei Network', + nativeCurrency: 'SEI', + }, + }, + }, + }, + }, + }, + errorMessage: `Migration ${migrationVersion}: Invalid SEI network rpcEndpoints: expected array, got 'string'`, + scenario: 'rpcEndpoints is not an array in SEI network configuration', + }, + ]; + + it.each(invalidStates)( + 'should capture 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 SEI network is not found', () => { + const state = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + 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 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 = { + engine: { + backgroundState: { + NetworkController: { + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: { + '0x1': { + 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', + }, + '0x531': { + chainId: '0x531', + rpcEndpoints: [ + { + networkClientId: 'sei-network', + url: 'https://sei-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Sei Network', + failoverUrls: ['https://failover.com'], + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://seitrace.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Sei Network', + nativeCurrency: 'SEI', + }, + }, + }, + }, + }, + }; + + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = await migrate(oldState); + expect(migratedState).toStrictEqual(oldState); + }); + + it('does not add failover URL if QUICKNODE_SEI_URL env variable is not set', async () => { + const oldState = { + engine: { + backgroundState: { + NetworkController: { + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: { + '0x1': { + 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', + }, + '0x531': { + chainId: '0x531', + rpcEndpoints: [ + { + networkClientId: 'sei-network', + url: 'https://sei-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Sei Network', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://seitrace.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Sei Network', + nativeCurrency: 'SEI', + }, + }, + }, + }, + }, + }; + + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = await migrate(oldState); + expect(migratedState).toStrictEqual(oldState); + }); + + it('adds QuickNode failover URL to all SEI RPC endpoints when no failover URLs exist', async () => { + process.env.QUICKNODE_SEI_URL = QUICKNODE_SEI_URL; + const oldState = { + engine: { + backgroundState: { + NetworkController: { + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: { + '0x1': { + 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', + }, + '0x531': { + chainId: '0x531', + rpcEndpoints: [ + { + networkClientId: 'sei-network', + url: 'https://sei-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Sei Network', + }, + { + networkClientId: 'sei-network-2', + url: 'http://some-sei-rpc.com', + type: 'custom', + name: 'Sei Network', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://seitrace.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Sei Network', + nativeCurrency: 'SEI', + }, + }, + }, + }, + }, + }; + + mockedEnsureValidState.mockReturnValue(true); + + const expectedData = { + engine: { + backgroundState: { + NetworkController: { + ...oldState.engine.backgroundState.NetworkController, + networkConfigurationsByChainId: { + ...oldState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + '0x531': { + ...oldState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId['0x531'], + rpcEndpoints: [ + { + networkClientId: 'sei-network', + url: 'https://sei-mainnet.infura.io/v3/{infuraProjectId}', + type: 'custom', + name: 'Sei Network', + failoverUrls: [QUICKNODE_SEI_URL], + }, + { + networkClientId: 'sei-network-2', + url: 'http://some-sei-rpc.com', + type: 'custom', + name: 'Sei Network', + failoverUrls: [QUICKNODE_SEI_URL], + }, + ], + }, + }, + }, + }, + }, + }; + + const migratedState = await migrate(oldState); + expect(migratedState).toStrictEqual(expectedData); + }); +}); diff --git a/app/store/migrations/107.ts b/app/store/migrations/107.ts new file mode 100644 index 000000000000..66af8f935882 --- /dev/null +++ b/app/store/migrations/107.ts @@ -0,0 +1,175 @@ +import { captureException } from '@sentry/react-native'; +import { hasProperty } from '@metamask/utils'; +import { isObject } from 'lodash'; + +import { ensureValidState } from './util'; + +const seiChainId = '0x531'; +const migrationVersion = 107; +/** + * Migration 107: Add failoverUrls to SEI network configuration + * + * This migration adds failoverUrls to the SEI network configuration + * to ensure that the app can connect to the SEI network even if the + * 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; + }); + + return state; + } catch (error) { + captureException( + new Error( + `Migration ${migrationVersion}: Failed to add failoverUrls to SEI network configuration: ${error}`, + ), + ); + } + + return state; +} diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index e63084cb88c3..cf9c47d4633b 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -107,6 +107,7 @@ import migration103 from './103'; import migration104 from './104'; import migration105 from './105'; import migration106 from './106'; +import migration107 from './107'; // Add migrations above this line import { ControllerStorage } from '../persistConfig'; @@ -230,6 +231,7 @@ export const migrationList: MigrationsList = { 104: migration104, 105: migration105, 106: migration106, + 107: migration107, }; // Enable both synchronous and asynchronous migrations diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 161ffede2979..e15333f863d7 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -306,21 +306,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State }, ], }, - "0x531": { - "blockExplorerUrls": [], - "chainId": "0x531", - "defaultRpcEndpointIndex": 0, - "name": "Sei", - "nativeCurrency": "SEI", - "rpcEndpoints": [ - { - "failoverUrls": [], - "networkClientId": "sei-mainnet", - "type": "infura", - "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", - }, - ], - }, "0x89": { "blockExplorerUrls": [], "chainId": "0x89", @@ -1065,21 +1050,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` }, ], }, - "0x531": { - "blockExplorerUrls": [], - "chainId": "0x531", - "defaultRpcEndpointIndex": 0, - "name": "Sei", - "nativeCurrency": "SEI", - "rpcEndpoints": [ - { - "failoverUrls": [], - "networkClientId": "sei-mainnet", - "type": "infura", - "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", - }, - ], - }, "0x89": { "blockExplorerUrls": [], "chainId": "0x89", diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index c7683d2dc3fa..c402631b7f7d 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -25,6 +25,7 @@ export const QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME = { 'polygon-mainnet': () => process.env.QUICKNODE_POLYGON_URL, 'base-mainnet': () => process.env.QUICKNODE_BASE_URL, 'bsc-mainnet': () => process.env.QUICKNODE_BSC_URL, + 'sei-mainnet': () => process.env.QUICKNODE_SEI_URL, }; export function getFailoverUrlsForInfuraNetwork( @@ -152,7 +153,7 @@ export const PopularList = [ chainId: toHex('1329'), nickname: 'Sei', rpcUrl: `https://sei-mainnet.infura.io/v3/${infuraProjectId}`, - failoverRpcUrls: [], + failoverRpcUrls: getFailoverUrlsForInfuraNetwork('sei-mainnet'), ticker: 'SEI', warning: true, rpcPrefs: { diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 7fced8f964d7..3b8786c70bae 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -128,21 +128,6 @@ } ] }, - "0x531": { - "blockExplorerUrls": [], - "chainId": "0x531", - "defaultRpcEndpointIndex": 0, - "name": "Sei", - "nativeCurrency": "SEI", - "rpcEndpoints": [ - { - "failoverUrls": [], - "networkClientId": "sei-mainnet", - "type": "infura", - "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}" - } - ] - }, "0x89": { "blockExplorerUrls": [], "chainId": "0x89", @@ -334,8 +319,7 @@ "0x507": true, "0x505": true, "0x64": true, - "0x531": true, - "0x8f": true + "0x531": true }, "isIpfsGatewayEnabled": true, "smartTransactionsOptInStatus": true,