From f075c1fb9915b494b488ad317cff6f5169a4b2ed Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 13 Jan 2026 10:51:37 +0100 Subject: [PATCH 1/4] feat: add slip-44 identifier for added chains --- eslint-suppressions.json | 11 - .../CHANGELOG.md | 7 + .../package.json | 1 + .../src/NetworkEnablementController.test.ts | 590 +++++++++++++----- .../src/NetworkEnablementController.ts | 316 ++++++++-- .../src/index.ts | 6 + .../src/selectors.test.ts | 1 + .../src/services/Slip44Service.test.ts | 165 +++++ .../src/services/Slip44Service.ts | 97 +++ .../src/services/index.ts | 2 + .../src/utils.test.ts | 1 + yarn.lock | 1 + 12 files changed, 960 insertions(+), 238 deletions(-) create mode 100644 packages/network-enablement-controller/src/services/Slip44Service.test.ts create mode 100644 packages/network-enablement-controller/src/services/Slip44Service.ts create mode 100644 packages/network-enablement-controller/src/services/index.ts 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..e88aa1ee298 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,24 @@ 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 { advanceTime } from '../../../tests/helpers'; 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 +90,7 @@ const setupController = ({ events: [ 'NetworkController:networkAdded', 'NetworkController:networkRemoved', + 'NetworkController:stateChange', 'TransactionController:transactionSubmitted', ], }); @@ -154,6 +169,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -209,6 +225,10 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:43114': 'eip155:43114/slip44:9000', // AVAX + }, }); }); @@ -233,6 +253,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 +288,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: expectedNativeAssetIdentifiers, }); }); @@ -335,6 +364,122 @@ describe('NetworkEnablementController', () => { expect(controller.state).toStrictEqual(initialState); }); + it('subscribes to NetworkController:stateChange and updates nativeAssetIdentifiers when nativeCurrency changes', async () => { + const { controller, rootMessenger } = setupController(); + + // First add a network + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa86a', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await advanceTime({ clock, duration: 1 }); + + // Verify the network was added with AVAX coin type + expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( + 'eip155:43114/slip44:9000', + ); + + // Now publish a state change that updates the nativeCurrency + // The patch replaces the entire network config object (path length 2) + rootMessenger.publish( + 'NetworkController:stateChange', + { + networkConfigurationsByChainId: { + '0xa86a': { + chainId: '0xa86a', + nativeCurrency: 'ETH', // Changed from AVAX to ETH + }, + }, + } as never, + [ + { + op: 'replace', + path: ['networkConfigurationsByChainId', '0xa86a'], + value: { + chainId: '0xa86a', + nativeCurrency: 'ETH', + }, + }, + ], + ); + + await advanceTime({ clock, duration: 1 }); + + // The nativeAssetIdentifier should now use ETH's coin type (60) + expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( + 'eip155:43114/slip44:60', + ); + }); + + it('removes nativeAssetIdentifier when symbol has no SLIP-44 mapping', async () => { + const { controller, rootMessenger } = setupController(); + + // First add a network with a known symbol + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa86a', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await advanceTime({ clock, duration: 1 }); + + // Verify the network was added with AVAX coin type + expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( + 'eip155:43114/slip44:9000', + ); + + // Now publish a state change with an unknown symbol + // The patch replaces the entire network config object (path length 2) + rootMessenger.publish( + 'NetworkController:stateChange', + { + networkConfigurationsByChainId: { + '0xa86a': { + chainId: '0xa86a', + nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', // Unknown symbol + }, + }, + } as never, + [ + { + op: 'replace', + path: ['networkConfigurationsByChainId', '0xa86a'], + value: { + chainId: '0xa86a', + nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', + }, + }, + ], + ); + + await advanceTime({ clock, duration: 1 }); + + // The nativeAssetIdentifier should be removed + expect( + controller.state.nativeAssetIdentifiers['eip155:43114'], + ).toBeUndefined(); + }); + it('does fallback to ethereum when removing the last enabled network', async () => { const { controller, rootMessenger } = setupController(); @@ -365,6 +510,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,6 +545,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: expectedNativeAssetIdentifiersForFallback, }); }); @@ -407,9 +561,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 +583,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', @@ -469,6 +645,12 @@ 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', + }, }); }); @@ -481,6 +663,7 @@ describe('NetworkEnablementController', () => { [KnownCaipNamespace.Eip155]: {}, [KnownCaipNamespace.Solana]: {}, }, + nativeAssetIdentifiers: {}, }, }, }); @@ -492,8 +675,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 +696,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, }, selectedMultichainNetworkChainId: @@ -532,6 +724,12 @@ 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 + }, }); }); @@ -546,9 +744,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: {}, }; @@ -584,8 +794,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 +814,12 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana', + nativeCurrency: 'SOL', }, 'bip122:000000000019d6689c085ae165831e93': { chainId: 'bip122:000000000019d6689c085ae165831e93', name: 'Bitcoin', + nativeCurrency: 'BTC', }, }, selectedMultichainNetworkChainId: @@ -693,7 +913,11 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -742,7 +966,11 @@ describe('NetworkEnablementController', () => { return { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: { - '0x1': { chainId: '0x1', name: 'Ethereum Mainnet' }, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, }, networksMetadata: {}, }; @@ -753,10 +981,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, @@ -780,6 +1010,90 @@ describe('NetworkEnablementController', () => { }); }); + describe('initNativeAssetIdentifiers', () => { + it('populates nativeAssetIdentifiers from network configurations', () => { + 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', + }, + ]; + + 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('skips networks with unknown symbols', () => { + const { controller } = setupController(); + + const networks = [ + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + { chainId: 'eip155:999' as const, nativeCurrency: 'UNKNOWN_XYZ' }, + ]; + + controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers['eip155:1']).toBe( + 'eip155:1/slip44:60', + ); + expect( + controller.state.nativeAssetIdentifiers['eip155:999'], + ).toBeUndefined(); + }); + + it('does not modify state for empty input', () => { + const { controller } = setupController(); + + controller.initNativeAssetIdentifiers([]); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({}); + }); + + it('handles CAIP-19 format nativeCurrency from MultichainNetworkController', () => { + 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', + }, + ]; + + 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', () => { it('enables all popular networks that exist in controller configurations and Solana mainnet', () => { const { controller, messenger } = setupController(); @@ -793,9 +1107,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 +1132,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, @@ -857,6 +1184,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); // Enable all popular networks @@ -890,6 +1218,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -922,6 +1251,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, @@ -970,6 +1300,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -985,9 +1316,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 +1342,7 @@ describe('NetworkEnablementController', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana Mainnet', + nativeCurrency: 'SOL', }, [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, @@ -1137,6 +1481,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); // Enable the network again - this should disable all others in all namespaces @@ -1170,6 +1515,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1223,6 +1569,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 +1607,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 +1645,10 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:966', // MATIC + }, }); }); @@ -1336,6 +1694,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1352,6 +1711,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 +1735,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1428,6 +1789,11 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'bip122:000000000019d6689c085ae165831e93': + 'bip122:000000000019d6689c085ae165831e93/slip44:0', // BTC + }, }); }); }); @@ -1467,6 +1833,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); }); @@ -1514,6 +1881,7 @@ describe('NetworkEnablementController', () => { [TrxScope.Shasta]: false, }, }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), }); // Try to disable the last active network @@ -2447,169 +2815,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..68e89b44046 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,13 +240,52 @@ export class NetworkEnablementController extends BaseController< }, }); - messenger.subscribe('NetworkController:networkAdded', ({ chainId }) => { - this.#onAddNetwork(chainId); - }); + messenger.subscribe( + 'NetworkController:networkAdded', + ({ chainId, nativeCurrency }) => { + this.#onAddNetwork(chainId, nativeCurrency); + }, + ); messenger.subscribe('NetworkController:networkRemoved', ({ chainId }) => { this.#removeNetworkEntry(chainId); }); + + messenger.subscribe( + 'NetworkController:stateChange', + (_newState, patches) => { + this.#onNetworkControllerStateChange(patches); + }, + ); + } + + /** + * Handles NetworkController state changes to detect symbol updates. + * + * @param patches - The patches describing what changed + */ + #onNetworkControllerStateChange( + patches: { op: string; path: (string | number)[]; value?: unknown }[], + ): void { + // Look for patches that replace a network configuration + // Path format: ['networkConfigurationsByChainId', chainId] + for (const patch of patches) { + if ( + patch.path.length === 2 && + patch.path[0] === 'networkConfigurationsByChainId' && + patch.op === 'replace' && + patch.value && + typeof patch.value === 'object' && + 'nativeCurrency' in patch.value + ) { + const chainId = patch.path[1] as Hex; + const networkConfig = patch.value as { nativeCurrency: string }; + this.#updateNativeAssetIdentifier( + chainId, + networkConfig.nativeCurrency, + ); + } + } } /** @@ -212,22 +307,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 +355,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 +382,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 +407,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 +421,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 +435,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 +449,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,7 +460,7 @@ 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. * @@ -372,7 +468,7 @@ export class NetworkEnablementController extends BaseController< * have been initialized and their configurations are available. */ init(): void { - this.update((s) => { + this.update((state) => { // Get network configurations from NetworkController (EVM networks) const networkControllerState = this.messenger.call( 'NetworkController:getState', @@ -384,15 +480,26 @@ export class NetworkEnablementController extends BaseController< ); // Initialize namespace buckets for EVM networks from NetworkController - Object.keys( + Object.entries( networkControllerState.networkConfigurationsByChainId, - ).forEach((chainId) => { - const { namespace, storageKey } = deriveKeys(chainId as Hex); - this.#ensureNamespaceBucket(s, namespace); + ).forEach(([chainId, config]) => { + const { namespace, storageKey, caipChainId } = deriveKeys( + chainId as Hex, + ); + 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; + + // Sync nativeAssetIdentifiers using the nativeCurrency symbol + if (state.nativeAssetIdentifiers[caipChainId] === undefined) { + const slip44CoinType = Slip44Service.getSlip44BySymbol( + config.nativeCurrency, + ); + if (slip44CoinType !== undefined) { + state.nativeAssetIdentifiers[caipChainId] = + buildNativeAssetIdentifier(caipChainId, slip44CoinType); + } } }); @@ -401,16 +508,60 @@ export class NetworkEnablementController extends BaseController< 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, + * })); + * + * controller.initNativeAssetIdentifiers([...evmNetworks, ...multichainNetworks]); + * ``` + */ + initNativeAssetIdentifiers(networks: NetworkConfig[]): void { + this.update((state) => { + 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:')) { + state.nativeAssetIdentifiers[chainId] = + nativeCurrency as NativeAssetIdentifier; + } else { + // EVM networks use simple symbols like "ETH", "BNB" + const slip44CoinType = + Slip44Service.getSlip44BySymbol(nativeCurrency); + if (slip44CoinType !== undefined) { + state.nativeAssetIdentifiers[chainId] = buildNativeAssetIdentifier( + chainId, + slip44CoinType, + ); + } + } + } + }); + } + /** * Disables a network for the user. * @@ -429,8 +580,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,12 +612,42 @@ export class NetworkEnablementController extends BaseController< #ensureNamespaceBucket( state: NetworkEnablementControllerState, ns: CaipNamespace, - ) { + ): void { if (!state.enabledNetworkMap[ns]) { state.enabledNetworkMap[ns] = {}; } } + /** + * Updates the native asset identifier for a network based on its symbol. + * + * This method looks up the SLIP-44 coin type for the given symbol using the + * Slip44Service and updates the nativeAssetIdentifiers state with the full + * CAIP-19-like identifier. + * + * @param chainId - The chain ID of the network (Hex or CAIP-2 format) + * @param symbol - The native currency symbol of the network (e.g., 'ETH', 'BTC') + */ + #updateNativeAssetIdentifier( + chainId: Hex | CaipChainId, + symbol: string, + ): void { + const slip44CoinType = Slip44Service.getSlip44BySymbol(symbol); + const { caipChainId } = deriveKeys(chainId); + + this.update((state) => { + if (slip44CoinType === undefined) { + // Remove the entry if no SLIP-44 mapping exists for the symbol + delete state.nativeAssetIdentifiers[caipChainId]; + return; + } + state.nativeAssetIdentifiers[caipChainId] = buildNativeAssetIdentifier( + caipChainId, + slip44CoinType, + ); + }); + } + /** * Checks if popular networks mode is active (more than 2 popular networks enabled). * @@ -507,24 +688,29 @@ 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]; }); } @@ -532,19 +718,25 @@ export class NetworkEnablementController extends BaseController< * Handles the addition of a new network to the controller. * * @param chainId - The chain ID to add (Hex or CAIP-2 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); + #onAddNetwork(chainId: Hex | CaipChainId, nativeCurrency: string): void { + const { namespace, storageKey, reference, caipChainId } = + deriveKeys(chainId); + + // Look up the SLIP-44 coin type for the native currency + const slip44CoinType = Slip44Service.getSlip44BySymbol(nativeCurrency); - this.update((s) => { + 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,16 +750,24 @@ 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 + if (slip44CoinType !== undefined) { + 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..705f9dba373 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,6 @@ export { selectEnabledEvmNetworks, selectEnabledSolanaNetworks, } from './selectors'; + +export { Slip44Service } 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..b04a51b055f --- /dev/null +++ b/packages/network-enablement-controller/src/services/Slip44Service.test.ts @@ -0,0 +1,165 @@ +import { Slip44Service } from './Slip44Service'; + +describe('Slip44Service', () => { + beforeEach(() => { + // Clear cache before each test to ensure clean state + Slip44Service.clearCache(); + }); + + 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); + }); + }); +}); 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..09a75c31b12 --- /dev/null +++ b/packages/network-enablement-controller/src/services/Slip44Service.ts @@ -0,0 +1,97 @@ +// @ts-expect-error: No type definitions for '@metamask/slip44' +import slip44 from '@metamask/slip44'; + +/** + * Represents a single SLIP-44 entry with its metadata. + */ +export type Slip44Entry = { + index: string; + symbol: string; + name: 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 by symbol. + * + * SLIP-44 defines registered coin types used in BIP-44 derivation paths. + * + * @see https://github.com/satoshilabs/slips/blob/master/slip-0044.md + */ +export class Slip44Service { + /** + * Cache for symbol to slip44 index lookups. + * This avoids iterating through all entries on repeated lookups. + */ + static readonly #symbolCache: Map = new Map(); + + /** + * Gets the SLIP-44 coin type identifier for a given network symbol. + * + * @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 + 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 the internal symbol cache. + * Useful for testing or if the underlying data might change. + */ + static clearCache(): void { + this.#symbolCache.clear(); + } +} 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..1b3df6590d8 --- /dev/null +++ b/packages/network-enablement-controller/src/services/index.ts @@ -0,0 +1,2 @@ +export { Slip44Service } from './Slip44Service'; +export type { Slip44Entry } from './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" From 08513f4020e4fd72da8ef866ae9ba02813cd8ddb Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 14 Jan 2026 13:15:24 +0100 Subject: [PATCH 2/4] fix: fix PR comments --- .../src/NetworkEnablementController.test.ts | 244 ++++++++++++---- .../src/NetworkEnablementController.ts | 271 ++++++++++++------ .../src/index.ts | 2 +- .../src/services/Slip44Service.test.ts | 159 ++++++++++ .../src/services/Slip44Service.ts | 127 +++++++- .../src/services/index.ts | 10 +- 6 files changed, 666 insertions(+), 147 deletions(-) diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts index e88aa1ee298..c5cc15f594c 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -21,8 +21,35 @@ 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'; /** @@ -390,28 +417,35 @@ describe('NetworkEnablementController', () => { 'eip155:43114/slip44:9000', ); - // Now publish a state change that updates the nativeCurrency - // The patch replaces the entire network config object (path length 2) + // Publish an initial state to establish the baseline for the selector rootMessenger.publish( 'NetworkController:stateChange', { networkConfigurationsByChainId: { '0xa86a': { chainId: '0xa86a', - nativeCurrency: 'ETH', // Changed from AVAX to ETH + nativeCurrency: 'AVAX', }, }, } as never, - [ - { - op: 'replace', - path: ['networkConfigurationsByChainId', '0xa86a'], - value: { + [], + ); + + await advanceTime({ clock, duration: 1 }); + + // Now publish a state change that updates the nativeCurrency + // The selector will detect the change from AVAX to ETH + rootMessenger.publish( + 'NetworkController:stateChange', + { + networkConfigurationsByChainId: { + '0xa86a': { chainId: '0xa86a', - nativeCurrency: 'ETH', + nativeCurrency: 'ETH', // Changed from AVAX to ETH }, }, - ], + } as never, + [], ); await advanceTime({ clock, duration: 1 }); @@ -422,7 +456,7 @@ describe('NetworkEnablementController', () => { ); }); - it('removes nativeAssetIdentifier when symbol has no SLIP-44 mapping', async () => { + it('defaults to slip44:60 when new symbol has no SLIP-44 mapping', async () => { const { controller, rootMessenger } = setupController(); // First add a network with a known symbol @@ -448,36 +482,43 @@ describe('NetworkEnablementController', () => { 'eip155:43114/slip44:9000', ); - // Now publish a state change with an unknown symbol - // The patch replaces the entire network config object (path length 2) + // Publish an initial state to establish the baseline for the selector rootMessenger.publish( 'NetworkController:stateChange', { networkConfigurationsByChainId: { '0xa86a': { chainId: '0xa86a', - nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', // Unknown symbol + nativeCurrency: 'AVAX', }, }, } as never, - [ - { - op: 'replace', - path: ['networkConfigurationsByChainId', '0xa86a'], - value: { + [], + ); + + await advanceTime({ clock, duration: 1 }); + + // Now publish a state change with an unknown symbol + // The selector will detect the change from AVAX to UNKNOWN_SYMBOL_XYZ + rootMessenger.publish( + 'NetworkController:stateChange', + { + networkConfigurationsByChainId: { + '0xa86a': { chainId: '0xa86a', - nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', + nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', // Unknown symbol }, }, - ], + } as never, + [], ); await advanceTime({ clock, duration: 1 }); - // The nativeAssetIdentifier should be removed - expect( - controller.state.nativeAssetIdentifiers['eip155:43114'], - ).toBeUndefined(); + // EVM networks default to slip44:60 (Ethereum) when no specific mapping is found + expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( + 'eip155:43114/slip44:60', + ); }); it('does fallback to ethereum when removing the last enabled network', async () => { @@ -550,7 +591,7 @@ describe('NetworkEnablementController', () => { }); describe('init', () => { - it('initializes network enablement state from controller configurations', () => { + it('initializes network enablement state from controller configurations', async () => { const { controller, messenger } = setupController(); jest @@ -613,7 +654,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) @@ -654,7 +695,7 @@ describe('NetworkEnablementController', () => { }); }); - 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: { @@ -710,7 +751,7 @@ describe('NetworkEnablementController', () => { ); // Initialize from configurations - controller.init(); + await controller.init(); // Should only enable networks that exist in configurations expect(controller.state).toStrictEqual({ @@ -733,7 +774,7 @@ describe('NetworkEnablementController', () => { }); }); - it('handles missing MultichainNetworkController gracefully', () => { + it('handles missing MultichainNetworkController gracefully', async () => { const { controller, messenger } = setupController(); jest @@ -775,7 +816,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); @@ -783,7 +824,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 @@ -831,7 +872,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( @@ -845,7 +886,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 @@ -892,7 +933,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( @@ -901,7 +942,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 @@ -943,7 +984,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); @@ -954,7 +995,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 @@ -998,7 +1039,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); @@ -1008,10 +1049,112 @@ 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', () => { + it('populates nativeAssetIdentifiers from network configurations', async () => { const { controller } = setupController(); const networks = [ @@ -1023,7 +1166,7 @@ describe('NetworkEnablementController', () => { }, ]; - controller.initNativeAssetIdentifiers(networks); + await controller.initNativeAssetIdentifiers(networks); expect(controller.state.nativeAssetIdentifiers).toStrictEqual({ 'eip155:1': 'eip155:1/slip44:60', @@ -1033,7 +1176,7 @@ describe('NetworkEnablementController', () => { }); }); - it('skips networks with unknown symbols', () => { + it('defaults to slip44:60 for EVM networks with unknown symbols', async () => { const { controller } = setupController(); const networks = [ @@ -1041,25 +1184,26 @@ describe('NetworkEnablementController', () => { { chainId: 'eip155:999' as const, nativeCurrency: 'UNKNOWN_XYZ' }, ]; - controller.initNativeAssetIdentifiers(networks); + await controller.initNativeAssetIdentifiers(networks); expect(controller.state.nativeAssetIdentifiers['eip155:1']).toBe( 'eip155:1/slip44:60', ); - expect( - controller.state.nativeAssetIdentifiers['eip155:999'], - ).toBeUndefined(); + // 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', () => { + it('does not modify state for empty input', async () => { const { controller } = setupController(); - controller.initNativeAssetIdentifiers([]); + await controller.initNativeAssetIdentifiers([]); expect(controller.state.nativeAssetIdentifiers).toStrictEqual({}); }); - it('handles CAIP-19 format nativeCurrency from MultichainNetworkController', () => { + it('handles CAIP-19 format nativeCurrency from MultichainNetworkController', async () => { const { controller } = setupController(); // Non-EVM networks from MultichainNetworkController use CAIP-19 format for nativeCurrency @@ -1081,7 +1225,7 @@ describe('NetworkEnablementController', () => { }, ]; - controller.initNativeAssetIdentifiers(networks); + await controller.initNativeAssetIdentifiers(networks); expect(controller.state.nativeAssetIdentifiers).toStrictEqual({ 'eip155:1': 'eip155:1/slip44:60', diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.ts b/packages/network-enablement-controller/src/NetworkEnablementController.ts index 68e89b44046..ba3552d6ee1 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.ts @@ -243,7 +243,8 @@ export class NetworkEnablementController extends BaseController< messenger.subscribe( 'NetworkController:networkAdded', ({ chainId, nativeCurrency }) => { - this.#onAddNetwork(chainId, nativeCurrency); + // eslint-disable-next-line no-void + void this.#onAddNetwork(chainId, nativeCurrency); }, ); @@ -251,38 +252,59 @@ export class NetworkEnablementController extends BaseController< this.#removeNetworkEntry(chainId); }); + // Subscribe to nativeCurrency changes using selector approach messenger.subscribe( 'NetworkController:stateChange', - (_newState, patches) => { - this.#onNetworkControllerStateChange(patches); + ( + currentNativeCurrencies: Record, + previousNativeCurrencies: Record | undefined, + ) => { + // eslint-disable-next-line no-void + void this.#onNativeCurrencyChange( + currentNativeCurrencies, + previousNativeCurrencies, + ); }, + // Selector: extract chainId -> nativeCurrency map + (networkState) => + Object.fromEntries( + Object.entries(networkState.networkConfigurationsByChainId).map( + ([chainId, config]) => [chainId, config.nativeCurrency], + ), + ) as Record, ); } /** - * Handles NetworkController state changes to detect symbol updates. + * Handles changes to network nativeCurrency values. + * Compares current and previous nativeCurrency maps to detect updates. * - * @param patches - The patches describing what changed + * @param currentNativeCurrencies - Current map of chainId to nativeCurrency + * @param previousNativeCurrencies - Previous map of chainId to nativeCurrency */ - #onNetworkControllerStateChange( - patches: { op: string; path: (string | number)[]; value?: unknown }[], - ): void { - // Look for patches that replace a network configuration - // Path format: ['networkConfigurationsByChainId', chainId] - for (const patch of patches) { + async #onNativeCurrencyChange( + currentNativeCurrencies: Record, + previousNativeCurrencies: Record | undefined, + ): Promise { + // Skip if no previous state (initial subscription) + if (!previousNativeCurrencies) { + return; + } + + // Find chains where nativeCurrency has changed + for (const [chainId, currentCurrency] of Object.entries( + currentNativeCurrencies, + )) { + const previousCurrency = previousNativeCurrencies[chainId as Hex]; + + // Only update if the nativeCurrency changed (not for new chains - those are handled by networkAdded) if ( - patch.path.length === 2 && - patch.path[0] === 'networkConfigurationsByChainId' && - patch.op === 'replace' && - patch.value && - typeof patch.value === 'object' && - 'nativeCurrency' in patch.value + previousCurrency !== undefined && + previousCurrency !== currentCurrency ) { - const chainId = patch.path[1] as Hex; - const networkConfig = patch.value as { nativeCurrency: string }; - this.#updateNativeAssetIdentifier( - chainId, - networkConfig.nativeCurrency, + await this.#updateNativeAssetIdentifier( + chainId as Hex, + currentCurrency, ); } } @@ -467,42 +489,68 @@ export class NetworkEnablementController extends BaseController< * This method should be called after the NetworkController and MultichainNetworkController * have been initialized and their configurations are available. */ - init(): void { - this.update((state) => { - // 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.entries( networkControllerState.networkConfigurationsByChainId, - ).forEach(([chainId, config]) => { - const { namespace, storageKey, caipChainId } = deriveKeys( - chainId as Hex, - ); + ).forEach(([chainId]) => { + const { namespace, storageKey } = deriveKeys(chainId as Hex); this.#ensureNamespaceBucket(state, namespace); // Only add network if it doesn't already exist in state (preserves user settings) state.enabledNetworkMap[namespace][storageKey] ??= false; - - // Sync nativeAssetIdentifiers using the nativeCurrency symbol - if (state.nativeAssetIdentifiers[caipChainId] === undefined) { - const slip44CoinType = Slip44Service.getSlip44BySymbol( - config.nativeCurrency, - ); - if (slip44CoinType !== undefined) { - state.nativeAssetIdentifiers[caipChainId] = - buildNativeAssetIdentifier(caipChainId, slip44CoinType); - } - } }); + // 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, @@ -536,28 +584,57 @@ export class NetworkEnablementController extends BaseController< * nativeCurrency: config.nativeCurrency, * })); * - * controller.initNativeAssetIdentifiers([...evmNetworks, ...multichainNetworks]); + * await controller.initNativeAssetIdentifiers([...evmNetworks, ...multichainNetworks]); * ``` */ - initNativeAssetIdentifiers(networks: NetworkConfig[]): void { + 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, 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:')) { - state.nativeAssetIdentifiers[chainId] = - nativeCurrency as NativeAssetIdentifier; - } else { - // EVM networks use simple symbols like "ETH", "BNB" - const slip44CoinType = - Slip44Service.getSlip44BySymbol(nativeCurrency); - if (slip44CoinType !== undefined) { - state.nativeAssetIdentifiers[chainId] = buildNativeAssetIdentifier( - chainId, - slip44CoinType, - ); - } - } + for (const { chainId, identifier } of updates) { + state.nativeAssetIdentifiers[chainId] = identifier; } }); } @@ -619,28 +696,30 @@ export class NetworkEnablementController extends BaseController< } /** - * Updates the native asset identifier for a network based on its symbol. + * Updates the native asset identifier for an EVM network based on its symbol. * - * This method looks up the SLIP-44 coin type for the given symbol using the - * Slip44Service and updates the nativeAssetIdentifiers state with the full - * CAIP-19-like identifier. + * This method looks up the SLIP-44 coin type using chainid.network data + * (via getSlip44ByChainId) and updates the nativeAssetIdentifiers state. + * This is only called for EVM networks from NetworkController state changes. * - * @param chainId - The chain ID of the network (Hex or CAIP-2 format) - * @param symbol - The native currency symbol of the network (e.g., 'ETH', 'BTC') + * @param chainId - The chain ID of the network (Hex format) + * @param symbol - The native currency symbol of the network (e.g., 'ETH') */ - #updateNativeAssetIdentifier( - chainId: Hex | CaipChainId, + async #updateNativeAssetIdentifier( + chainId: Hex, symbol: string, - ): void { - const slip44CoinType = Slip44Service.getSlip44BySymbol(symbol); - const { caipChainId } = deriveKeys(chainId); + ): Promise { + const { caipChainId, reference } = 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, symbol)) ?? 60; this.update((state) => { - if (slip44CoinType === undefined) { - // Remove the entry if no SLIP-44 mapping exists for the symbol - delete state.nativeAssetIdentifiers[caipChainId]; - return; - } state.nativeAssetIdentifiers[caipChainId] = buildNativeAssetIdentifier( caipChainId, slip44CoinType, @@ -715,9 +794,9 @@ export class NetworkEnablementController extends BaseController< } /** - * 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 @@ -727,12 +806,20 @@ export class NetworkEnablementController extends BaseController< * - 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, nativeCurrency: string): void { + async #onAddNetwork(chainId: Hex, nativeCurrency: string): Promise { const { namespace, storageKey, reference, caipChainId } = deriveKeys(chainId); - // Look up the SLIP-44 coin type for the native currency - const slip44CoinType = Slip44Service.getSlip44BySymbol(nativeCurrency); + // 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 @@ -763,12 +850,10 @@ export class NetworkEnablementController extends BaseController< } // Update nativeAssetIdentifiers with the CAIP-19-like identifier - if (slip44CoinType !== undefined) { - state.nativeAssetIdentifiers[caipChainId] = buildNativeAssetIdentifier( - caipChainId, - slip44CoinType, - ); - } + 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 705f9dba373..63dbffba85c 100644 --- a/packages/network-enablement-controller/src/index.ts +++ b/packages/network-enablement-controller/src/index.ts @@ -21,5 +21,5 @@ export { selectEnabledSolanaNetworks, } from './selectors'; -export { Slip44Service } from './services'; +export { Slip44Service, getSlip44BySymbol, getSlip44ByChainId } from './services'; export type { Slip44Entry } from './services'; diff --git a/packages/network-enablement-controller/src/services/Slip44Service.test.ts b/packages/network-enablement-controller/src/services/Slip44Service.test.ts index b04a51b055f..68659f09997 100644 --- a/packages/network-enablement-controller/src/services/Slip44Service.test.ts +++ b/packages/network-enablement-controller/src/services/Slip44Service.test.ts @@ -1,9 +1,19 @@ +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', () => { @@ -162,4 +172,153 @@ describe('Slip44Service', () => { 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 index 09a75c31b12..0655405eafc 100644 --- a/packages/network-enablement-controller/src/services/Slip44Service.ts +++ b/packages/network-enablement-controller/src/services/Slip44Service.ts @@ -1,6 +1,9 @@ +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. */ @@ -10,6 +13,17 @@ export type Slip44Entry = { 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. @@ -25,22 +39,126 @@ type Slip44DataEntry = Slip44Entry & { type Slip44Data = Record; /** - * Service for looking up SLIP-44 coin type identifiers by symbol. + * 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 @@ -62,6 +180,9 @@ export class Slip44Service { 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) { @@ -88,10 +209,12 @@ export class Slip44Service { } /** - * Clears the internal symbol cache. + * 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 index 1b3df6590d8..683cb976dbd 100644 --- a/packages/network-enablement-controller/src/services/index.ts +++ b/packages/network-enablement-controller/src/services/index.ts @@ -1,2 +1,10 @@ -export { Slip44Service } from './Slip44Service'; +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); From 0c691324f97c5c4cb74ad7471709d3d0c1d72751 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 14 Jan 2026 13:22:27 +0100 Subject: [PATCH 3/4] fix: fix linter --- packages/network-enablement-controller/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/network-enablement-controller/src/index.ts b/packages/network-enablement-controller/src/index.ts index 63dbffba85c..e998a911b64 100644 --- a/packages/network-enablement-controller/src/index.ts +++ b/packages/network-enablement-controller/src/index.ts @@ -21,5 +21,9 @@ export { selectEnabledSolanaNetworks, } from './selectors'; -export { Slip44Service, getSlip44BySymbol, getSlip44ByChainId } from './services'; +export { + Slip44Service, + getSlip44BySymbol, + getSlip44ByChainId, +} from './services'; export type { Slip44Entry } from './services'; From f8cdcfc266bab0da0043592ea003a3243b56de68 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 14 Jan 2026 14:38:27 +0100 Subject: [PATCH 4/4] fix: clean up --- .../src/NetworkEnablementController.test.ts | 130 ------------------ .../src/NetworkEnablementController.ts | 89 ------------ 2 files changed, 219 deletions(-) diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts index c5cc15f594c..18b92a0826a 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -391,136 +391,6 @@ describe('NetworkEnablementController', () => { expect(controller.state).toStrictEqual(initialState); }); - it('subscribes to NetworkController:stateChange and updates nativeAssetIdentifiers when nativeCurrency changes', async () => { - const { controller, rootMessenger } = setupController(); - - // First add a network - rootMessenger.publish('NetworkController:networkAdded', { - chainId: '0xa86a', - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - name: 'Avalanche', - nativeCurrency: 'AVAX', - rpcEndpoints: [ - { - url: 'https://api.avax.network/ext/bc/C/rpc', - networkClientId: 'id', - type: RpcEndpointType.Custom, - }, - ], - }); - - await advanceTime({ clock, duration: 1 }); - - // Verify the network was added with AVAX coin type - expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( - 'eip155:43114/slip44:9000', - ); - - // Publish an initial state to establish the baseline for the selector - rootMessenger.publish( - 'NetworkController:stateChange', - { - networkConfigurationsByChainId: { - '0xa86a': { - chainId: '0xa86a', - nativeCurrency: 'AVAX', - }, - }, - } as never, - [], - ); - - await advanceTime({ clock, duration: 1 }); - - // Now publish a state change that updates the nativeCurrency - // The selector will detect the change from AVAX to ETH - rootMessenger.publish( - 'NetworkController:stateChange', - { - networkConfigurationsByChainId: { - '0xa86a': { - chainId: '0xa86a', - nativeCurrency: 'ETH', // Changed from AVAX to ETH - }, - }, - } as never, - [], - ); - - await advanceTime({ clock, duration: 1 }); - - // The nativeAssetIdentifier should now use ETH's coin type (60) - expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( - 'eip155:43114/slip44:60', - ); - }); - - it('defaults to slip44:60 when new symbol has no SLIP-44 mapping', async () => { - const { controller, rootMessenger } = setupController(); - - // First add a network with a known symbol - rootMessenger.publish('NetworkController:networkAdded', { - chainId: '0xa86a', - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - name: 'Avalanche', - nativeCurrency: 'AVAX', - rpcEndpoints: [ - { - url: 'https://api.avax.network/ext/bc/C/rpc', - networkClientId: 'id', - type: RpcEndpointType.Custom, - }, - ], - }); - - await advanceTime({ clock, duration: 1 }); - - // Verify the network was added with AVAX coin type - expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( - 'eip155:43114/slip44:9000', - ); - - // Publish an initial state to establish the baseline for the selector - rootMessenger.publish( - 'NetworkController:stateChange', - { - networkConfigurationsByChainId: { - '0xa86a': { - chainId: '0xa86a', - nativeCurrency: 'AVAX', - }, - }, - } as never, - [], - ); - - await advanceTime({ clock, duration: 1 }); - - // Now publish a state change with an unknown symbol - // The selector will detect the change from AVAX to UNKNOWN_SYMBOL_XYZ - rootMessenger.publish( - 'NetworkController:stateChange', - { - networkConfigurationsByChainId: { - '0xa86a': { - chainId: '0xa86a', - nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', // Unknown symbol - }, - }, - } as never, - [], - ); - - await advanceTime({ clock, duration: 1 }); - - // EVM networks default to slip44:60 (Ethereum) when no specific mapping is found - expect(controller.state.nativeAssetIdentifiers['eip155:43114']).toBe( - 'eip155:43114/slip44:60', - ); - }); - it('does fallback to ethereum when removing the last enabled network', async () => { const { controller, rootMessenger } = setupController(); diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.ts b/packages/network-enablement-controller/src/NetworkEnablementController.ts index ba3552d6ee1..fe549aa0d38 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.ts @@ -251,63 +251,6 @@ export class NetworkEnablementController extends BaseController< messenger.subscribe('NetworkController:networkRemoved', ({ chainId }) => { this.#removeNetworkEntry(chainId); }); - - // Subscribe to nativeCurrency changes using selector approach - messenger.subscribe( - 'NetworkController:stateChange', - ( - currentNativeCurrencies: Record, - previousNativeCurrencies: Record | undefined, - ) => { - // eslint-disable-next-line no-void - void this.#onNativeCurrencyChange( - currentNativeCurrencies, - previousNativeCurrencies, - ); - }, - // Selector: extract chainId -> nativeCurrency map - (networkState) => - Object.fromEntries( - Object.entries(networkState.networkConfigurationsByChainId).map( - ([chainId, config]) => [chainId, config.nativeCurrency], - ), - ) as Record, - ); - } - - /** - * Handles changes to network nativeCurrency values. - * Compares current and previous nativeCurrency maps to detect updates. - * - * @param currentNativeCurrencies - Current map of chainId to nativeCurrency - * @param previousNativeCurrencies - Previous map of chainId to nativeCurrency - */ - async #onNativeCurrencyChange( - currentNativeCurrencies: Record, - previousNativeCurrencies: Record | undefined, - ): Promise { - // Skip if no previous state (initial subscription) - if (!previousNativeCurrencies) { - return; - } - - // Find chains where nativeCurrency has changed - for (const [chainId, currentCurrency] of Object.entries( - currentNativeCurrencies, - )) { - const previousCurrency = previousNativeCurrencies[chainId as Hex]; - - // Only update if the nativeCurrency changed (not for new chains - those are handled by networkAdded) - if ( - previousCurrency !== undefined && - previousCurrency !== currentCurrency - ) { - await this.#updateNativeAssetIdentifier( - chainId as Hex, - currentCurrency, - ); - } - } } /** @@ -695,38 +638,6 @@ export class NetworkEnablementController extends BaseController< } } - /** - * Updates the native asset identifier for an EVM network based on its symbol. - * - * This method looks up the SLIP-44 coin type using chainid.network data - * (via getSlip44ByChainId) and updates the nativeAssetIdentifiers state. - * This is only called for EVM networks from NetworkController state changes. - * - * @param chainId - The chain ID of the network (Hex format) - * @param symbol - The native currency symbol of the network (e.g., 'ETH') - */ - async #updateNativeAssetIdentifier( - chainId: Hex, - symbol: string, - ): Promise { - const { caipChainId, reference } = 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, symbol)) ?? 60; - - this.update((state) => { - state.nativeAssetIdentifiers[caipChainId] = buildNativeAssetIdentifier( - caipChainId, - slip44CoinType, - ); - }); - } - /** * Checks if popular networks mode is active (more than 2 popular networks enabled). *