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"