diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 8c4f7455714..a6a729a723c 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `ChainService` module for SLIP-44 coin type resolution ([#7034](https://github.com/MetaMask/core/pull/7034)) + - Add `getSlip44ByChainId()` function to resolve SLIP-44 coin types from EVM chain IDs + - Add `getNativeCaip19()` function to generate CAIP-19 asset identifiers for native currencies + - Add support for fetching chain metadata from chainid.network with caching + - Add `@metamask/slip44` dependency for symbol-to-coin-type mapping + ### Fixed - include additional popular networks now enabled by default ([#7014](https://github.com/MetaMask/core/pull/7014)) diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index 951dfabf78e..a9e1ab1f51e 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -65,6 +65,7 @@ "@metamask/controller-utils": "^11.15.0", "@metamask/keyring-api": "^21.0.0", "@metamask/messenger": "^0.3.0", + "@metamask/slip44": "^4.2.0", "@metamask/utils": "^11.8.1", "reselect": "^5.1.1" }, diff --git a/packages/network-enablement-controller/src/ChainService.test.ts b/packages/network-enablement-controller/src/ChainService.test.ts new file mode 100644 index 00000000000..fdaaeb361be --- /dev/null +++ b/packages/network-enablement-controller/src/ChainService.test.ts @@ -0,0 +1,361 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import { getSlip44ByChainId, getNativeCaip19 } from './ChainService'; + +// Mock slip44 data - intentionally exclude BTC to test COMMON_SYMBOL_DEFAULTS fallback +jest.mock('@metamask/slip44/slip44.json', () => ({ + __esModule: true, + default: [ + { index: 60, symbol: 'ETH', name: 'Ethereum' }, + { index: 714, symbol: 'BNB', name: 'BNB' }, + { index: 966, symbol: 'MATIC', name: 'Polygon' }, + { index: 966, symbol: 'POL', name: 'Polygon' }, + { index: 9000, symbol: 'AVAX', name: 'Avalanche' }, + { index: 1007, symbol: 'FTM', name: 'Fantom' }, + { index: 700, symbol: 'XDAI', name: 'xDai' }, + // Intentionally omit BTC, SOL, SEI, MON to test COMMON_SYMBOL_DEFAULTS + ], +})); + +jest.mock('@metamask/controller-utils', () => ({ + handleFetch: jest.fn(), +})); + +const mockHandleFetch = handleFetch as jest.MockedFunction; + +describe('ChainService', () => { + beforeAll(() => { + // Use fake timers to control Date.now + jest.useFakeTimers(); + // Set initial time to a large value + jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + }); + + beforeEach(() => { + // Clear mocks + mockHandleFetch.mockClear(); + + // Advance time by 2 hours to expire any cached data from previous tests + // This is much more than the 30-minute cache duration + jest.advanceTimersByTime(2 * 60 * 60 * 1000); + }); + + afterAll(() => { + // Restore real timers + jest.useRealTimers(); + }); + + describe('getSlip44ByChainId', () => { + const mockChains = [ + { + chainId: 1, + name: 'Ethereum Mainnet', + nativeCurrency: { symbol: 'ETH', name: 'Ether', decimals: 18 }, + }, + { + chainId: 56, + name: 'Binance Smart Chain', + nativeCurrency: { symbol: 'BNB', name: 'BNB', decimals: 18 }, + }, + { + chainId: 137, + name: 'Polygon', + nativeCurrency: { symbol: 'POL', name: 'POL', decimals: 18 }, + }, + { + chainId: 42161, + name: 'Arbitrum One', + nativeCurrency: { symbol: 'ETH', name: 'Ether', decimals: 18 }, + }, + { + chainId: 43114, + name: 'Avalanche C-Chain', + nativeCurrency: { symbol: 'AVAX', name: 'Avalanche', decimals: 18 }, + }, + ]; + + it('returns the slip44 value for Ethereum mainnet', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getSlip44ByChainId(1); + + expect(result).toBe('60'); + expect(mockHandleFetch).toHaveBeenCalledWith( + 'https://chainid.network/chains.json', + ); + }); + + it('returns the slip44 value for BNB Chain based on symbol', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getSlip44ByChainId(56); + + expect(result).toBe('714'); + }); + + it('returns the slip44 value for Polygon based on symbol', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getSlip44ByChainId(137); + + expect(result).toBe('966'); + }); + + it('returns ETH slip44 for Arbitrum (L2 with ETH)', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getSlip44ByChainId(42161); + + expect(result).toBe('60'); + }); + + it('returns the slip44 value for Avalanche based on symbol', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getSlip44ByChainId(43114); + + expect(result).toBe('9000'); + }); + + it('returns default ETH slip44 for unknown chain (L2 heuristic)', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getSlip44ByChainId(999999); + + expect(result).toBe('60'); + }); + + it('returns default ETH slip44 when chain has no native currency', async () => { + mockHandleFetch.mockResolvedValueOnce([ + { chainId: 12345, name: 'Test Chain' }, + ]); + + const result = await getSlip44ByChainId(12345); + + expect(result).toBe('60'); + }); + + it('returns null and logs error when fetch fails', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockHandleFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await getSlip44ByChainId(1); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'getSlip44ByChainId(1) failed:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('uses cached data on subsequent calls within cache duration', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + // First call - should fetch + const result1 = await getSlip44ByChainId(1); + expect(result1).toBe('60'); + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + + // Second call within cache duration - should use cache + jest.advanceTimersByTime(15 * 60 * 1000); // 15 minutes later + const result2 = await getSlip44ByChainId(56); + expect(result2).toBe('714'); + expect(mockHandleFetch).toHaveBeenCalledTimes(1); // Still only called once + }); + + it('fetches new data when cache expires', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + // First call - should fetch + const result1 = await getSlip44ByChainId(1); + expect(result1).toBe('60'); + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + + // Second call after cache expires - should fetch again + jest.advanceTimersByTime(31 * 60 * 1000); // 31 minutes later + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result2 = await getSlip44ByChainId(1); + expect(result2).toBe('60'); + expect(mockHandleFetch).toHaveBeenCalledTimes(2); // Called twice + }); + + it('handles multiple different chain IDs correctly', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + // All calls should use the same cached data from the first fetch + const result1 = await getSlip44ByChainId(1); + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + + const result2 = await getSlip44ByChainId(56); + expect(mockHandleFetch).toHaveBeenCalledTimes(1); // Still 1 - uses cache + + const result3 = await getSlip44ByChainId(137); + expect(mockHandleFetch).toHaveBeenCalledTimes(1); // Still 1 - uses cache + + expect(result1).toBe('60'); + expect(result2).toBe('714'); + expect(result3).toBe('966'); + }); + + it('handles chain with SOL symbol from COMMON_SYMBOL_DEFAULTS', async () => { + mockHandleFetch.mockResolvedValueOnce([ + { + chainId: 999001, + name: 'Solana-based chain', + nativeCurrency: { symbol: 'SOL', name: 'Solana', decimals: 9 }, + }, + ]); + + const result = await getSlip44ByChainId(999001); + + // SOL is not in mocked SLIP44_BY_SYMBOL but is in COMMON_SYMBOL_DEFAULTS + expect(result).toBe('501'); + expect(typeof result).toBe('string'); + }); + + it('handles invalid chains.json response', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockHandleFetch.mockResolvedValueOnce('invalid data'); + + const result = await getSlip44ByChainId(1); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('handles empty chains array', async () => { + mockHandleFetch.mockResolvedValueOnce([]); + + const result = await getSlip44ByChainId(1); + + // Should return default ETH slip44 + expect(result).toBe('60'); + }); + + it('handles chains with uppercase and lowercase symbols', async () => { + mockHandleFetch.mockResolvedValueOnce([ + { + chainId: 1, + name: 'Test Chain', + nativeCurrency: { symbol: 'eth', name: 'Ether', decimals: 18 }, + }, + ]); + + const result = await getSlip44ByChainId(1); + + expect(result).toBe('60'); + }); + + it('deduplicates concurrent requests (in-flight handling)', async () => { + mockHandleFetch.mockImplementation( + () => + new Promise((resolve) => { + // Simulate slow network request + setTimeout(() => { + resolve([ + { + chainId: 1, + name: 'Ethereum Mainnet', + nativeCurrency: { + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + }, + ]); + }, 100); + }), + ); + + // Make multiple concurrent requests + const promise1 = getSlip44ByChainId(1); + const promise2 = getSlip44ByChainId(1); + const promise3 = getSlip44ByChainId(1); + + // Advance timers to resolve the promises + jest.advanceTimersByTime(100); + + const [result1, result2, result3] = await Promise.all([ + promise1, + promise2, + promise3, + ]); + + // All should return the same result + expect(result1).toBe('60'); + expect(result2).toBe('60'); + expect(result3).toBe('60'); + + // But fetch should only be called once + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + }); + + it('uses COMMON_SYMBOL_DEFAULTS when symbol not in SLIP44_BY_SYMBOL', async () => { + mockHandleFetch.mockResolvedValueOnce([ + { + chainId: 999, + name: 'Test Chain', + // Use a symbol that's in COMMON_SYMBOL_DEFAULTS but not in mocked SLIP44 + nativeCurrency: { symbol: 'BTC', name: 'Bitcoin', decimals: 8 }, + }, + ]); + + const result = await getSlip44ByChainId(999); + + // Should find BTC in COMMON_SYMBOL_DEFAULTS + expect(result).toBe('0'); + }); + }); + + describe('getNativeCaip19', () => { + const mockChains = [ + { + chainId: 1, + name: 'Ethereum Mainnet', + nativeCurrency: { symbol: 'ETH', name: 'Ether', decimals: 18 }, + }, + { + chainId: 56, + name: 'Binance Smart Chain', + nativeCurrency: { symbol: 'BNB', name: 'BNB', decimals: 18 }, + }, + { + chainId: 137, + name: 'Polygon', + nativeCurrency: { symbol: 'POL', name: 'POL', decimals: 18 }, + }, + ]; + + it('returns CAIP-19 format for Ethereum', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getNativeCaip19(1); + + expect(result).toBe('eip155:1/slip44:60'); + }); + + it('returns CAIP-19 format for BNB Chain', async () => { + mockHandleFetch.mockResolvedValueOnce(mockChains); + + const result = await getNativeCaip19(56); + + expect(result).toBe('eip155:56/slip44:714'); + }); + + it('returns null when getSlip44ByChainId fails', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockHandleFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await getNativeCaip19(1); + + expect(result).toBeNull(); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/network-enablement-controller/src/ChainService.ts b/packages/network-enablement-controller/src/ChainService.ts new file mode 100644 index 00000000000..30288dc819d --- /dev/null +++ b/packages/network-enablement-controller/src/ChainService.ts @@ -0,0 +1,147 @@ +import { handleFetch } from '@metamask/controller-utils'; +// @ts-expect-error - JSON imports need resolveJsonModule +import slip44 from '@metamask/slip44/slip44.json'; +import { toCaipAssetType } from '@metamask/utils'; + +/** ---- Types ---- */ +type ChainsJsonItem = { + chainId: number; + name: string; + nativeCurrency?: { symbol?: string; name?: string; decimals?: number }; +}; + +type Slip44Entry = { + index: number; // the coin type (e.g., 60) + symbol?: string; // e.g., 'ETH', 'BNB' + name?: string; // e.g., 'Ethereum' +}; + +/** ---- Constants / Cache ---- */ +const CHAINS_JSON_URL = 'https://chainid.network/chains.json'; +const CACHE_DURATION_MS = 30 * 60 * 1000; // 30 min + +let chainsCache: ChainsJsonItem[] | null = null; +let chainsFetchedAt = 0; +let inflight: Promise | null = null; + +/** Build a quick symbol -> coinType map from slip44 dataset */ +const SLIP44_BY_SYMBOL: Record = (() => { + const map: Record = {}; + (slip44 as Slip44Entry[]).forEach((e) => { + const sym = e.symbol?.toUpperCase(); + if (sym && typeof e.index === 'number' && !(sym in map)) { + map[sym] = e.index; + } + }); + return map; +})(); + +/** Fallbacks for common native symbols */ +const COMMON_SYMBOL_DEFAULTS: Record = { + SOL: 501, + BTC: 0, + ETH: 60, + POL: 966, + MATIC: 966, + BNB: 714, + AVAX: 9000, + SEI: 19000118, + MON: 268435779, + FTM: 1007, + XDAI: 700, +}; + +/** + * Fetch with cache + in-flight dedupe. + * + * @returns The chains JSON data. + */ +async function fetchChains(): Promise { + const now = Date.now(); + if (chainsCache && now - chainsFetchedAt < CACHE_DURATION_MS) { + return chainsCache; + } + if (inflight) { + return inflight; + } + + inflight = (async () => { + const data = (await handleFetch(CHAINS_JSON_URL)) as unknown; + if (!Array.isArray(data)) { + throw new Error('chains.json: unexpected shape'); + } + // Minimal validation; keep what we need + const parsed = data + .filter((x) => x && typeof x === 'object') + .map((x: Record): ChainsJsonItem => { + const nativeCurrency = x.nativeCurrency as + | { symbol?: string; name?: string; decimals?: number } + | undefined; + return { + chainId: Number(x.chainId), + name: x.name as string, + nativeCurrency, + }; + }) + .filter((x) => Number.isFinite(x.chainId)); + + chainsCache = parsed; + chainsFetchedAt = now; + inflight = null; // clear in-flight marker + return parsed; + })(); + + try { + return await inflight; + } catch (e) { + inflight = null; + throw e; + } +} + +/** + * Resolve SLIP-44 coinType for a given chainId (EVM only). + * + * @param chainId - The chain ID to resolve. + * @returns The SLIP-44 coin type as a string, or null if not found. + */ +export async function getSlip44ByChainId( + chainId: number, +): Promise { + try { + // 1) Lookup by native symbol from chains.json + const chains = await fetchChains(); + const chain = chains.find((c) => c.chainId === chainId); + const symbol = chain?.nativeCurrency?.symbol?.toUpperCase(); + + if (symbol) { + // Exact symbol match from slip44 dataset + const coinType = + SLIP44_BY_SYMBOL[symbol] ?? COMMON_SYMBOL_DEFAULTS[symbol]; + if (coinType !== undefined) { + return String(coinType); + } + } + + // 2) Heuristic for ETH-based L2s: default to ETH coin type + // Many L2s (Arbitrum, Optimism, Base, Linea, Scroll, zkSync, etc.) use ETH as native + return '60'; + } catch (err) { + // Prefer null for ergonomics (callers can fallback silently) + console.error(`getSlip44ByChainId(${chainId}) failed:`, err); + return null; + } +} + +/** + * Convenience: native CAIP-19 for an EVM chain. + * + * @param chainId - The chain ID to generate CAIP-19 for. + * @returns The CAIP-19 asset type string, or null if not found. + */ +export async function getNativeCaip19(chainId: number): Promise { + const coinType = await getSlip44ByChainId(chainId); + return coinType + ? toCaipAssetType('eip155', String(chainId), 'slip44', coinType) + : null; +} diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts index ba969af1cc9..a65b47365ee 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -26,6 +26,49 @@ import { NetworkEnablementController } from './NetworkEnablementController'; import type { NetworkEnablementControllerMessenger } from './NetworkEnablementController'; import { advanceTime } from '../../../tests/helpers'; +// Mock the ChainService to prevent network calls during tests +jest.mock('./ChainService', () => ({ + getSlip44ByChainId: jest.fn().mockResolvedValue(null), +})); + +// Helper function to create expected state with slip44 data +const createExpectedState = ( + enabledNetworkMap: Record>, + additionalSlip44: { + eip155?: Record; + } = {}, +) => ({ + enabledNetworkMap, + slip44: { + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: '0', + [BtcScope.Testnet]: '0', + [BtcScope.Signet]: '0', + }, + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: '60', + [ChainId[BuiltInNetworkName.LineaMainnet]]: '69', + [ChainId[BuiltInNetworkName.BaseMainnet]]: '8453', + [ChainId[BuiltInNetworkName.ArbitrumOne]]: '42161', + [ChainId[BuiltInNetworkName.BscMainnet]]: '56', + [ChainId[BuiltInNetworkName.OptimismMainnet]]: '10', + [ChainId[BuiltInNetworkName.PolygonMainnet]]: '966', + [ChainId[BuiltInNetworkName.SeiMainnet]]: '19000118', + ...additionalSlip44.eip155, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: '501', + [SolScope.Testnet]: '501', + [SolScope.Devnet]: '501', + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: '195', + [TrxScope.Shasta]: '195', + [TrxScope.Nile]: '195', + }, + }, +}); + const controllerName = 'NetworkEnablementController'; type AllNetworkEnablementControllerActions = @@ -133,8 +176,8 @@ describe('NetworkEnablementController', () => { it('initializes with default state', () => { const { controller } = setupController(); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: true, [ChainId[BuiltInNetworkName.LineaMainnet]]: true, @@ -160,8 +203,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('subscribes to NetworkController:networkAdded', async () => { @@ -187,36 +230,43 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet - [ChainId[BuiltInNetworkName.LineaMainnet]]: true, // Linea Mainnet - [ChainId[BuiltInNetworkName.BaseMainnet]]: true, // Base Mainnet - [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, - [ChainId[BuiltInNetworkName.BscMainnet]]: true, - [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, - [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, - [ChainId[BuiltInNetworkName.SeiMainnet]]: true, - '0xa86a': true, // Avalanche network added and enabled (keeps current selection) - }, - [KnownCaipNamespace.Solana]: { - [SolScope.Mainnet]: true, - [SolScope.Testnet]: false, - [SolScope.Devnet]: false, - }, - [KnownCaipNamespace.Bip122]: { - [BtcScope.Mainnet]: true, - [BtcScope.Testnet]: false, - [BtcScope.Signet]: false, + expect(controller.state).toStrictEqual( + createExpectedState( + { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet + [ChainId[BuiltInNetworkName.LineaMainnet]]: true, // Linea Mainnet + [ChainId[BuiltInNetworkName.BaseMainnet]]: true, // Base Mainnet + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + '0xa86a': true, // Avalanche network added and enabled (keeps current selection) + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, }, - [KnownCaipNamespace.Tron]: { - [TrxScope.Mainnet]: true, - [TrxScope.Nile]: false, - [TrxScope.Shasta]: false, + { + eip155: { + '0xa86a': undefined, // Avalanche - slip44 not fetched due to mocked null return + }, }, - }, - }); + ), + ); }); it('subscribes to NetworkController:networkRemoved', async () => { @@ -240,8 +290,8 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet [ChainId[BuiltInNetworkName.BaseMainnet]]: true, // Base Mainnet (Linea removed) @@ -266,8 +316,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('handles TransactionController:transactionSubmitted with missing chainId gracefully', async () => { @@ -372,8 +422,8 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet (fallback enabled) [ChainId[BuiltInNetworkName.BaseMainnet]]: false, // Base Mainnet (still disabled) @@ -398,12 +448,12 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); describe('init', () => { - it('initializes network enablement state from controller configurations', () => { + it('initializes network enablement state from controller configurations', async () => { const { controller, messenger } = setupController(); jest @@ -446,12 +496,12 @@ describe('NetworkEnablementController', () => { }); // Initialize from configurations - controller.init(); + await controller.init(); // Should only enable popular networks that exist in NetworkController config // (0x1, 0xe708, 0x2105 exist in default NetworkController mock) - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet (exists in default config) [ChainId[BuiltInNetworkName.LineaMainnet]]: true, // Linea Mainnet (exists in default config) @@ -477,11 +527,11 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); - it('only enables popular networks that exist in NetworkController configurations', () => { + it('only enables popular networks that exist in NetworkController configurations', async () => { // Create a separate controller setup for this test to avoid handler conflicts const { controller, messenger } = setupController({ config: { @@ -529,11 +579,11 @@ describe('NetworkEnablementController', () => { ); // Initialize from configurations - controller.init(); + await controller.init(); // Should only enable networks that exist in configurations - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { '0x1': false, // Ethereum Mainnet (exists in config) '0xe708': false, // Linea Mainnet (exists in config) @@ -542,11 +592,11 @@ describe('NetworkEnablementController', () => { [KnownCaipNamespace.Solana]: { [SolScope.Mainnet]: false, // Solana Mainnet (exists in config) }, - }, - }); + }), + ); }); - it('handles missing MultichainNetworkController gracefully', () => { + it('handles missing MultichainNetworkController gracefully', async () => { const { controller, messenger } = setupController(); jest @@ -578,7 +628,7 @@ describe('NetworkEnablementController', () => { }); // Should not throw - expect(() => controller.init()).not.toThrow(); + expect(await controller.init()).toBeUndefined(); // Should still enable popular networks from NetworkController expect(controller.isNetworkEnabled('0x1')).toBe(true); @@ -586,7 +636,7 @@ describe('NetworkEnablementController', () => { expect(controller.isNetworkEnabled('0x2105')).toBe(true); }); - it('creates namespace buckets for all configured networks', () => { + it('creates namespace buckets for all configured networks', async () => { const { controller, messenger } = setupController(); jest @@ -626,7 +676,7 @@ describe('NetworkEnablementController', () => { throw new Error(`Unexpected action type: ${actionType}`); }); - controller.init(); + await controller.init(); // Should have created namespace buckets for all network types expect(controller.state.enabledNetworkMap).toHaveProperty( @@ -640,7 +690,7 @@ describe('NetworkEnablementController', () => { ); }); - it('creates new namespace buckets for networks that do not exist', () => { + it('creates new namespace buckets for networks that do not exist', async () => { const { controller, messenger } = setupController(); // Start with empty state to test namespace bucket creation @@ -687,7 +737,7 @@ describe('NetworkEnablementController', () => { return responses[actionType as keyof typeof responses]; }); - controller.init(); + await controller.init(); // Should have created namespace buckets for both EIP-155 and Cosmos expect(controller.state.enabledNetworkMap).toHaveProperty( @@ -696,7 +746,7 @@ describe('NetworkEnablementController', () => { expect(controller.state.enabledNetworkMap).toHaveProperty('cosmos'); }); - it('sets Bitcoin testnet to false when it exists in MultichainNetworkController configurations', () => { + it('sets Bitcoin testnet to false when it exists in MultichainNetworkController configurations', async () => { const { controller, messenger } = setupController(); // Mock MultichainNetworkController to include Bitcoin testnet BEFORE calling init @@ -736,7 +786,7 @@ describe('NetworkEnablementController', () => { }); // Initialize the controller to trigger line 378 (init() method sets testnet to false) - controller.init(); + await controller.init(); // Verify Bitcoin testnet is set to false by init() - line 378 expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); @@ -747,7 +797,7 @@ describe('NetworkEnablementController', () => { ).toBe(false); }); - it('sets Bitcoin signet to false when it exists in MultichainNetworkController configurations', () => { + it('sets Bitcoin signet to false when it exists in MultichainNetworkController configurations', async () => { const { controller, messenger } = setupController(); // Mock MultichainNetworkController to include Bitcoin signet BEFORE calling init @@ -787,7 +837,7 @@ describe('NetworkEnablementController', () => { }); // Initialize the controller to trigger line 391 (init() method sets signet to false) - controller.init(); + await controller.init(); // Verify Bitcoin signet is set to false by init() - line 391 expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); @@ -797,6 +847,436 @@ describe('NetworkEnablementController', () => { ], ).toBe(false); }); + + it('only initializes once per controller instance', async () => { + const { controller, messenger } = setupController(); + + const mockCall = jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + }, + networksMetadata: {}, + }; + } + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // First initialization should work + await controller.init(); + + // Verify messenger was called during initialization + expect(mockCall).toHaveBeenCalledWith('NetworkController:getState'); + expect(mockCall).toHaveBeenCalledWith( + 'MultichainNetworkController:getState', + ); + + // Clear mock call history + mockCall.mockClear(); + + // Second initialization should return early and not call messenger again + await controller.init(); + + // Verify messenger was NOT called on second init + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('reinit forces fresh initialization', async () => { + const { controller, messenger } = setupController(); + + const mockCall = jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + }, + networksMetadata: {}, + }; + } + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // First initialization + await controller.init(); + expect(mockCall).toHaveBeenCalledTimes(2); // Both controller calls + + mockCall.mockClear(); + + // Regular init should not call messenger again + await controller.init(); + expect(mockCall).not.toHaveBeenCalled(); + + // But reinit should force fresh initialization + await controller.reinit(); + expect(mockCall).toHaveBeenCalledWith('NetworkController:getState'); + expect(mockCall).toHaveBeenCalledWith( + 'MultichainNetworkController:getState', + ); + expect(mockCall).toHaveBeenCalledTimes(2); + }); + + it('fetches and stores slip44 values for EIP-155 networks during init', async () => { + // Use the mocked getSlip44ByChainId + const { getSlip44ByChainId } = jest.requireMock('./ChainService'); + + // Mock to return specific slip44 values based on chainId + getSlip44ByChainId.mockClear(); + getSlip44ByChainId.mockImplementation(async (chainId: number) => { + // Map chainId to slip44 values + // parseInt('1', 16) = 1 + // parseInt('a86a', 16) = 43114 + const slip44Values: { [key: number]: string } = { + 1: '60', // ETH + 43114: '9000', // AVAX + }; + // eslint-disable-next-line jest/no-conditional-in-test + return slip44Values[chainId] !== undefined + ? slip44Values[chainId] + : null; + }); + + const { controller, messenger } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + slip44: { + [KnownCaipNamespace.Eip155]: {}, + }, + }, + }, + }); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + '0xa86a': { chainId: '0xa86a', name: 'Avalanche' }, // parseInt('a86a', 16) = 43114 + }, + networksMetadata: {}, + }; + } + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + await controller.init(); + + // Verify slip44 fetch was called + expect(getSlip44ByChainId).toHaveBeenCalled(); + + // Verify at least one slip44 value was fetched and stored for chainId 0x1 + expect(getSlip44ByChainId).toHaveBeenCalledWith(1); + expect(controller.state.slip44[KnownCaipNamespace.Eip155]['0x1']).toBe( + '60', + ); + + // Restore mock + getSlip44ByChainId.mockResolvedValue(null); + }); + + it('handles errors during slip44 fetch in init gracefully', async () => { + const { getSlip44ByChainId } = jest.requireMock('./ChainService'); + + // Mock to throw an error + getSlip44ByChainId.mockClear(); + getSlip44ByChainId.mockRejectedValueOnce(new Error('Network error')); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { controller, messenger } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + slip44: { + [KnownCaipNamespace.Eip155]: {}, + }, + }, + }, + }); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + }, + networksMetadata: {}, + }; + } + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // Should not throw even if slip44 fetch fails + await controller.init(); + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch slip44 for chainId'), + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + getSlip44ByChainId.mockResolvedValue(null); + }); + + it('handles batch slip44 fetch errors gracefully', async () => { + const { getSlip44ByChainId } = jest.requireMock('./ChainService'); + + // Mock to simulate error in batch processing + getSlip44ByChainId.mockClear(); + getSlip44ByChainId.mockImplementation(async () => { + throw new Error('Batch processing error'); + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { controller, messenger } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + slip44: { + [KnownCaipNamespace.Eip155]: {}, + }, + }, + }, + }); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + '0x38': { chainId: '0x38', name: 'BSC' }, + }, + networksMetadata: {}, + }; + } + // eslint-disable-next-line jest/no-conditional-in-test + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // Should complete successfully despite errors + await controller.init(); + + // Verify errors were logged for each failed fetch + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch slip44'), + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + getSlip44ByChainId.mockResolvedValue(null); + }); + + // Note: Line 522 (`console.error('Error during slip44 batch fetch:', error);`) is + // defensive code that's extremely difficult to cover in tests. It's an outer catch block + // that wraps Promise.all and the results processing. Since all individual promise errors + // are already caught inside each promise (lines 499-503), this outer catch would only + // execute if something catastrophic happened in the Promise.all itself or the results + // processing that's outside the promise error handlers. This is practically impossible + // to trigger without breaking the entire test suite by mocking core JavaScript functions + // like Promise.all or Array.prototype.forEach. + }); + + describe('networkAdded subscription error handling', () => { + it('logs console error when networkAdded handler throws', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { rootMessenger } = setupController(); + + // Publish network added event with invalid chainId format that will cause deriveKeys to throw + // Using a chainId with special characters that would break parsing + rootMessenger.publish('NetworkController:networkAdded', { + // @ts-expect-error - intentionally passing invalid chainId to trigger error + chainId: null, + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Invalid Network', + nativeCurrency: 'TEST', + rpcEndpoints: [ + { + url: 'https://test-rpc.com', + networkClientId: 'test-network', + type: RpcEndpointType.Custom, + }, + ], + }); + + // Wait for async handler to complete + await advanceTime({ clock, duration: 10 }); + + // Verify error was logged - line 227 + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error adding network:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('handles slip44 fetch failure in onAddNetwork gracefully', async () => { + const { getSlip44ByChainId } = jest.requireMock('./ChainService'); + + // Mock to fail for specific chainId + getSlip44ByChainId.mockClear(); + getSlip44ByChainId.mockRejectedValueOnce( + new Error('Slip44 fetch failed'), + ); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { controller, rootMessenger } = setupController(); + + // Publish network added event + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa4b1', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://arb1.arbitrum.io/rpc', + networkClientId: 'arbitrum-one', + type: RpcEndpointType.Custom, + }, + ], + }); + + // Wait for async handler + await advanceTime({ clock, duration: 10 }); + + // Verify error was logged - line 700 + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch slip44 for chainId'), + expect.any(Error), + ); + + // Network should still be added despite slip44 failure + expect(controller.isNetworkEnabled('0xa4b1')).toBe(true); + + consoleErrorSpy.mockRestore(); + getSlip44ByChainId.mockResolvedValue(null); + }); + + it('fetches and stores slip44 value when adding an EIP-155 network', async () => { + const { getSlip44ByChainId } = jest.requireMock('./ChainService'); + + // Mock to return slip44 value for Arbitrum One (chainId 42161) + getSlip44ByChainId.mockClear(); + getSlip44ByChainId.mockResolvedValueOnce('42161'); + + const { controller, rootMessenger } = setupController(); + + // Publish network added event for Arbitrum One + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa4b1', // Arbitrum One + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://arb1.arbitrum.io/rpc', + networkClientId: 'arbitrum-one', + type: RpcEndpointType.Custom, + }, + ], + }); + + // Wait for async handler to complete + await advanceTime({ clock, duration: 10 }); + + // Verify getSlip44ByChainId was called with correct chainId (42161 in decimal) + expect(getSlip44ByChainId).toHaveBeenCalledWith(42161); + + // Verify slip44 value was stored in state + expect(controller.state.slip44[KnownCaipNamespace.Eip155]['0xa4b1']).toBe( + '42161', + ); + + // Network should also be enabled + expect(controller.isNetworkEnabled('0xa4b1')).toBe(true); + + // Restore mock + getSlip44ByChainId.mockResolvedValue(null); + }); }); describe('enableAllPopularNetworks', () => { @@ -850,8 +1330,8 @@ describe('NetworkEnablementController', () => { controller.disableNetwork('0xe708'); // Linea controller.disableNetwork('0x2105'); // Base - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { '0x1': true, // Ethereum Mainnet '0xe708': false, // Linea Mainnet (disabled) @@ -877,14 +1357,14 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); // Enable all popular networks controller.enableAllPopularNetworks(); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { '0x1': true, // Ethereum Mainnet '0xe708': true, // Linea Mainnet @@ -910,8 +1390,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('enables all popular networks from constants', () => { @@ -974,8 +1454,8 @@ describe('NetworkEnablementController', () => { {}, ); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: expectedEip155Networks, [KnownCaipNamespace.Solana]: { [SolScope.Mainnet]: true, // Solana Mainnet @@ -992,8 +1472,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('disables existing networks and enables only popular networks (exclusive behavior)', async () => { @@ -1134,8 +1614,8 @@ describe('NetworkEnablementController', () => { // Disable a popular network (Ethereum Mainnet) controller.disableNetwork('0x1'); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { '0x1': false, // Ethereum Mainnet (disabled) '0xe708': true, // Linea Mainnet @@ -1161,14 +1641,14 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); // Enable the network again - this should disable all others in all namespaces controller.enableNetwork('0x1'); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet (re-enabled) [ChainId[BuiltInNetworkName.LineaMainnet]]: false, // Linea Mainnet (disabled) @@ -1194,8 +1674,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('enables any network and clears all others (exclusive behavior)', async () => { @@ -1219,104 +1699,125 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': false, - '0xe708': false, - '0x2105': false, - '0x2': true, - [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, - [ChainId[BuiltInNetworkName.BscMainnet]]: false, - [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, - [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, - [ChainId[BuiltInNetworkName.SeiMainnet]]: false, - }, - [KnownCaipNamespace.Solana]: { - [SolScope.Mainnet]: false, // Disabled due to cross-namespace behavior - [SolScope.Testnet]: false, - [SolScope.Devnet]: false, - }, - [KnownCaipNamespace.Bip122]: { - [BtcScope.Mainnet]: false, // Disabled due to cross-namespace behavior - [BtcScope.Testnet]: false, - [BtcScope.Signet]: false, + expect(controller.state).toStrictEqual( + createExpectedState( + { + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xe708': false, + '0x2105': false, + '0x2': true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Disabled due to cross-namespace behavior + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Disabled due to cross-namespace behavior + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, }, - [KnownCaipNamespace.Tron]: { - [TrxScope.Mainnet]: false, - [TrxScope.Nile]: false, - [TrxScope.Shasta]: false, + { + eip155: { + '0x2': undefined, // Network '0x2' doesn't have slip44 data + }, }, - }, - }); + ), + ); // Enable one of the popular networks - only this one will be enabled controller.enableNetwork('0x2105'); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': false, - '0xe708': false, - '0x2105': true, - '0x2': false, - [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, - [ChainId[BuiltInNetworkName.BscMainnet]]: false, - [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, - [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, - [ChainId[BuiltInNetworkName.SeiMainnet]]: false, - }, - [KnownCaipNamespace.Solana]: { - [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) - [SolScope.Testnet]: false, - [SolScope.Devnet]: false, - }, - [KnownCaipNamespace.Bip122]: { - [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) - [BtcScope.Testnet]: false, - [BtcScope.Signet]: false, + expect(controller.state).toStrictEqual( + createExpectedState( + { + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xe708': false, + '0x2105': true, + '0x2': false, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, }, - [KnownCaipNamespace.Tron]: { - [TrxScope.Mainnet]: false, - [TrxScope.Nile]: false, - [TrxScope.Shasta]: false, + { + eip155: { + '0x2': undefined, // Network '0x2' doesn't have slip44 data + }, }, - }, - }); + ), + ); // Enable the non-popular network again - it will disable all others controller.enableNetwork('0x2'); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': false, - '0xe708': false, - '0x2105': false, - '0x2': true, - [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, - [ChainId[BuiltInNetworkName.BscMainnet]]: false, - [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, - [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, - [ChainId[BuiltInNetworkName.SeiMainnet]]: false, - }, - [KnownCaipNamespace.Solana]: { - [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) - [SolScope.Testnet]: false, - [SolScope.Devnet]: false, - }, - [KnownCaipNamespace.Bip122]: { - [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) - [BtcScope.Testnet]: false, - [BtcScope.Signet]: false, + expect(controller.state).toStrictEqual( + createExpectedState( + { + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xe708': false, + '0x2105': false, + '0x2': true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, }, - [KnownCaipNamespace.Tron]: { - [TrxScope.Mainnet]: false, - [TrxScope.Nile]: false, - [TrxScope.Shasta]: false, + { + eip155: { + '0x2': undefined, // Network '0x2' doesn't have slip44 data + }, }, - }, - }); + ), + ); }); it('handles invalid chain ID gracefully', () => { @@ -1333,8 +1834,8 @@ describe('NetworkEnablementController', () => { controller.enableNetwork('bip122:000000000019d6689c085ae165831e93'); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: false, // Disabled due to cross-namespace behavior [ChainId[BuiltInNetworkName.LineaMainnet]]: false, // Disabled due to cross-namespace behavior @@ -1360,8 +1861,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('handles enabling a network in non-existent namespace gracefully', () => { @@ -1377,8 +1878,8 @@ describe('NetworkEnablementController', () => { controller.enableNetwork('bip122:000000000933ea01ad0ee984209779ba'); // All existing networks should be disabled due to cross-namespace behavior, even though target network couldn't be enabled - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: false, [ChainId[BuiltInNetworkName.LineaMainnet]]: false, @@ -1399,8 +1900,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('handle no namespace bucket', async () => { @@ -1425,8 +1926,8 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { [ChainId[BuiltInNetworkName.Mainnet]]: false, // Disabled due to cross-namespace behavior [ChainId[BuiltInNetworkName.LineaMainnet]]: false, // Disabled due to cross-namespace behavior @@ -1452,8 +1953,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); }); @@ -1464,8 +1965,8 @@ describe('NetworkEnablementController', () => { // Disable a network (but not the last one) controller.disableNetwork('0xe708'); // Linea Mainnet - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { '0x1': true, '0xe708': false, @@ -1491,8 +1992,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); }); it('does disable a Solana network using CAIP chain ID as it is the only enabled network on the namespace', () => { @@ -1511,8 +2012,8 @@ describe('NetworkEnablementController', () => { controller.disableNetwork('0xe708'); // Linea Mainnet controller.disableNetwork('0x2105'); // Base Mainnet - expect(controller.state).toStrictEqual({ - enabledNetworkMap: { + expect(controller.state).toStrictEqual( + createExpectedState({ [KnownCaipNamespace.Eip155]: { '0x1': true, '0xe708': false, @@ -1538,8 +2039,8 @@ describe('NetworkEnablementController', () => { [TrxScope.Nile]: false, [TrxScope.Shasta]: false, }, - }, - }); + }), + ); // Try to disable the last active network expect(() => controller.disableNetwork('0x1')).not.toThrow(); @@ -2507,6 +3008,33 @@ describe('NetworkEnablementController', () => { "tron:728126428": true, }, }, + "slip44": Object { + "bip122": Object { + "bip122:000000000019d6689c085ae165831e93": "0", + "bip122:000000000933ea01ad0ee984209779ba": "0", + "bip122:00000008819873e925422c1ff0f99f7c": "0", + }, + "eip155": Object { + "0x1": "60", + "0x2105": "8453", + "0x38": "56", + "0x531": "19000118", + "0x89": "966", + "0xa": "10", + "0xa4b1": "42161", + "0xe708": "69", + }, + "solana": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": "501", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "501", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "501", + }, + "tron": Object { + "tron:2494104990": "195", + "tron:3448148188": "195", + "tron:728126428": "195", + }, + }, } `); }); @@ -2549,6 +3077,33 @@ describe('NetworkEnablementController', () => { "tron:728126428": true, }, }, + "slip44": Object { + "bip122": Object { + "bip122:000000000019d6689c085ae165831e93": "0", + "bip122:000000000933ea01ad0ee984209779ba": "0", + "bip122:00000008819873e925422c1ff0f99f7c": "0", + }, + "eip155": Object { + "0x1": "60", + "0x2105": "8453", + "0x38": "56", + "0x531": "19000118", + "0x89": "966", + "0xa": "10", + "0xa4b1": "42161", + "0xe708": "69", + }, + "solana": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": "501", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "501", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "501", + }, + "tron": Object { + "tron:2494104990": "195", + "tron:3448148188": "195", + "tron:728126428": "195", + }, + }, } `); }); @@ -2591,6 +3146,33 @@ describe('NetworkEnablementController', () => { "tron:728126428": true, }, }, + "slip44": Object { + "bip122": Object { + "bip122:000000000019d6689c085ae165831e93": "0", + "bip122:000000000933ea01ad0ee984209779ba": "0", + "bip122:00000008819873e925422c1ff0f99f7c": "0", + }, + "eip155": Object { + "0x1": "60", + "0x2105": "8453", + "0x38": "56", + "0x531": "19000118", + "0x89": "966", + "0xa": "10", + "0xa4b1": "42161", + "0xe708": "69", + }, + "solana": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": "501", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "501", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "501", + }, + "tron": Object { + "tron:2494104990": "195", + "tron:3448148188": "195", + "tron:728126428": "195", + }, + }, } `); }); @@ -2633,6 +3215,33 @@ describe('NetworkEnablementController', () => { "tron:728126428": true, }, }, + "slip44": Object { + "bip122": Object { + "bip122:000000000019d6689c085ae165831e93": "0", + "bip122:000000000933ea01ad0ee984209779ba": "0", + "bip122:00000008819873e925422c1ff0f99f7c": "0", + }, + "eip155": Object { + "0x1": "60", + "0x2105": "8453", + "0x38": "56", + "0x531": "19000118", + "0x89": "966", + "0xa": "10", + "0xa4b1": "42161", + "0xe708": "69", + }, + "solana": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": "501", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "501", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "501", + }, + "tron": Object { + "tron:2494104990": "195", + "tron:3448148188": "195", + "tron:728126428": "195", + }, + }, } `); }); diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.ts b/packages/network-enablement-controller/src/NetworkEnablementController.ts index 7c85a955407..5f793a4f687 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.ts @@ -17,6 +17,7 @@ import type { TransactionControllerTransactionSubmittedEvent } from '@metamask/t import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; import { KnownCaipNamespace } from '@metamask/utils'; +import { getSlip44ByChainId } from './ChainService'; import { POPULAR_NETWORKS } from './constants'; import { deriveKeys, @@ -46,6 +47,7 @@ type EnabledMap = Record>; // State shape for NetworkEnablementController export type NetworkEnablementControllerState = { enabledNetworkMap: EnabledMap; + slip44: Record>; }; export type NetworkEnablementControllerGetStateAction = @@ -134,6 +136,33 @@ const getDefaultNetworkEnablementControllerState = [TrxScope.Shasta]: false, }, }, + slip44: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: '60', // ETH + [ChainId[BuiltInNetworkName.LineaMainnet]]: '69', // LINEA + [ChainId[BuiltInNetworkName.BaseMainnet]]: '8453', // BASE + [ChainId[BuiltInNetworkName.ArbitrumOne]]: '42161', // ARBITRUM + [ChainId[BuiltInNetworkName.BscMainnet]]: '56', // BSC + [ChainId[BuiltInNetworkName.OptimismMainnet]]: '10', // OPTIMISM + [ChainId[BuiltInNetworkName.PolygonMainnet]]: '966', // POLYGON + [ChainId[BuiltInNetworkName.SeiMainnet]]: '19000118', // SEI + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: '501', // SOL + [SolScope.Testnet]: '501', // SOL + [SolScope.Devnet]: '501', // SOL + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: '0', // BTC + [BtcScope.Testnet]: '0', // BTC + [BtcScope.Signet]: '0', // BTC + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: '195', // TRX + [TrxScope.Nile]: '195', // TRX + [TrxScope.Shasta]: '195', // TRX + }, + }, }); // Metadata for the controller state @@ -144,6 +173,12 @@ const metadata = { includeInDebugSnapshot: true, usedInUi: true, }, + slip44: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; /** @@ -160,6 +195,8 @@ export class NetworkEnablementController extends BaseController< NetworkEnablementControllerState, NetworkEnablementControllerMessenger > { + #initialized = false; + /** * Creates a NetworkEnablementController instance. * @@ -185,7 +222,10 @@ export class NetworkEnablementController extends BaseController< }); messenger.subscribe('NetworkController:networkAdded', ({ chainId }) => { - this.#onAddNetwork(chainId); + // Handle async network addition + this.#onAddNetwork(chainId).catch((error) => { + console.error('Error adding network:', error); + }); }); messenger.subscribe('NetworkController:networkRemoved', ({ chainId }) => { @@ -261,8 +301,9 @@ export class NetworkEnablementController extends BaseController< } this.update((s) => { - // Ensure the namespace bucket exists + // Ensure the namespace buckets exist this.#ensureNamespaceBucket(s, namespace); + this.#ensureSlip44NamespaceBucket(s, namespace); // Disable all networks in the specified namespace first if (s.enabledNetworkMap[namespace]) { @@ -311,8 +352,9 @@ export class NetworkEnablementController extends BaseController< if ( networkControllerState.networkConfigurationsByChainId[chainId as Hex] ) { - // Ensure namespace bucket exists + // Ensure namespace buckets exist this.#ensureNamespaceBucket(s, namespace); + this.#ensureSlip44NamespaceBucket(s, namespace); // Enable the network s.enabledNetworkMap[namespace][storageKey] = true; } @@ -325,8 +367,9 @@ export class NetworkEnablementController extends BaseController< SolScope.Mainnet ] ) { - // Ensure namespace bucket exists + // Ensure namespace buckets exist this.#ensureNamespaceBucket(s, solanaKeys.namespace); + this.#ensureSlip44NamespaceBucket(s, solanaKeys.namespace); // Enable Solana mainnet s.enabledNetworkMap[solanaKeys.namespace][solanaKeys.storageKey] = true; } @@ -338,8 +381,9 @@ export class NetworkEnablementController extends BaseController< BtcScope.Mainnet ] ) { - // Ensure namespace bucket exists + // Ensure namespace buckets exist this.#ensureNamespaceBucket(s, bitcoinKeys.namespace); + this.#ensureSlip44NamespaceBucket(s, bitcoinKeys.namespace); // Enable Bitcoin mainnet s.enabledNetworkMap[bitcoinKeys.namespace][bitcoinKeys.storageKey] = true; @@ -352,8 +396,9 @@ export class NetworkEnablementController extends BaseController< TrxScope.Mainnet ] ) { - // Ensure namespace bucket exists + // Ensure namespace buckets exist this.#ensureNamespaceBucket(s, tronKeys.namespace); + this.#ensureSlip44NamespaceBucket(s, tronKeys.namespace); // Enable Tron mainnet s.enabledNetworkMap[tronKeys.namespace][tronKeys.storageKey] = true; } @@ -367,28 +412,37 @@ export class NetworkEnablementController extends BaseController< * and MultichainNetworkController and syncs the enabled network map accordingly. * It ensures proper namespace buckets exist for all configured networks and only * adds missing networks with a default value of false, preserving existing user settings. + * Additionally, it fetches slip44 values for EIP-155 networks that don't have them. + * + * This method only runs once per controller instance to avoid unnecessary API calls. + * Subsequent calls will return immediately without performing any operations. + * Use `reinit()` if you need to force re-initialization. * * This method should be called after the NetworkController and MultichainNetworkController * have been initialized and their configurations are available. */ - init(): void { - this.update((s) => { - // Get network configurations from NetworkController (EVM networks) - const networkControllerState = this.messenger.call( - 'NetworkController:getState', - ); + async init(): Promise { + if (this.#initialized) { + return; + } - // Get network configurations from MultichainNetworkController (all networks) - const multichainState = this.messenger.call( - 'MultichainNetworkController:getState', - ); + // Get network configurations + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + const multichainState = this.messenger.call( + 'MultichainNetworkController:getState', + ); + // First, initialize the state synchronously + this.update((s) => { // Initialize namespace buckets for EVM networks from NetworkController Object.keys( networkControllerState.networkConfigurationsByChainId, ).forEach((chainId) => { const { namespace, storageKey } = deriveKeys(chainId as Hex); this.#ensureNamespaceBucket(s, namespace); + this.#ensureSlip44NamespaceBucket(s, namespace); // Only add network if it doesn't already exist in state (preserves user settings) if (s.enabledNetworkMap[namespace][storageKey] === undefined) { @@ -402,6 +456,7 @@ export class NetworkEnablementController extends BaseController< ).forEach((chainId) => { const { namespace, storageKey } = deriveKeys(chainId as CaipChainId); this.#ensureNamespaceBucket(s, namespace); + this.#ensureSlip44NamespaceBucket(s, namespace); // Only add network if it doesn't already exist in state (preserves user settings) if (s.enabledNetworkMap[namespace][storageKey] === undefined) { @@ -409,6 +464,74 @@ export class NetworkEnablementController extends BaseController< } }); }); + + // Collect EIP-155 networks that need slip44 values + const networksToFetch: { + chainId: string; + storageKey: string; + numericChainId: number; + }[] = []; + + Object.keys(networkControllerState.networkConfigurationsByChainId).forEach( + (chainId) => { + const { namespace, storageKey, reference } = deriveKeys(chainId as Hex); + + if (namespace === 'eip155') { + // Check if slip44 value already exists in state + const existingSlip44 = this.state.slip44[namespace]?.[storageKey]; + if (!existingSlip44) { + const numericChainId = parseInt(reference, 16); + networksToFetch.push({ chainId, storageKey, numericChainId }); + } + } + }, + ); + + // Fetch slip44 values for networks that don't have them + if (networksToFetch.length > 0) { + const slip44Promises = networksToFetch.map( + async ({ chainId, storageKey, numericChainId }) => { + try { + const slip44Value = await getSlip44ByChainId(numericChainId); + return { chainId, storageKey, slip44Value }; + } catch (error) { + console.error( + `Failed to fetch slip44 for chainId ${chainId}:`, + error, + ); + return { chainId, storageKey, slip44Value: null }; + } + }, + ); + + const results = await Promise.all(slip44Promises); + + // Update state with fetched slip44 values + this.update((s) => { + results.forEach(({ storageKey, slip44Value }) => { + if (slip44Value !== null) { + // Ensure namespace exists (should already exist from above) + this.#ensureSlip44NamespaceBucket(s, 'eip155'); + // @ts-expect-error - TypeScript doesn't recognize the dynamic namespace access + s.slip44.eip155[storageKey] = slip44Value; + } + }); + }); + } + + this.#initialized = true; + } + + /** + * Re-initializes the controller's state. + * + * This method forces a fresh initialization even if the controller has already been initialized. + * It will re-fetch slip44 values for all EIP-155 networks and re-sync the network state. + * Use this when you need to force a full re-initialization. + */ + async reinit(): Promise { + this.#initialized = false; + await this.init(); } /** @@ -467,6 +590,25 @@ export class NetworkEnablementController extends BaseController< } } + /** + * Ensures that a namespace bucket exists in the slip44 state. + * + * This method creates the namespace entry in the slip44 map if it doesn't + * already exist. This is used to prepare the state structure before adding + * slip44 entries. + * + * @param state - The current controller state + * @param ns - The CAIP namespace to ensure exists + */ + #ensureSlip44NamespaceBucket( + state: NetworkEnablementControllerState, + ns: CaipNamespace, + ) { + if (!state.slip44[ns]) { + state.slip44[ns] = {}; + } + } + /** * Checks if popular networks mode is active (more than 2 popular networks enabled). * @@ -538,13 +680,32 @@ export class NetworkEnablementController extends BaseController< * - Keep current selection (add but don't enable the new network) * - Otherwise: * - Switch to the newly added network (disable all others, enable this one) + * - Fetches and stores slip44 value for EIP-155 networks */ - #onAddNetwork(chainId: Hex | CaipChainId): void { + async #onAddNetwork(chainId: Hex | CaipChainId): Promise { const { namespace, storageKey, reference } = deriveKeys(chainId); + // Fetch slip44 for EIP-155 networks + let slip44Value: string | null = null; + if (namespace === 'eip155') { + try { + // Convert hex chainId to decimal for the API call + const numericChainId = parseInt(reference); + slip44Value = await getSlip44ByChainId(numericChainId); + } catch (error) { + console.error(`Failed to fetch slip44 for chainId ${chainId}:`, error); + } + } + this.update((s) => { - // Ensure the namespace bucket exists + // Ensure the namespace buckets exist this.#ensureNamespaceBucket(s, namespace); + this.#ensureSlip44NamespaceBucket(s, namespace); + + // Add slip44 value if fetched successfully + if (slip44Value !== null) { + s.slip44[namespace][storageKey] = slip44Value; + } // Check if popular networks mode is active (>2 popular networks enabled) const inPopularNetworksMode = this.#isInPopularNetworksMode(); diff --git a/packages/network-enablement-controller/src/index.ts b/packages/network-enablement-controller/src/index.ts index 95c066a1f11..3abb257a78e 100644 --- a/packages/network-enablement-controller/src/index.ts +++ b/packages/network-enablement-controller/src/index.ts @@ -17,3 +17,5 @@ export { selectEnabledEvmNetworks, selectEnabledSolanaNetworks, } from './selectors'; + +export { getSlip44ByChainId } from './ChainService'; diff --git a/packages/network-enablement-controller/src/selectors.test.ts b/packages/network-enablement-controller/src/selectors.test.ts index 235e9d53608..db91e9c8804 100644 --- a/packages/network-enablement-controller/src/selectors.test.ts +++ b/packages/network-enablement-controller/src/selectors.test.ts @@ -24,6 +24,17 @@ describe('NetworkEnablementController Selectors', () => { 'solana:testnet': false, }, }, + slip44: { + [KnownCaipNamespace.Eip155]: { + '0x1': '60', // Ethereum + '0xa': '10', // Optimism + '0xa4b1': '42161', // Arbitrum One + }, + [KnownCaipNamespace.Solana]: { + 'solana:mainnet': '501', + 'solana:testnet': '501', + }, + }, }; describe('selectEnabledNetworkMap', () => { diff --git a/packages/network-enablement-controller/src/utils.test.ts b/packages/network-enablement-controller/src/utils.test.ts index 56e0f75e558..df642d2e44c 100644 --- a/packages/network-enablement-controller/src/utils.test.ts +++ b/packages/network-enablement-controller/src/utils.test.ts @@ -74,6 +74,7 @@ describe('Utils', () => { enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], ): NetworkEnablementControllerState => ({ enabledNetworkMap, + slip44: {}, }); describe('EVM namespace scenarios', () => { diff --git a/yarn.lock b/yarn.lock index 700bcfaeb58..6568c74ca02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4352,6 +4352,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/multichain-network-controller": "npm:^2.0.0" "@metamask/network-controller": "npm:^25.0.0" + "@metamask/slip44": "npm:^4.2.0" "@metamask/transaction-controller": "npm:^61.1.0" "@metamask/utils": "npm:^11.8.1" "@types/jest": "npm:^27.4.1" @@ -4896,9 +4897,9 @@ __metadata: linkType: soft "@metamask/slip44@npm:^4.2.0": - version: 4.2.0 - resolution: "@metamask/slip44@npm:4.2.0" - checksum: 10/262c671647776afd66fff4d70206400ecfe576c40a38b32e2d21744f2f65dc117af194a9e2f611e389851a9ccf7b2f2f939521f555c5fdb8c4bc70508f5b99e8 + version: 4.3.0 + resolution: "@metamask/slip44@npm:4.3.0" + checksum: 10/508983a48911f2be8d9de117d390ecfb5b949a6032f5d6c5cc63f7f23302b87468be6ff08dee4881d39e8f5f66b5545eab15e6fc0511acea10fd4c99852a8212 languageName: node linkType: hard