diff --git a/app/components/Views/Settings/NetworksSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/NetworksSettings/__snapshots__/index.test.tsx.snap index a45b7d0da40..fd3bd4b0038 100644 --- a/app/components/Views/Settings/NetworksSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/NetworksSettings/__snapshots__/index.test.tsx.snap @@ -903,6 +903,71 @@ exports[`NetworksSettings should render correctly 1`] = ` + + + + + + Monad Testnet + + +  + + + + ({ + captureException: jest.fn(), +})); + +jest.mock('./util', () => ({ + ensureValidState: jest.fn(), +})); + +const mockedCaptureException = jest.mocked(captureException); +const mockedEnsureValidState = jest.mocked(ensureValidState); + +const createTestState = () => ({ + 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', + }, + '0xaa36a7': { + chainId: '0xaa36a7', + rpcEndpoints: [ + { + networkClientId: 'sepolia', + url: 'https://sepolia.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://sepolia.etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + }, + '0xe705': { + chainId: '0xe705', + rpcEndpoints: [ + { + networkClientId: 'linea-sepolia', + url: 'https://linea-sepolia.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://sepolia.lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + name: 'Linea Sepolia', + nativeCurrency: 'LineaETH', + }, + '0xe708': { + chainId: '0xe708', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x18c6': { + chainId: '0x18c6', + rpcEndpoints: [ + { + networkClientId: 'megaeth-testnet', + url: 'https://carrot.megaeth.com/rpc', + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://megaexplorer.xyz'], + defaultBlockExplorerUrlIndex: 0, + name: 'Mega Testnet', + nativeCurrency: 'MegaETH', + }, + }, + }, + } + } +}); + +const createMonadTestnetConfiguration = (): NetworkConfiguration => ({ + chainId: '0x279f', + rpcEndpoints: [ + { + networkClientId: 'monad-testnet', + url: 'https://testnet-rpc.monad.xyz', + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://testnet.monadexplorer.com'], + defaultBlockExplorerUrlIndex: 0, + name: 'Monad Testnet', + nativeCurrency: 'MON', +}); + +describe('Migration 77: Add `Monad Testnet`', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + 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(); + }); + + it('adds `Monad Testnet` as default network to state', () => { + const monadTestnetConfiguration = createMonadTestnetConfiguration(); + const oldState = createTestState(); + mockedEnsureValidState.mockReturnValue(true); + + const expectedData = { + engine: { + backgroundState: { + NetworkController: { + ...oldState.engine.backgroundState.NetworkController, + networkConfigurationsByChainId: { + ...oldState.engine.backgroundState.NetworkController.networkConfigurationsByChainId, + [monadTestnetConfiguration.chainId]: monadTestnetConfiguration + }, + }, + } + } + }; + + const migratedState = migrate(oldState); + + expect(migratedState).toStrictEqual(expectedData); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('replaces `Monad Testnet` NetworkConfiguration if there is one', () => { + const monadTestnetConfiguration = createMonadTestnetConfiguration(); + const oldState = createTestState(); + const networkConfigurationsByChainId = oldState.engine.backgroundState.NetworkController.networkConfigurationsByChainId as Record; + networkConfigurationsByChainId[monadTestnetConfiguration.chainId] = { + ...monadTestnetConfiguration, + rpcEndpoints: [ + { + networkClientId: 'some-client-id', + url: 'https://some-url.com/rpc', + type: RpcEndpointType.Custom, + }, + ], + }; + mockedEnsureValidState.mockReturnValue(true); + + const expectedData = { + engine: { + backgroundState: { + NetworkController: { + ...oldState.engine.backgroundState.NetworkController, + networkConfigurationsByChainId: { + ...oldState.engine.backgroundState.NetworkController.networkConfigurationsByChainId, + [monadTestnetConfiguration.chainId]: monadTestnetConfiguration + }, + }, + } + } + }; + + const migratedState = migrate(oldState); + + expect(migratedState).toStrictEqual(expectedData); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it.each([{ + state: { + engine: {} + }, + test: 'empty engine state', + }, { + state: { + engine: { + backgroundState: {} + } + }, + test: 'empty backgroundState', + }, { + state: { + engine: { + backgroundState: { + NetworkController: 'invalid' + } + }, + }, + test: 'invalid NetworkController state' + }, { + state: { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: 'invalid' + } + } + }, + }, + test: 'invalid networkConfigurationsByChainId state' + } + ])('does not modify state if the state is invalid - $test', ({ state }) => { + const orgState = cloneDeep(state); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(state); + + // State should be unchanged + expect(migratedState).toStrictEqual(orgState); + expect(mockedCaptureException).toHaveBeenCalledWith( + new Error('Migration 77: NetworkController or networkConfigurationsByChainId not found in state'), + ); + }); +}); diff --git a/app/store/migrations/077.ts b/app/store/migrations/077.ts new file mode 100644 index 00000000000..c0acb765d6b --- /dev/null +++ b/app/store/migrations/077.ts @@ -0,0 +1,84 @@ +import { captureException } from '@sentry/react-native'; +import { hasProperty, isObject } from '@metamask/utils'; +import { type NetworkConfiguration, RpcEndpointType } from '@metamask/network-controller'; +import { + ChainId, + BuiltInNetworkName, + NetworkNickname, + BUILT_IN_CUSTOM_NETWORKS_RPC, + NetworksTicker, + BlockExplorerUrl +} from '@metamask/controller-utils'; + +import { ensureValidState } from './util'; + +/** + * Migration 77: Add 'Monad Testnet' + * + * This migration add Monad Testnet to the network controller + * as a default Testnet. + */ +const migration = (state: unknown): unknown => { + const migrationVersion = 77; + + // Ensure the state is valid for migration + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + try { + if ( + hasProperty(state, 'engine') && + hasProperty(state.engine, 'backgroundState') && + hasProperty(state.engine.backgroundState, 'NetworkController') && + isObject(state.engine.backgroundState.NetworkController) && + isObject( + state.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + ) + ) { + // It is possible to get the Monad Network configuration by `getDefaultNetworkConfigurationsByChainId()`, + // But we choose to re-define it here to prevent the need to change this file, + // when `getDefaultNetworkConfigurationsByChainId()` has some breaking changes in the future. + const networkClientId = BuiltInNetworkName.MonadTestnet; + const chainId = ChainId[networkClientId]; + const monadTestnetConfiguration: NetworkConfiguration = { + blockExplorerUrls: [BlockExplorerUrl[networkClientId]], + chainId, + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + name: NetworkNickname[networkClientId], + nativeCurrency: NetworksTicker[networkClientId], + rpcEndpoints: [ + { + failoverUrls: [], + networkClientId, + type: RpcEndpointType.Custom, + url: BUILT_IN_CUSTOM_NETWORKS_RPC['monad-testnet'], + }, + ], + }; + + // Regardless if the network already exists, we will overwrite it with the default configuration. + state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[ + chainId + ] = monadTestnetConfiguration; + } else { + captureException( + new Error( + `Migration ${migrationVersion}: NetworkController or networkConfigurationsByChainId not found in state`, + ), + ); + } + return state; + } catch (error) { + captureException( + new Error( + `Migration ${migrationVersion}: Adding Monad Testnet failed with error: ${error}`, + ), + ); + return state; + } +}; + +export default migration; diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index 41a997f82d2..ab362f953b1 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -76,6 +76,7 @@ import migration72 from './072'; import migration74 from './074'; import migration75 from './075'; import migration76 from './076'; +import migration77 from './077'; // Add migrations above this line import { validatePostMigrationState } from '../validateMigration/validateMigration'; import { RootState } from '../../reducers'; @@ -167,6 +168,7 @@ export const migrationList: MigrationsList = { 74: migration74, 75: migration75, 76: migration76, + 77: migration77, }; // 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 b85e849d4e8..5350772fb68 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -224,6 +224,24 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` }, ], }, + "0x279f": { + "blockExplorerUrls": [ + "https://testnet.monadexplorer.com", + ], + "chainId": "0x279f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-testnet", + "type": "custom", + "url": "https://testnet-rpc.monad.xyz", + }, + ], + }, "0xaa36a7": { "blockExplorerUrls": [], "chainId": "0xaa36a7", diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 743c4b6f44f..84e6456e38d 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -9,6 +9,7 @@ import { LINEA_MAINNET, LINEA_SEPOLIA, MEGAETH_TESTNET, + MONAD_TESTNET, } from '../../../app/constants/network'; import { NetworkSwitchErrorType } from '../../../app/constants/error'; import { @@ -28,6 +29,7 @@ const sepoliaLogo = require('../../images/sepolia-logo-dark.png'); const lineaTestnetLogo = require('../../images/linea-testnet-logo.png'); const lineaMainnetLogo = require('../../images/linea-mainnet-logo.png'); const megaEthTestnetLogo = require('../../images/megaeth-testnet-logo.png'); +const monadTestnetLogo = require('../../images/monad-testnet-logo.png'); /* eslint-enable */ import { @@ -134,6 +136,19 @@ export const NetworkList = { imageSource: megaEthTestnetLogo, blockExplorerUrl: BlockExplorerUrl['megaeth-testnet'], }, + [MONAD_TESTNET]: { + name: 'Monad Testnet', + shortName: 'Monad Testnet', + networkId: 10143, + chainId: toHex('10143'), + ticker: 'MON', + // Third party color + // eslint-disable-next-line @metamask/design-tokens/color-no-hex + color: '#61dfff', + networkType: 'monad-testnet', + imageSource: monadTestnetLogo, + blockExplorerUrl: BlockExplorerUrl['monad-testnet'], + }, [RPC]: { name: 'Private Network', shortName: 'Private', @@ -246,6 +261,9 @@ export const getTestNetImageByChainId = (chainId) => { if (NETWORKS_CHAIN_ID.MEGAETH_TESTNET === chainId) { return networksWithImages?.['MEGAETH-TESTNET']; } + if (NETWORKS_CHAIN_ID.MONAD_TESTNET === chainId) { + return networksWithImages?.['MONAD-TESTNET']; + } }; /** @@ -257,6 +275,7 @@ export const TESTNET_CHAIN_IDS = [ ChainId[NetworkType['linea-goerli']], ChainId[NetworkType['linea-sepolia']], ChainId[NetworkType['megaeth-testnet']], + ChainId[NetworkType['monad-testnet']], ]; /** @@ -607,6 +626,7 @@ export const WHILELIST_NETWORK_NAME = { [ChainId.mainnet]: 'Mainnet', [ChainId['linea-mainnet']]: 'Linea Mainnet', [ChainId['megaeth-testnet']]: 'Mega Testnet', + [ChainId['monad-testnet']]: 'Monad Testnet', }; /** diff --git a/app/util/networks/index.test.ts b/app/util/networks/index.test.ts index 98da9020e4c..24465503d9a 100644 --- a/app/util/networks/index.test.ts +++ b/app/util/networks/index.test.ts @@ -29,7 +29,8 @@ import { LINEA_GOERLI, LINEA_MAINNET, LINEA_SEPOLIA, - MEGAETH_TESTNET + MEGAETH_TESTNET, + MONAD_TESTNET, } from '../../../app/constants/network'; import { NetworkSwitchErrorType } from '../../../app/constants/error'; import Engine from './../../core/Engine'; @@ -117,7 +118,12 @@ describe('network-utils', () => { const allNetworks = getAllNetworks(); it('should get all networks', () => { expect(allNetworks).toStrictEqual([ - MAINNET,LINEA_MAINNET, SEPOLIA, LINEA_SEPOLIA, MEGAETH_TESTNET + MAINNET, + LINEA_MAINNET, + SEPOLIA, + LINEA_SEPOLIA, + MEGAETH_TESTNET, + MONAD_TESTNET, ]); }); @@ -763,6 +769,10 @@ describe('network-utils', () => { { chainId: ChainId['megaeth-testnet'], expectedImage: networksWithImages?.['MEGAETH-TESTNET'], + }, + { + chainId: ChainId['monad-testnet'], + expectedImage: networksWithImages?.['MONAD-TESTNET'], } ] )('returns corresponding image for the testnet - $.chainId', ({ @@ -794,6 +804,11 @@ describe('network-utils', () => { name: 'MegaETH Testnet', nickname: WHILELIST_NETWORK_NAME[ChainId['megaeth-testnet']], }, + { + chainId: ChainId['monad-testnet'], + name: 'Monad Testnet', + nickname: WHILELIST_NETWORK_NAME[ChainId['monad-testnet']], + }, ])('returns true if the chainId is %.chainId and network nickname is the same with the whilelisted name', ({ chainId, name, nickname }) =>{ diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 5a5d3bb4c20..b3a57f14e2c 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -90,6 +90,24 @@ } ] }, + "0x279f": { + "blockExplorerUrls": [ + "https://testnet.monadexplorer.com" + ], + "chainId": "0x279f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-testnet", + "type": "custom", + "url": "https://testnet-rpc.monad.xyz" + } + ] + }, "0xaa36a7": { "blockExplorerUrls": [], "chainId": "0xaa36a7", diff --git a/package.json b/package.json index 632992760c2..9fa04ba38f6 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ "@metamask/bridge-status-controller": "^18.0.0", "@metamask/chain-agnostic-permission": "^0.3.0", "@metamask/composable-controller": "^11.0.0", - "@metamask/controller-utils": "^11.7.0", + "@metamask/controller-utils": "^11.8.0", "@metamask/design-tokens": "^7.0.0", "@metamask/earn-controller": "^0.13.0", "@metamask/eip1193-permission-middleware": "^0.1.0", @@ -198,7 +198,7 @@ "@metamask/metamask-eth-abis": "3.1.1", "@metamask/multichain-network-controller": "^0.4.0", "@metamask/multichain-transactions-controller": "^0.10.0", - "@metamask/network-controller": "^23.2.0", + "@metamask/network-controller": "^23.4.0", "@metamask/notification-services-controller": "^7.0.0", "@metamask/permission-controller": "^11.0.6", "@metamask/phishing-controller": "^12.4.1", diff --git a/yarn.lock b/yarn.lock index 0ab68b84bc9..1dead2a69d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4866,10 +4866,10 @@ resolved "https://registry.yarnpkg.com/@metamask/contract-metadata/-/contract-metadata-2.5.0.tgz#33921fa9c15eb1863f55dcd5f75467ae15614ebb" integrity sha512-+j7jEcp0P1OUMEpa/OIwfJs/ahBC/akwgWxaRTSWX2SWABvlUKBVRMtslfL94Qj2wN2xw8xjaUy5nSHqrznqDA== -"@metamask/controller-utils@^11.0.0", "@metamask/controller-utils@^11.3.0", "@metamask/controller-utils@^11.5.0", "@metamask/controller-utils@^11.6.0", "@metamask/controller-utils@^11.7.0": - version "11.7.0" - resolved "https://registry.yarnpkg.com/@metamask/controller-utils/-/controller-utils-11.7.0.tgz#1186daecffff9dec6846f64f9da7a319dd9c9a83" - integrity sha512-Q2TPmTJT6L/ixBk5TEb+mJ1NRyFAGe+VjFuulNQMVu6AanEBeGSoxsuBnAzWQlIgbt3/EHGP7o31ep1H5gr5Gw== +"@metamask/controller-utils@^11.0.0", "@metamask/controller-utils@^11.3.0", "@metamask/controller-utils@^11.5.0", "@metamask/controller-utils@^11.6.0", "@metamask/controller-utils@^11.7.0", "@metamask/controller-utils@^11.8.0": + version "11.8.0" + resolved "https://registry.yarnpkg.com/@metamask/controller-utils/-/controller-utils-11.8.0.tgz#7d573db8a2ab0ce594f92753b0bda02d18330142" + integrity sha512-FqApJXW0mnHWwnKC4HOQwf2P9fPxfiQmlLJdGWwK6hAYKQ+t7ADDw9Wf8RwVquFPzJGQbXnOcbXVBY2zAyoU5w== dependencies: "@ethereumjs/util" "^9.1.0" "@metamask/eth-query" "^4.0.0" @@ -5381,13 +5381,13 @@ uri-js "^4.4.1" uuid "^8.3.2" -"@metamask/network-controller@^23.1.0", "@metamask/network-controller@^23.2.0": - version "23.2.0" - resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-23.2.0.tgz#02b0641bcf4e8ca35005f945bc4943a3baf949e3" - integrity sha512-DnsMUtBxDQm4tk+2+bMbEgbUEgWS2xGmN6JqA47AXGBjzpnUUKQolcyJuPX0jJA+qUSnMG9jQjuMJBf1RHSR5Q== +"@metamask/network-controller@^23.1.0", "@metamask/network-controller@^23.4.0": + version "23.4.0" + resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-23.4.0.tgz#17236ad4c06b5d1e1bc982aef4a03db6fddfd559" + integrity sha512-RQCbpNqWAJKLH4TLGRbS0Oj/R01cNsa+B8UF47hTVuRJXJMH/4ArU6kzUBbVyHM/gULcKU8eIrf1iEVqdkrWuw== dependencies: - "@metamask/base-controller" "^8.0.0" - "@metamask/controller-utils" "^11.7.0" + "@metamask/base-controller" "^8.0.1" + "@metamask/controller-utils" "^11.8.0" "@metamask/eth-block-tracker" "^11.0.3" "@metamask/eth-json-rpc-infura" "^10.1.1" "@metamask/eth-json-rpc-middleware" "^16.0.1"