diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 72db8441213..d097f868f07 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1477,17 +1477,6 @@ "count": 1 } }, - "packages/network-enablement-controller/src/NetworkEnablementController.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 2 - }, - "id-length": { - "count": 7 - } - }, "packages/network-enablement-controller/src/selectors.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 3 diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 1cfcb5e3d9e..0b13c45f148 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `nativeAssetIdentifiers` state property that maps CAIP-2 chain IDs to CAIP-19-like native asset identifiers (e.g., `eip155:1/slip44:60`) ([#7609](https://github.com/MetaMask/core/pull/7609)) +- Add `Slip44Service` to look up SLIP-44 coin types by native currency symbol ([#7609](https://github.com/MetaMask/core/pull/7609)) +- Add `@metamask/slip44` dependency for SLIP-44 coin type lookups ([#7609](https://github.com/MetaMask/core/pull/7609)) +- Subscribe to `NetworkController:stateChange` to update `nativeAssetIdentifiers` when a network's native currency changes ([#7609](https://github.com/MetaMask/core/pull/7609)) + ### Changed - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index 6e374e041a8..e410362118b 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -54,6 +54,7 @@ "@metamask/messenger": "^0.3.0", "@metamask/multichain-network-controller": "^3.0.1", "@metamask/network-controller": "^28.0.0", + "@metamask/slip44": "^4.3.0", "@metamask/transaction-controller": "^62.9.1", "@metamask/utils": "^11.9.0", "reselect": "^5.1.1" diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts index 53e1adad218..18b92a0826a 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -7,6 +7,7 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { RpcEndpointType } from '@metamask/network-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; @@ -16,11 +17,51 @@ import { useFakeTimers } from 'sinon'; import { POPULAR_NETWORKS } from './constants'; import { NetworkEnablementController } from './NetworkEnablementController'; -import type { NetworkEnablementControllerMessenger } from './NetworkEnablementController'; +import type { + NetworkEnablementControllerMessenger, + NativeAssetIdentifiersMap, +} from './NetworkEnablementController'; +import { Slip44Service } from './services'; import { advanceTime } from '../../../tests/helpers'; +// Mock Slip44Service.getSlip44ByChainId to avoid network calls +jest + .spyOn(Slip44Service, 'getSlip44ByChainId') + .mockImplementation(async (chainId: number, symbol?: string) => { + // Known chainId mappings from chainid.network + const chainIdToSlip44: Record = { + 1: 60, // Ethereum + 10: 60, // Optimism + 56: 714, // BNB Chain + 137: 966, // Polygon + 43114: 9000, // Avalanche + 42161: 60, // Arbitrum + 8453: 60, // Base + 59144: 60, // Linea + 1329: 60, // Sei (uses ETH as native) + }; + if (chainIdToSlip44[chainId] !== undefined) { + return chainIdToSlip44[chainId]; + } + // Fall back to symbol lookup if chainId not found + if (symbol) { + return Slip44Service.getSlip44BySymbol(symbol); + } + return undefined; + }); + const controllerName = 'NetworkEnablementController'; +/** + * Returns the default nativeAssetIdentifiers state for testing. + * + * @returns The default nativeAssetIdentifiers with all pre-configured networks. + */ +// Default nativeAssetIdentifiers is empty - should be populated by client using initNativeAssetIdentifiers() +function getDefaultNativeAssetIdentifiers(): NativeAssetIdentifiersMap { + return {}; +} + type AllNetworkEnablementControllerActions = MessengerActions; @@ -76,6 +117,7 @@ const setupController = ({ events: [ 'NetworkController:networkAdded', 'NetworkController:networkRemoved', + 'NetworkController:stateChange', 'TransactionController:transactionSubmitted', ], }); @@ -154,6 +196,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -209,6 +252,10 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:43114': 'eip155:43114/slip44:9000', // AVAX + }, }); }); @@ -233,6 +280,14 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); + // Create expected nativeAssetIdentifiers without Linea + const expectedNativeAssetIdentifiers = { + ...getDefaultNativeAssetIdentifiers(), + }; + delete expectedNativeAssetIdentifiers[ + toEvmCaipChainId(ChainId[BuiltInNetworkName.LineaMainnet]) + ]; + expect(controller.state).toStrictEqual({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: { @@ -260,6 +315,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: expectedNativeAssetIdentifiers, }); }); @@ -365,6 +421,14 @@ describe('NetworkEnablementController', () => { await advanceTime({ clock, duration: 1 }); + // Create expected nativeAssetIdentifiers without Linea + const expectedNativeAssetIdentifiersForFallback = { + ...getDefaultNativeAssetIdentifiers(), + }; + delete expectedNativeAssetIdentifiersForFallback[ + toEvmCaipChainId(ChainId[BuiltInNetworkName.LineaMainnet]) + ]; + expect(controller.state).toStrictEqual({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: { @@ -392,11 +456,12 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: expectedNativeAssetIdentifiersForFallback, }); }); describe('init', () => { - it('initializes network enablement state from controller configurations', () => { + it('initializes network enablement state from controller configurations', async () => { const { controller, messenger } = setupController(); jest @@ -407,9 +472,21 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, - '0xe708': { chainId: '0xe708', name: 'Linea Mainnet' }, - '0x2105': { chainId: '0x2105', name: 'Base Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -417,15 +494,25 @@ describe('NetworkEnablementController', () => { if (actionType === 'MultichainNetworkController:getState') { return { multichainNetworkConfigurationsByChainId: { - 'eip155:1': { chainId: 'eip155:1', name: 'Ethereum Mainnet' }, + 'eip155:1': { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, 'eip155:59144': { chainId: 'eip155:59144', name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + 'eip155:8453': { + chainId: 'eip155:8453', + name: 'Base Mainnet', + nativeCurrency: 'ETH', }, - 'eip155:8453': { chainId: 'eip155:8453', name: 'Base Mainnet' }, 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, }, selectedMultichainNetworkChainId: 'eip155:1', @@ -437,7 +524,7 @@ 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) @@ -469,10 +556,16 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + // init() populates nativeAssetIdentifiers from NetworkController (EVM networks only) + nativeAssetIdentifiers: { + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:59144': 'eip155:59144/slip44:60', + 'eip155:8453': 'eip155:8453/slip44:60', + }, }); }); - 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: { @@ -481,6 +574,7 @@ describe('NetworkEnablementController', () => { [KnownCaipNamespace.Eip155]: {}, [KnownCaipNamespace.Solana]: {}, }, + nativeAssetIdentifiers: {}, }, }, }); @@ -492,8 +586,16 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, - '0xe708': { chainId: '0xe708', name: 'Linea Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, // Missing other popular networks }, networksMetadata: {}, @@ -505,6 +607,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, }, selectedMultichainNetworkChainId: @@ -518,7 +621,7 @@ describe('NetworkEnablementController', () => { ); // Initialize from configurations - controller.init(); + await controller.init(); // Should only enable networks that exist in configurations expect(controller.state).toStrictEqual({ @@ -532,10 +635,16 @@ describe('NetworkEnablementController', () => { [SolScope.Mainnet]: false, // Solana Mainnet (exists in config) }, }, + nativeAssetIdentifiers: { + 'eip155:1': 'eip155:1/slip44:60', // ETH + 'eip155:59144': 'eip155:59144/slip44:60', // ETH (Linea uses ETH) + // Multichain networks don't populate nativeAssetIdentifiers in init() because + // the mock doesn't include the required nativeCurrency for non-EVM networks + }, }); }); - it('handles missing MultichainNetworkController gracefully', () => { + it('handles missing MultichainNetworkController gracefully', async () => { const { controller, messenger } = setupController(); jest @@ -546,9 +655,21 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, - '0xe708': { chainId: '0xe708', name: 'Linea Mainnet' }, - '0x2105': { chainId: '0x2105', name: 'Base Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -565,7 +686,7 @@ describe('NetworkEnablementController', () => { }); // Should not throw - expect(() => controller.init()).not.toThrow(); + await controller.init(); // Should still enable popular networks from NetworkController expect(controller.isNetworkEnabled('0x1')).toBe(true); @@ -573,7 +694,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 @@ -584,8 +705,16 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum' }, - '0x89': { chainId: '0x89', name: 'Polygon' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum', + nativeCurrency: 'ETH', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + }, }, networksMetadata: {}, }; @@ -596,10 +725,12 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana', + nativeCurrency: 'SOL', }, 'bip122:000000000019d6689c085ae165831e93': { chainId: 'bip122:000000000019d6689c085ae165831e93', name: 'Bitcoin', + nativeCurrency: 'BTC', }, }, selectedMultichainNetworkChainId: @@ -611,7 +742,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( @@ -625,7 +756,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 @@ -672,7 +803,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( @@ -681,7 +812,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 @@ -693,7 +824,11 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -719,7 +854,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); @@ -730,7 +865,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 @@ -742,7 +877,11 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -753,10 +892,12 @@ describe('NetworkEnablementController', () => { [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, name: 'Bitcoin Mainnet', + nativeCurrency: 'BTC', }, [BtcScope.Signet]: { chainId: BtcScope.Signet, name: 'Bitcoin Signet', + nativeCurrency: 'BTC', }, }, selectedMultichainNetworkChainId: BtcScope.Mainnet, @@ -768,7 +909,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); @@ -778,6 +919,193 @@ describe('NetworkEnablementController', () => { ], ).toBe(false); }); + + it('skips networks that already have nativeAssetIdentifiers in state', async () => { + // Create controller with existing nativeAssetIdentifiers + const { controller, messenger } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + nativeAssetIdentifiers: { + // Pre-existing nativeAssetIdentifier with custom value + 'eip155:1': 'eip155:1/slip44:999' as const, + }, + }, + }, + }); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0x38': { + chainId: '0x38', + name: 'BNB Chain', + nativeCurrency: 'BNB', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + await controller.init(); + + // Existing nativeAssetIdentifier should be preserved (not overwritten) + expect(controller.state.nativeAssetIdentifiers['eip155:1']).toBe( + 'eip155:1/slip44:999', + ); + + // New network should be added + expect(controller.state.nativeAssetIdentifiers['eip155:56']).toBe( + 'eip155:56/slip44:714', + ); + }); + + it('defaults to slip44:60 for EVM networks with unknown chainId and symbol', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Use an unknown chainId (99999 = 0x1869F) and unknown symbol + '0x1869f': { + chainId: '0x1869f', + name: 'Unknown Network', + nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + await controller.init(); + + // Should default to slip44:60 when no mapping is found + expect(controller.state.nativeAssetIdentifiers['eip155:99999']).toBe( + 'eip155:99999/slip44:60', + ); + }); + }); + + describe('initNativeAssetIdentifiers', () => { + it('populates nativeAssetIdentifiers from network configurations', async () => { + const { controller } = setupController(); + + const networks = [ + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + { chainId: 'eip155:56' as const, nativeCurrency: 'BNB' }, + { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const, + nativeCurrency: 'SOL', + }, + ]; + + await controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({ + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:56': 'eip155:56/slip44:714', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }); + }); + + it('defaults to slip44:60 for EVM networks with unknown symbols', async () => { + const { controller } = setupController(); + + const networks = [ + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + { chainId: 'eip155:999' as const, nativeCurrency: 'UNKNOWN_XYZ' }, + ]; + + await controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers['eip155:1']).toBe( + 'eip155:1/slip44:60', + ); + // EVM networks default to slip44:60 (Ethereum) when no specific mapping is found + expect(controller.state.nativeAssetIdentifiers['eip155:999']).toBe( + 'eip155:999/slip44:60', + ); + }); + + it('does not modify state for empty input', async () => { + const { controller } = setupController(); + + await controller.initNativeAssetIdentifiers([]); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({}); + }); + + it('handles CAIP-19 format nativeCurrency from MultichainNetworkController', async () => { + const { controller } = setupController(); + + // Non-EVM networks from MultichainNetworkController use CAIP-19 format for nativeCurrency + const networks = [ + // EVM networks use simple symbols + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + // Non-EVM networks use full CAIP-19 format + { + chainId: 'bip122:000000000019d6689c085ae165831e93' as const, + nativeCurrency: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const, + nativeCurrency: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + { + chainId: 'tron:728126428' as const, + nativeCurrency: 'tron:728126428/slip44:195', + }, + ]; + + await controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({ + 'eip155:1': 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93': + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + 'tron:728126428': 'tron:728126428/slip44:195', + }); + }); }); describe('enableAllPopularNetworks', () => { @@ -793,9 +1121,21 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, - '0xe708': { chainId: '0xe708', name: 'Linea Mainnet' }, - '0x2105': { chainId: '0x2105', name: 'Base Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -806,6 +1146,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, @@ -857,6 +1198,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); // Enable all popular networks @@ -890,6 +1232,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -922,6 +1265,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, @@ -970,6 +1314,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -985,9 +1330,21 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, - '0xe708': { chainId: '0xe708', name: 'Linea Mainnet' }, - '0x2105': { chainId: '0x2105', name: 'Base Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, '0x2': { chainId: '0x2', name: 'Test Network' }, // Non-popular network }, networksMetadata: {}, @@ -999,6 +1356,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, @@ -1137,6 +1495,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); // Enable the network again - this should disable all others in all namespaces @@ -1170,6 +1529,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1223,6 +1583,10 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:966', // MATIC + }, }); // Enable one of the popular networks - only this one will be enabled @@ -1257,6 +1621,10 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:966', // MATIC + }, }); // Enable the non-popular network again - it will disable all others @@ -1291,6 +1659,10 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:966', // MATIC + }, }); }); @@ -1336,6 +1708,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1352,6 +1725,7 @@ 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 + // slip44Map is not affected by enabledNetworkMap changes, so it still contains all the original entries expect(controller.state).toStrictEqual({ enabledNetworkMap: { [KnownCaipNamespace.Eip155]: { @@ -1375,6 +1749,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1428,6 +1803,11 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'bip122:000000000019d6689c085ae165831e93': + 'bip122:000000000019d6689c085ae165831e93/slip44:0', // BTC + }, }); }); }); @@ -1467,6 +1847,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1514,6 +1895,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); // Try to disable the last active network @@ -2447,169 +2829,53 @@ describe('NetworkEnablementController', () => { it('includes expected state in debug snapshots', () => { const { controller } = setupController(); - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInDebugSnapshot', - ), - ).toMatchInlineSnapshot(` - Object { - "enabledNetworkMap": Object { - "bip122": Object { - "bip122:000000000019d6689c085ae165831e93": true, - "bip122:000000000933ea01ad0ee984209779ba": false, - "bip122:00000008819873e925422c1ff0f99f7c": false, - }, - "eip155": Object { - "0x1": true, - "0x2105": true, - "0x38": true, - "0x531": true, - "0x89": true, - "0xa": true, - "0xa4b1": true, - "0xe708": true, - }, - "solana": Object { - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": false, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": true, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": false, - }, - "tron": Object { - "tron:2494104990": false, - "tron:3448148188": false, - "tron:728126428": true, - }, - }, - } - `); + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); }); it('includes expected state in state logs', () => { const { controller } = setupController(); - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'includeInStateLogs', - ), - ).toMatchInlineSnapshot(` - Object { - "enabledNetworkMap": Object { - "bip122": Object { - "bip122:000000000019d6689c085ae165831e93": true, - "bip122:000000000933ea01ad0ee984209779ba": false, - "bip122:00000008819873e925422c1ff0f99f7c": false, - }, - "eip155": Object { - "0x1": true, - "0x2105": true, - "0x38": true, - "0x531": true, - "0x89": true, - "0xa": true, - "0xa4b1": true, - "0xe708": true, - }, - "solana": Object { - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": false, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": true, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": false, - }, - "tron": Object { - "tron:2494104990": false, - "tron:3448148188": false, - "tron:728126428": true, - }, - }, - } - `); + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); }); it('persists expected state', () => { const { controller } = setupController(); - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'persist', - ), - ).toMatchInlineSnapshot(` - Object { - "enabledNetworkMap": Object { - "bip122": Object { - "bip122:000000000019d6689c085ae165831e93": true, - "bip122:000000000933ea01ad0ee984209779ba": false, - "bip122:00000008819873e925422c1ff0f99f7c": false, - }, - "eip155": Object { - "0x1": true, - "0x2105": true, - "0x38": true, - "0x531": true, - "0x89": true, - "0xa": true, - "0xa4b1": true, - "0xe708": true, - }, - "solana": Object { - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": false, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": true, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": false, - }, - "tron": Object { - "tron:2494104990": false, - "tron:3448148188": false, - "tron:728126428": true, - }, - }, - } - `); + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); }); it('exposes expected state to UI', () => { const { controller } = setupController(); - expect( - deriveStateFromMetadata( - controller.state, - controller.metadata, - 'usedInUi', - ), - ).toMatchInlineSnapshot(` - Object { - "enabledNetworkMap": Object { - "bip122": Object { - "bip122:000000000019d6689c085ae165831e93": true, - "bip122:000000000933ea01ad0ee984209779ba": false, - "bip122:00000008819873e925422c1ff0f99f7c": false, - }, - "eip155": Object { - "0x1": true, - "0x2105": true, - "0x38": true, - "0x531": true, - "0x89": true, - "0xa": true, - "0xa4b1": true, - "0xe708": true, - }, - "solana": Object { - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": false, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": true, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": false, - }, - "tron": Object { - "tron:2494104990": false, - "tron:3448148188": false, - "tron:728126428": true, - }, - }, - } - `); + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); }); }); diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.ts b/packages/network-enablement-controller/src/NetworkEnablementController.ts index 7c85a955407..fe549aa0d38 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.ts @@ -18,6 +18,7 @@ import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; import { KnownCaipNamespace } from '@metamask/utils'; import { POPULAR_NETWORKS } from './constants'; +import { Slip44Service } from './services'; import { deriveKeys, isOnlyNetworkEnabledInNamespace, @@ -43,9 +44,32 @@ export type NetworksInfo = { */ type EnabledMap = Record>; +/** + * A native asset identifier in CAIP-19-like format. + * Format: `{caip2ChainId}/slip44:{coinType}` + * + * @example + * - `eip155:1/slip44:60` for Ethereum mainnet (ETH) + * - `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501` for Solana mainnet (SOL) + * - `bip122:000000000019d6689c085ae165831e93/slip44:0` for Bitcoin mainnet (BTC) + */ +export type NativeAssetIdentifier = `${CaipChainId}/slip44:${number}`; + +/** + * A map of CAIP-2 chain IDs to their native asset identifiers. + * Uses CAIP-19-like format to identify the native asset for each chain. + * + * @see https://github.com/satoshilabs/slips/blob/master/slip-0044.md + */ +export type NativeAssetIdentifiersMap = Record< + CaipChainId, + NativeAssetIdentifier +>; + // State shape for NetworkEnablementController export type NetworkEnablementControllerState = { enabledNetworkMap: EnabledMap; + nativeAssetIdentifiers: NativeAssetIdentifiersMap; }; export type NetworkEnablementControllerGetStateAction = @@ -100,6 +124,29 @@ export type NetworkEnablementControllerMessenger = Messenger< NetworkEnablementControllerEvents | AllowedEvents >; +/** + * Builds a native asset identifier in CAIP-19-like format. + * + * @param caipChainId - The CAIP-2 chain ID (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') + * @param slip44CoinType - The SLIP-44 coin type number + * @returns The native asset identifier string (e.g., 'eip155:1/slip44:60') + */ +function buildNativeAssetIdentifier( + caipChainId: CaipChainId, + slip44CoinType: number, +): NativeAssetIdentifier { + return `${caipChainId}/slip44:${slip44CoinType}`; +} + +/** + * Network configuration with chain ID and native currency symbol. + * Used to initialize native asset identifiers. + */ +export type NetworkConfig = { + chainId: CaipChainId; + nativeCurrency: string; +}; + /** * Gets the default state for the NetworkEnablementController. * @@ -134,6 +181,9 @@ const getDefaultNetworkEnablementControllerState = [TrxScope.Shasta]: false, }, }, + // nativeAssetIdentifiers is initialized as empty and should be populated + // by the client using initNativeAssetIdentifiers() during controller init + nativeAssetIdentifiers: {}, }); // Metadata for the controller state @@ -144,6 +194,12 @@ const metadata = { includeInDebugSnapshot: true, usedInUi: true, }, + nativeAssetIdentifiers: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; /** @@ -184,9 +240,13 @@ export class NetworkEnablementController extends BaseController< }, }); - messenger.subscribe('NetworkController:networkAdded', ({ chainId }) => { - this.#onAddNetwork(chainId); - }); + messenger.subscribe( + 'NetworkController:networkAdded', + ({ chainId, nativeCurrency }) => { + // eslint-disable-next-line no-void + void this.#onAddNetwork(chainId, nativeCurrency); + }, + ); messenger.subscribe('NetworkController:networkRemoved', ({ chainId }) => { this.#removeNetworkEntry(chainId); @@ -212,22 +272,22 @@ export class NetworkEnablementController extends BaseController< enableNetwork(chainId: Hex | CaipChainId): void { const { namespace, storageKey } = deriveKeys(chainId); - this.update((s) => { + this.update((state) => { // disable all networks in all namespaces first - Object.keys(s.enabledNetworkMap).forEach((ns) => { - Object.keys(s.enabledNetworkMap[ns]).forEach((key) => { - s.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; + Object.keys(state.enabledNetworkMap).forEach((ns) => { + Object.keys(state.enabledNetworkMap[ns]).forEach((key) => { + state.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; }); }); // if the namespace bucket does not exist, return // new nemespace are added only when a new network is added - if (!s.enabledNetworkMap[namespace]) { + if (!state.enabledNetworkMap[namespace]) { return; } // enable the network - s.enabledNetworkMap[namespace][storageKey] = true; + state.enabledNetworkMap[namespace][storageKey] = true; }); } @@ -260,19 +320,19 @@ export class NetworkEnablementController extends BaseController< ); } - this.update((s) => { + this.update((state) => { // Ensure the namespace bucket exists - this.#ensureNamespaceBucket(s, namespace); + this.#ensureNamespaceBucket(state, namespace); // Disable all networks in the specified namespace first - if (s.enabledNetworkMap[namespace]) { - Object.keys(s.enabledNetworkMap[namespace]).forEach((key) => { - s.enabledNetworkMap[namespace][key as CaipChainId | Hex] = false; + if (state.enabledNetworkMap[namespace]) { + Object.keys(state.enabledNetworkMap[namespace]).forEach((key) => { + state.enabledNetworkMap[namespace][key as CaipChainId | Hex] = false; }); } // Enable the target network in the specified namespace - s.enabledNetworkMap[namespace][storageKey] = true; + state.enabledNetworkMap[namespace][storageKey] = true; }); } @@ -287,11 +347,11 @@ export class NetworkEnablementController extends BaseController< * Popular networks that don't exist in NetworkController or MultichainNetworkController configurations will be skipped silently. */ enableAllPopularNetworks(): void { - this.update((s) => { + this.update((state) => { // First disable all networks across all namespaces - Object.keys(s.enabledNetworkMap).forEach((ns) => { - Object.keys(s.enabledNetworkMap[ns]).forEach((key) => { - s.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; + Object.keys(state.enabledNetworkMap).forEach((ns) => { + Object.keys(state.enabledNetworkMap[ns]).forEach((key) => { + state.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; }); }); @@ -312,9 +372,9 @@ export class NetworkEnablementController extends BaseController< networkControllerState.networkConfigurationsByChainId[chainId as Hex] ) { // Ensure namespace bucket exists - this.#ensureNamespaceBucket(s, namespace); + this.#ensureNamespaceBucket(state, namespace); // Enable the network - s.enabledNetworkMap[namespace][storageKey] = true; + state.enabledNetworkMap[namespace][storageKey] = true; } }); @@ -326,9 +386,10 @@ export class NetworkEnablementController extends BaseController< ] ) { // Ensure namespace bucket exists - this.#ensureNamespaceBucket(s, solanaKeys.namespace); + this.#ensureNamespaceBucket(state, solanaKeys.namespace); // Enable Solana mainnet - s.enabledNetworkMap[solanaKeys.namespace][solanaKeys.storageKey] = true; + state.enabledNetworkMap[solanaKeys.namespace][solanaKeys.storageKey] = + true; } // Enable Bitcoin mainnet if it exists in MultichainNetworkController configurations @@ -339,9 +400,9 @@ export class NetworkEnablementController extends BaseController< ] ) { // Ensure namespace bucket exists - this.#ensureNamespaceBucket(s, bitcoinKeys.namespace); + this.#ensureNamespaceBucket(state, bitcoinKeys.namespace); // Enable Bitcoin mainnet - s.enabledNetworkMap[bitcoinKeys.namespace][bitcoinKeys.storageKey] = + state.enabledNetworkMap[bitcoinKeys.namespace][bitcoinKeys.storageKey] = true; } @@ -353,9 +414,9 @@ export class NetworkEnablementController extends BaseController< ] ) { // Ensure namespace bucket exists - this.#ensureNamespaceBucket(s, tronKeys.namespace); + this.#ensureNamespaceBucket(state, tronKeys.namespace); // Enable Tron mainnet - s.enabledNetworkMap[tronKeys.namespace][tronKeys.storageKey] = true; + state.enabledNetworkMap[tronKeys.namespace][tronKeys.storageKey] = true; } }); } @@ -364,53 +425,163 @@ export class NetworkEnablementController extends BaseController< * Initializes the network enablement state from network controller configurations. * * This method reads the current network configurations from both NetworkController - * and MultichainNetworkController and syncs the enabled network map accordingly. + * and MultichainNetworkController and syncs the enabled network map and nativeAssetIdentifiers 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. * * 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 { + // Get network configurations from NetworkController (EVM networks) + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); - // Get network configurations from MultichainNetworkController (all networks) - const multichainState = this.messenger.call( - 'MultichainNetworkController:getState', - ); + // Get network configurations from MultichainNetworkController (all networks) + const multichainState = this.messenger.call( + 'MultichainNetworkController:getState', + ); + + // Build nativeAssetIdentifiers for EVM networks using chainid.network + const evmNativeAssetUpdates: { + caipChainId: CaipChainId; + identifier: NativeAssetIdentifier; + }[] = []; + + for (const [chainId, config] of Object.entries( + networkControllerState.networkConfigurationsByChainId, + )) { + const { caipChainId } = deriveKeys(chainId as Hex); + // Skip if already in state + if (this.state.nativeAssetIdentifiers[caipChainId] !== undefined) { + continue; + } + + // Parse hex chainId to number for chainid.network lookup + const numericChainId = parseInt(chainId, 16); + + // EVM networks: use getSlip44ByChainId (chainid.network data) + // Default to 60 (Ethereum) if no specific mapping is found + const slip44CoinType = + (await Slip44Service.getSlip44ByChainId( + numericChainId, + config.nativeCurrency, + )) ?? 60; + + evmNativeAssetUpdates.push({ + caipChainId, + identifier: buildNativeAssetIdentifier(caipChainId, slip44CoinType), + }); + } + + // Update state synchronously + this.update((state) => { // Initialize namespace buckets for EVM networks from NetworkController - Object.keys( + Object.entries( networkControllerState.networkConfigurationsByChainId, - ).forEach((chainId) => { + ).forEach(([chainId]) => { const { namespace, storageKey } = deriveKeys(chainId as Hex); - this.#ensureNamespaceBucket(s, namespace); + this.#ensureNamespaceBucket(state, namespace); // Only add network if it doesn't already exist in state (preserves user settings) - if (s.enabledNetworkMap[namespace][storageKey] === undefined) { - s.enabledNetworkMap[namespace][storageKey] = false; - } + state.enabledNetworkMap[namespace][storageKey] ??= false; }); + // Apply nativeAssetIdentifier updates + for (const { caipChainId, identifier } of evmNativeAssetUpdates) { + state.nativeAssetIdentifiers[caipChainId] = identifier; + } + // Initialize namespace buckets for all networks from MultichainNetworkController Object.keys( multichainState.multichainNetworkConfigurationsByChainId, ).forEach((chainId) => { const { namespace, storageKey } = deriveKeys(chainId as CaipChainId); - this.#ensureNamespaceBucket(s, namespace); + this.#ensureNamespaceBucket(state, namespace); // Only add network if it doesn't already exist in state (preserves user settings) - if (s.enabledNetworkMap[namespace][storageKey] === undefined) { - s.enabledNetworkMap[namespace][storageKey] = false; - } + state.enabledNetworkMap[namespace][storageKey] ??= false; }); }); } + /** + * Initializes the native asset identifiers from network configurations. + * This method should be called from the client during controller initialization + * to populate the nativeAssetIdentifiers state based on actual network configurations. + * + * @param networks - Array of network configurations with chainId and nativeCurrency + * @example + * ```typescript + * const evmNetworks = Object.values(networkControllerState.networkConfigurationsByChainId) + * .map(config => ({ + * chainId: toEvmCaipChainId(config.chainId), + * nativeCurrency: config.nativeCurrency, + * })); + * + * const multichainNetworks = Object.values(multichainState.multichainNetworkConfigurationsByChainId) + * .map(config => ({ + * chainId: config.chainId, + * nativeCurrency: config.nativeCurrency, + * })); + * + * await controller.initNativeAssetIdentifiers([...evmNetworks, ...multichainNetworks]); + * ``` + */ + async initNativeAssetIdentifiers(networks: NetworkConfig[]): Promise { + // Process networks and collect updates + const updates: { + chainId: CaipChainId; + identifier: NativeAssetIdentifier; + }[] = []; + + for (const { chainId, nativeCurrency } of networks) { + // Check if nativeCurrency is already in CAIP-19 format (e.g., "bip122:.../slip44:0") + // Non-EVM networks from MultichainNetworkController use this format + if (nativeCurrency.includes('/slip44:')) { + updates.push({ + chainId, + identifier: nativeCurrency as NativeAssetIdentifier, + }); + continue; + } + + // Extract namespace from CAIP-2 chainId + const [namespace, reference] = chainId.split(':'); + let slip44CoinType: number | undefined; + + if (namespace === 'eip155') { + // EVM networks: use getSlip44ByChainId (chainid.network data) + // Default to 60 (Ethereum) if no specific mapping is found + const numericChainId = parseInt(reference, 10); + slip44CoinType = + (await Slip44Service.getSlip44ByChainId( + numericChainId, + nativeCurrency, + )) ?? 60; + } else { + // Non-EVM networks: use getSlip44BySymbol (@metamask/slip44 package) + slip44CoinType = Slip44Service.getSlip44BySymbol(nativeCurrency); + } + + if (slip44CoinType !== undefined) { + updates.push({ + chainId, + identifier: buildNativeAssetIdentifier(chainId, slip44CoinType), + }); + } + } + + // Apply all updates synchronously + this.update((state) => { + for (const { chainId, identifier } of updates) { + state.nativeAssetIdentifiers[chainId] = identifier; + } + }); + } + /** * Disables a network for the user. * @@ -429,8 +600,8 @@ export class NetworkEnablementController extends BaseController< const derivedKeys = deriveKeys(chainId); const { namespace, storageKey } = derivedKeys; - this.update((s) => { - s.enabledNetworkMap[namespace][storageKey] = false; + this.update((state) => { + state.enabledNetworkMap[namespace][storageKey] = false; }); } @@ -461,7 +632,7 @@ export class NetworkEnablementController extends BaseController< #ensureNamespaceBucket( state: NetworkEnablementControllerState, ns: CaipNamespace, - ) { + ): void { if (!state.enabledNetworkMap[ns]) { state.enabledNetworkMap[ns] = {}; } @@ -507,44 +678,63 @@ export class NetworkEnablementController extends BaseController< * Removes a network entry from the state. * * This method is called when a network is removed from the system. It cleans up - * the network entry and ensures that at least one network remains enabled. + * the network entry from both enabledNetworkMap and nativeAssetIdentifiers, and ensures that + * at least one network remains enabled. * * @param chainId - The chain ID to remove (Hex or CAIP-2 format) */ #removeNetworkEntry(chainId: Hex | CaipChainId): void { const derivedKeys = deriveKeys(chainId); - const { namespace, storageKey } = derivedKeys; + const { namespace, storageKey, caipChainId } = derivedKeys; - this.update((s) => { + this.update((state) => { // fallback and enable ethereum mainnet if (isOnlyNetworkEnabledInNamespace(this.state, derivedKeys)) { - s.enabledNetworkMap[namespace][ChainId[BuiltInNetworkName.Mainnet]] = - true; + state.enabledNetworkMap[namespace][ + ChainId[BuiltInNetworkName.Mainnet] + ] = true; } - if (namespace in s.enabledNetworkMap) { - delete s.enabledNetworkMap[namespace][storageKey]; + if (namespace in state.enabledNetworkMap) { + delete state.enabledNetworkMap[namespace][storageKey]; } + + // Remove from nativeAssetIdentifiers as well + delete state.nativeAssetIdentifiers[caipChainId]; }); } /** - * Handles the addition of a new network to the controller. + * Handles the addition of a new EVM network to the controller. * - * @param chainId - The chain ID to add (Hex or CAIP-2 format) + * @param chainId - The chain ID to add (Hex format) + * @param nativeCurrency - The native currency symbol of the network (e.g., 'ETH') * * @description * - If in popular networks mode (>2 popular networks enabled) AND adding a popular network: * - Keep current selection (add but don't enable the new network) * - Otherwise: * - Switch to the newly added network (disable all others, enable this one) + * - Also updates the nativeAssetIdentifiers with the CAIP-19-like identifier */ - #onAddNetwork(chainId: Hex | CaipChainId): void { - const { namespace, storageKey, reference } = deriveKeys(chainId); - - this.update((s) => { + async #onAddNetwork(chainId: Hex, nativeCurrency: string): Promise { + const { namespace, storageKey, reference, caipChainId } = + deriveKeys(chainId); + + // Parse hex chainId to number for chainid.network lookup + const numericChainId = parseInt(reference, 16); + + // EVM networks: use getSlip44ByChainId (chainid.network data) + // Default to 60 (Ethereum) if no specific mapping is found + const slip44CoinType = + (await Slip44Service.getSlip44ByChainId( + numericChainId, + nativeCurrency, + )) ?? 60; + + this.update((state) => { // Ensure the namespace bucket exists - this.#ensureNamespaceBucket(s, namespace); + this.#ensureNamespaceBucket(state, namespace); // Check if popular networks mode is active (>2 popular networks enabled) const inPopularNetworksMode = this.#isInPopularNetworksMode(); @@ -558,17 +748,23 @@ export class NetworkEnablementController extends BaseController< if (shouldKeepCurrentSelection) { // Add the popular network but don't enable it (keep current selection) - s.enabledNetworkMap[namespace][storageKey] = true; + state.enabledNetworkMap[namespace][storageKey] = true; } else { // Switch to the newly added network (disable all others, enable this one) - Object.keys(s.enabledNetworkMap).forEach((ns) => { - Object.keys(s.enabledNetworkMap[ns]).forEach((key) => { - s.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; + Object.keys(state.enabledNetworkMap).forEach((ns) => { + Object.keys(state.enabledNetworkMap[ns]).forEach((key) => { + state.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; }); }); // Enable the newly added network - s.enabledNetworkMap[namespace][storageKey] = true; + state.enabledNetworkMap[namespace][storageKey] = true; } + + // Update nativeAssetIdentifiers with the CAIP-19-like identifier + state.nativeAssetIdentifiers[caipChainId] = buildNativeAssetIdentifier( + caipChainId, + slip44CoinType, + ); }); } } diff --git a/packages/network-enablement-controller/src/index.ts b/packages/network-enablement-controller/src/index.ts index 95c066a1f11..e998a911b64 100644 --- a/packages/network-enablement-controller/src/index.ts +++ b/packages/network-enablement-controller/src/index.ts @@ -6,6 +6,9 @@ export type { NetworkEnablementControllerActions, NetworkEnablementControllerEvents, NetworkEnablementControllerMessenger, + NativeAssetIdentifier, + NativeAssetIdentifiersMap, + NetworkConfig, } from './NetworkEnablementController'; export { @@ -17,3 +20,10 @@ export { selectEnabledEvmNetworks, selectEnabledSolanaNetworks, } from './selectors'; + +export { + Slip44Service, + getSlip44BySymbol, + getSlip44ByChainId, +} from './services'; +export type { Slip44Entry } from './services'; diff --git a/packages/network-enablement-controller/src/selectors.test.ts b/packages/network-enablement-controller/src/selectors.test.ts index 235e9d53608..1410080bb47 100644 --- a/packages/network-enablement-controller/src/selectors.test.ts +++ b/packages/network-enablement-controller/src/selectors.test.ts @@ -24,6 +24,7 @@ describe('NetworkEnablementController Selectors', () => { 'solana:testnet': false, }, }, + nativeAssetIdentifiers: {}, }; describe('selectEnabledNetworkMap', () => { diff --git a/packages/network-enablement-controller/src/services/Slip44Service.test.ts b/packages/network-enablement-controller/src/services/Slip44Service.test.ts new file mode 100644 index 00000000000..68659f09997 --- /dev/null +++ b/packages/network-enablement-controller/src/services/Slip44Service.test.ts @@ -0,0 +1,324 @@ +import { fetchWithErrorHandling } from '@metamask/controller-utils'; + +import { Slip44Service } from './Slip44Service'; + +jest.mock('@metamask/controller-utils', () => ({ + fetchWithErrorHandling: jest.fn(), +})); + +const mockFetchWithErrorHandling = + fetchWithErrorHandling as jest.MockedFunction; + +describe('Slip44Service', () => { + beforeEach(() => { + // Clear cache before each test to ensure clean state + Slip44Service.clearCache(); + jest.clearAllMocks(); + }); + + describe('getSlip44BySymbol', () => { + it('returns 60 for ETH symbol', () => { + const result = Slip44Service.getSlip44BySymbol('ETH'); + expect(result).toBe(60); + }); + + it('returns 0 for BTC symbol', () => { + const result = Slip44Service.getSlip44BySymbol('BTC'); + expect(result).toBe(0); + }); + + it('returns 501 for SOL symbol', () => { + const result = Slip44Service.getSlip44BySymbol('SOL'); + expect(result).toBe(501); + }); + + it('returns 195 for TRX symbol', () => { + const result = Slip44Service.getSlip44BySymbol('TRX'); + expect(result).toBe(195); + }); + + it('returns 2 for LTC symbol', () => { + const result = Slip44Service.getSlip44BySymbol('LTC'); + expect(result).toBe(2); + }); + + it('returns 3 for DOGE symbol', () => { + const result = Slip44Service.getSlip44BySymbol('DOGE'); + expect(result).toBe(3); + }); + + it('returns undefined for unknown symbol', () => { + const result = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + expect(result).toBeUndefined(); + }); + + it('is case-insensitive for symbols', () => { + const lowerResult = Slip44Service.getSlip44BySymbol('eth'); + const upperResult = Slip44Service.getSlip44BySymbol('ETH'); + const mixedResult = Slip44Service.getSlip44BySymbol('Eth'); + + expect(lowerResult).toBe(60); + expect(upperResult).toBe(60); + expect(mixedResult).toBe(60); + }); + + it('caches the result for repeated lookups', () => { + // First lookup + const firstResult = Slip44Service.getSlip44BySymbol('ETH'); + // Second lookup (should come from cache) + const secondResult = Slip44Service.getSlip44BySymbol('ETH'); + + expect(firstResult).toBe(60); + expect(secondResult).toBe(60); + }); + + it('caches undefined for unknown symbols', () => { + // First lookup + const firstResult = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + // Second lookup (should come from cache) + const secondResult = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + + expect(firstResult).toBeUndefined(); + expect(secondResult).toBeUndefined(); + }); + + it('returns coin type 1 for empty string (Testnet)', () => { + // The SLIP-44 data has an entry with empty symbol for "Testnet (all coins)" at index 1 + const result = Slip44Service.getSlip44BySymbol(''); + expect(result).toBe(1); + }); + }); + + describe('getSlip44Entry', () => { + it('returns entry for ETH coin type 60', () => { + const result = Slip44Service.getSlip44Entry(60); + + expect(result).toBeDefined(); + expect(result?.symbol).toBe('ETH'); + expect(result?.name).toBe('Ethereum'); + expect(result?.index).toBe('60'); + }); + + it('returns entry for BTC coin type 0', () => { + const result = Slip44Service.getSlip44Entry(0); + + expect(result).toBeDefined(); + expect(result?.symbol).toBe('BTC'); + expect(result?.name).toBe('Bitcoin'); + expect(result?.index).toBe('0'); + }); + + it('returns entry for SOL coin type 501', () => { + const result = Slip44Service.getSlip44Entry(501); + + expect(result).toBeDefined(); + expect(result?.symbol).toBe('SOL'); + expect(result?.name).toBe('Solana'); + expect(result?.index).toBe('501'); + }); + + it('returns undefined for non-existent coin type', () => { + const result = Slip44Service.getSlip44Entry(999999999); + expect(result).toBeUndefined(); + }); + + it('returns undefined for negative coin type', () => { + const result = Slip44Service.getSlip44Entry(-1); + expect(result).toBeUndefined(); + }); + }); + + describe('clearCache', () => { + it('clears the cache so lookups are performed again', () => { + // Perform initial lookup to populate cache + Slip44Service.getSlip44BySymbol('ETH'); + + // Clear the cache + Slip44Service.clearCache(); + + // Perform another lookup - should work correctly + const result = Slip44Service.getSlip44BySymbol('ETH'); + expect(result).toBe(60); + }); + + it('clears cached undefined values', () => { + // Perform initial lookup for unknown symbol + Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + + // Clear the cache + Slip44Service.clearCache(); + + // Verify cache is cleared (no error thrown) + const result = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + expect(result).toBeUndefined(); + }); + }); + + describe('real-world network symbols', () => { + it('correctly maps common EVM network native currencies', () => { + // All EVM networks use ETH or similar tokens with coin type 60 + expect(Slip44Service.getSlip44BySymbol('ETH')).toBe(60); + }); + + it('correctly maps Polygon MATIC symbol', () => { + const result = Slip44Service.getSlip44BySymbol('MATIC'); + // MATIC has coin type 966 + expect(result).toBe(966); + }); + + it('correctly maps BNB symbol', () => { + const result = Slip44Service.getSlip44BySymbol('BNB'); + // BNB has coin type 714 + expect(result).toBe(714); + }); + }); + + describe('getSlip44ByChainId', () => { + it('returns slip44 from chainid.network data when available', async () => { + // Mock chainid.network response with Ethereum data + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + { chainId: 56, slip44: 714 }, + ]); + + const result = await Slip44Service.getSlip44ByChainId(1); + + expect(result).toBe(60); + expect(mockFetchWithErrorHandling).toHaveBeenCalledWith({ + url: 'https://chainid.network/chains.json', + timeout: 10000, + }); + }); + + it('returns cached value on subsequent calls without re-fetching', async () => { + // Mock chainid.network response + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + { chainId: 56, slip44: 714 }, + ]); + + // First call - fetches data + const result1 = await Slip44Service.getSlip44ByChainId(1); + // Second call - should use cache (line 144) + const result2 = await Slip44Service.getSlip44ByChainId(56); + + expect(result1).toBe(60); + expect(result2).toBe(714); + // Should only fetch once + expect(mockFetchWithErrorHandling).toHaveBeenCalledTimes(1); + }); + + it('handles concurrent calls by reusing the fetch promise (line 82)', async () => { + // Mock chainid.network response with a delay + mockFetchWithErrorHandling.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve([{ chainId: 1, slip44: 60 }]); + }, 10); + }), + ); + + // Make concurrent calls + const [result1, result2, result3] = await Promise.all([ + Slip44Service.getSlip44ByChainId(1), + Slip44Service.getSlip44ByChainId(1), + Slip44Service.getSlip44ByChainId(1), + ]); + + expect(result1).toBe(60); + expect(result2).toBe(60); + expect(result3).toBe(60); + // Should only fetch once despite concurrent calls + expect(mockFetchWithErrorHandling).toHaveBeenCalledTimes(1); + }); + + it('falls back to symbol lookup when chainId not found in cache', async () => { + // Mock chainid.network response without the requested chainId + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + ]); + + // Request a chainId not in the response, but provide a symbol + const result = await Slip44Service.getSlip44ByChainId(999, 'ETH'); + + expect(result).toBe(60); // Falls back to symbol lookup + }); + + it('returns undefined when chainId not found and no symbol provided (line 152)', async () => { + // Mock chainid.network response + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + ]); + + // Request a chainId not in the response, without symbol + const result = await Slip44Service.getSlip44ByChainId(999); + + expect(result).toBeUndefined(); + }); + + it('handles invalid response by initializing empty cache (lines 102-104)', async () => { + // Mock invalid response (not an array) + mockFetchWithErrorHandling.mockResolvedValueOnce('invalid response'); + + // Should not throw, but return undefined + const result = await Slip44Service.getSlip44ByChainId(1); + + expect(result).toBeUndefined(); + }); + + it('handles null response by initializing empty cache', async () => { + // Mock null response + mockFetchWithErrorHandling.mockResolvedValueOnce(null); + + // Should not throw, but return undefined + const result = await Slip44Service.getSlip44ByChainId(1); + + expect(result).toBeUndefined(); + }); + + it('handles network error by initializing empty cache and falling back to symbol', async () => { + // Mock network error + mockFetchWithErrorHandling.mockRejectedValueOnce( + new Error('Network error'), + ); + + // Should not throw, but fall back to symbol lookup + const result = await Slip44Service.getSlip44ByChainId(1, 'ETH'); + + expect(result).toBe(60); // Falls back to symbol lookup + }); + + it('handles network error and returns undefined when no symbol provided', async () => { + // Mock network error + mockFetchWithErrorHandling.mockRejectedValueOnce( + new Error('Network error'), + ); + + // Should not throw, return undefined when no symbol + const result = await Slip44Service.getSlip44ByChainId(1); + + expect(result).toBeUndefined(); + }); + + it('filters out entries without slip44 field', async () => { + // Mock response with some entries missing slip44 + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + { chainId: 2 }, // No slip44 field + { chainId: 3, slip44: undefined }, // Explicit undefined + { chainId: 56, slip44: 714 }, + ]); + + const result1 = await Slip44Service.getSlip44ByChainId(1); + const result2 = await Slip44Service.getSlip44ByChainId(2); + const result3 = await Slip44Service.getSlip44ByChainId(3); + const result56 = await Slip44Service.getSlip44ByChainId(56); + + expect(result1).toBe(60); + expect(result2).toBeUndefined(); // Not in cache + expect(result3).toBeUndefined(); // Not in cache + expect(result56).toBe(714); + }); + }); +}); diff --git a/packages/network-enablement-controller/src/services/Slip44Service.ts b/packages/network-enablement-controller/src/services/Slip44Service.ts new file mode 100644 index 00000000000..0655405eafc --- /dev/null +++ b/packages/network-enablement-controller/src/services/Slip44Service.ts @@ -0,0 +1,220 @@ +import { fetchWithErrorHandling } from '@metamask/controller-utils'; +// @ts-expect-error: No type definitions for '@metamask/slip44' +import slip44 from '@metamask/slip44'; + +const CHAINID_NETWORK_URL = 'https://chainid.network/chains.json'; + +/** + * Represents a single SLIP-44 entry with its metadata. + */ +export type Slip44Entry = { + index: string; + symbol: string; + name: string; +}; + +/** + * Chain data from chainid.network + */ +type ChainIdNetworkEntry = { + chainId: number; + slip44?: number; + nativeCurrency?: { + symbol: string; + }; +}; + +/** + * Internal type for SLIP-44 data from the @metamask/slip44 package. + * Includes the hex field which we don't expose externally. + */ +type Slip44DataEntry = Slip44Entry & { + // eslint-disable-next-line id-denylist + hex: `0x${string}`; +}; + +/** + * The SLIP-44 mapping type from the @metamask/slip44 package. + */ +type Slip44Data = Record; + +/** + * Service for looking up SLIP-44 coin type identifiers. + * + * SLIP-44 defines registered coin types used in BIP-44 derivation paths. + * + * This service provides two lookup methods: + * 1. `getSlip44ByChainId` - Primary method using chainid.network data (recommended) + * 2. `getSlip44BySymbol` - Fallback method using @metamask/slip44 package + * + * @see https://github.com/satoshilabs/slips/blob/master/slip-0044.md + * @see https://chainid.network/chains.json + */ +export class Slip44Service { + /** + * Cache for chainId to slip44 lookups from chainid.network. + */ + static #chainIdCache: Map | null = null; + + /** + * Whether a fetch is currently in progress. + */ + static #fetchPromise: Promise | null = null; + + /** + * Cache for symbol to slip44 index lookups. + * This avoids iterating through all entries on repeated lookups. + */ + static readonly #symbolCache: Map = new Map(); + + /** + * Fetches and caches chain data from chainid.network. + * This is called automatically by getSlip44ByChainId. + */ + static async #fetchChainData(): Promise { + if (this.#chainIdCache !== null) { + return; + } + + // Avoid duplicate fetches + if (this.#fetchPromise) { + await this.#fetchPromise; + return; + } + + this.#fetchPromise = (async (): Promise => { + try { + const chains: ChainIdNetworkEntry[] | undefined = + await fetchWithErrorHandling({ + url: CHAINID_NETWORK_URL, + timeout: 10000, + }); + + if (chains && Array.isArray(chains)) { + this.#chainIdCache = new Map( + chains + .filter( + (chain): chain is ChainIdNetworkEntry & { slip44: number } => + chain.slip44 !== undefined, + ) + .map((chain) => [chain.chainId, chain.slip44]), + ); + } else { + // Invalid response, initialize empty cache + this.#chainIdCache = new Map(); + } + } catch { + // Network failed, initialize empty cache so we fall back to symbol lookup + this.#chainIdCache = new Map(); + } + })(); + + await this.#fetchPromise; + this.#fetchPromise = null; + } + + /** + * Gets the SLIP-44 coin type identifier for a given EVM chain ID. + * + * This method first checks chainid.network data (which maps chainId directly + * to slip44), then falls back to symbol lookup if not found. + * + * @param chainId - The EVM chain ID (e.g., 1 for Ethereum, 56 for BNB Chain) + * @param symbol - Optional symbol for fallback lookup (e.g., 'ETH', 'BNB') + * @returns The SLIP-44 coin type number, or undefined if not found + * @example + * ```typescript + * const ethCoinType = await Slip44Service.getSlip44ByChainId(1); + * // Returns 60 + * + * const bnbCoinType = await Slip44Service.getSlip44ByChainId(56); + * // Returns 714 + * ``` + */ + static async getSlip44ByChainId( + chainId: number, + symbol?: string, + ): Promise { + // Ensure chain data is loaded + await this.#fetchChainData(); + + // Check chainId cache first + const cached = this.#chainIdCache?.get(chainId); + if (cached !== undefined) { + return cached; + } + + // Fall back to symbol lookup if provided + if (symbol) { + return this.getSlip44BySymbol(symbol); + } + + return undefined; + } + + /** + * Gets the SLIP-44 coin type identifier for a given network symbol. + * + * Note: Symbol lookup may return incorrect results for duplicate symbols + * (e.g., CPC is both CPChain and Capricoin). For EVM networks, prefer + * using getSlip44ByChainId instead. + * + * @param symbol - The network symbol (e.g., 'ETH', 'BTC', 'SOL') + * @returns The SLIP-44 coin type number, or undefined if not found + * @example + * ```typescript + * const ethCoinType = Slip44Service.getSlip44BySymbol('ETH'); + * // Returns 60 + * + * const btcCoinType = Slip44Service.getSlip44BySymbol('BTC'); + * // Returns 0 + * ``` + */ + static getSlip44BySymbol(symbol: string): number | undefined { + // Check cache first + if (this.#symbolCache.has(symbol)) { + return this.#symbolCache.get(symbol); + } + + const slip44Data = slip44 as Slip44Data; + const upperSymbol = symbol.toUpperCase(); + + // Iterate through all entries to find matching symbol + // Note: Object.keys returns numeric keys in ascending order, + // so for duplicate symbols we get the lowest coin type first + // (which is the convention for resolving duplicates) + for (const key of Object.keys(slip44Data)) { + const entry = slip44Data[key]; + if (entry.symbol.toUpperCase() === upperSymbol) { + const coinType = parseInt(key, 10); + this.#symbolCache.set(symbol, coinType); + return coinType; + } + } + + // Cache the miss as well to avoid repeated lookups + this.#symbolCache.set(symbol, undefined); + return undefined; + } + + /** + * Gets the SLIP-44 entry for a given coin type index. + * + * @param index - The SLIP-44 coin type index (e.g., 60 for ETH, 0 for BTC) + * @returns The SLIP-44 entry with metadata, or undefined if not found + */ + static getSlip44Entry(index: number): Slip44Entry | undefined { + const slip44Data = slip44 as Slip44Data; + return slip44Data[index.toString()]; + } + + /** + * Clears all internal caches. + * Useful for testing or if the underlying data might change. + */ + static clearCache(): void { + this.#symbolCache.clear(); + this.#chainIdCache = null; + this.#fetchPromise = null; + } +} diff --git a/packages/network-enablement-controller/src/services/index.ts b/packages/network-enablement-controller/src/services/index.ts new file mode 100644 index 00000000000..683cb976dbd --- /dev/null +++ b/packages/network-enablement-controller/src/services/index.ts @@ -0,0 +1,10 @@ +import { Slip44Service } from './Slip44Service'; + +export { Slip44Service }; +export type { Slip44Entry } from './Slip44Service'; + +// Re-export static methods as standalone functions for convenience +export const getSlip44BySymbol = + Slip44Service.getSlip44BySymbol.bind(Slip44Service); +export const getSlip44ByChainId = + Slip44Service.getSlip44ByChainId.bind(Slip44Service); diff --git a/packages/network-enablement-controller/src/utils.test.ts b/packages/network-enablement-controller/src/utils.test.ts index 56e0f75e558..ed975ab5641 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, + nativeAssetIdentifiers: {}, }); describe('EVM namespace scenarios', () => { diff --git a/yarn.lock b/yarn.lock index 5da153fd94f..0d346693b76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4239,6 +4239,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/multichain-network-controller": "npm:^3.0.1" "@metamask/network-controller": "npm:^28.0.0" + "@metamask/slip44": "npm:^4.3.0" "@metamask/transaction-controller": "npm:^62.9.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4"