diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 1974bfaf12..0149aa948c 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add stage-gated ingestion of the Snaps → AssetsController migration networks (Solana, Stellar, Tron) ([#9534](https://github.com/MetaMask/core/pull/9647)) + - `AssetsController` and `BackendWebsocketDataSource` now resolve a per-network migration stage from `RemoteFeatureFlagController` state (via `RemoteFeatureFlagController:getState`) using the `networkAssetsSnapsMigrationSolana`, `networkAssetsSnapsMigrationStellar`, and `networkAssetsSnapsMigrationTron` flags. Migration networks are only ingested and surfaced as active chains from `SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback` onward, and left to the Snap when the stage is `Off` (the fail-safe when the flag is missing). Non-migration namespaces (e.g. `eip155`) are never gated. + ### Fixed - Fetch spot prices immediately on price-subscription updates and after seeding native / default tracked assets so held assets are not left unpriced until the next poll after onboarding ([#9631](https://github.com/MetaMask/core/pull/9631)) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 3fb7d56a85..0c018c86f0 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -332,7 +332,7 @@ type AllowedActions = | BackendWebSocketServiceActions // PhishingController | PhishingControllerBulkScanTokensAction - // AccountsApiDataSource (Accounts API v6 balances feature flag) + // AccountsApiDataSource / BackendWebsocketDataSource (Accounts API v6 balances feature flag) | RemoteFeatureFlagControllerGetStateAction; type AllowedEvents = diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 5a0a5042fb..01686b2ead 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -24,6 +24,7 @@ import type { Middleware, AssetsControllerStateInternal, } from '../types.js'; +import { decimalToChainId } from '../utils/caip.js'; import { fetchWithTimeout, normalizeAssetId } from '../utils/index.js'; import { getMigrationStages, @@ -115,17 +116,6 @@ export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { // HELPER FUNCTIONS // ============================================================================ -function decimalToChainId(decimalChainId: number | string): ChainId { - // Handle both decimal numbers and already-formatted CAIP chain IDs - if (typeof decimalChainId === 'string') { - if (isCaipChainId(decimalChainId)) { - return decimalChainId; - } - return toCaipChainId(KnownCaipNamespace.Eip155, decimalChainId); - } - return toCaipChainId(KnownCaipNamespace.Eip155, String(decimalChainId)); -} - /** * Convert a CAIP-2 chain ID from the API response to our ChainId type. * Handles both formats: "eip155:1" or just "1" (decimal). diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 5f6887deba..95fcd58612 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -11,6 +11,10 @@ import type { MockAnyNamespace } from '@metamask/messenger'; import type { AssetsControllerMessenger } from '../AssetsController.js'; import type { Caip19AssetId, ChainId, DataRequest } from '../types.js'; +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, +} from '../utils/snaps-assets-migration.js'; import { BackendWebsocketDataSource, createBackendWebsocketDataSource, @@ -27,6 +31,8 @@ type RootMessenger = Messenger; const CHAIN_MAINNET = 'eip155:1' as ChainId; const CHAIN_POLYGON = 'eip155:137' as ChainId; const CHAIN_BASE = 'eip155:8453' as ChainId; +const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; +const STELLAR_CHAIN_ID = 'stellar:pubnet' as ChainId; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; type SetupResult = { @@ -39,6 +45,7 @@ type SetupResult = { removeChannelCallbackMock: jest.Mock; assetsUpdateHandler: jest.Mock; activeChainsUpdateHandler: jest.Mock; + fetchV2SupportedNetworksMock: jest.Mock; triggerConnectionStateChange: (state: WebSocketState) => void; triggerActiveChainsUpdate: (chains: ChainId[]) => void; }; @@ -106,11 +113,15 @@ function setupController( options: { initialActiveChains?: ChainId[]; connectionState?: WebSocketState; + supportedNetworks?: (number | string)[]; + remoteFeatureFlags?: Record; } = {}, ): SetupResult { const { initialActiveChains = [], connectionState = WebSocketState.CONNECTED, + supportedNetworks, + remoteFeatureFlags = {}, } = options; const rootMessenger = new Messenger({ @@ -127,6 +138,15 @@ function setupController( parent: rootMessenger, }); + ( + rootMessenger as unknown as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('RemoteFeatureFlagController:getState', () => ({ + remoteFeatureFlags, + cacheTimestamp: 0, + })); + rootMessenger.delegate({ messenger: controllerMessenger, actions: [ @@ -135,6 +155,7 @@ function setupController( 'BackendWebSocketService:findSubscriptionsByChannelPrefix', 'BackendWebSocketService:addChannelCallback', 'BackendWebSocketService:removeChannelCallback', + 'RemoteFeatureFlagController:getState', ], events: ['BackendWebSocketService:connectionStateChanged'], }); @@ -178,14 +199,18 @@ function setupController( removeChannelCallbackMock, ); + const fetchV2SupportedNetworksMock = jest.fn().mockResolvedValue({ + fullSupport: + supportedNetworks ?? + initialActiveChains.map((chainId) => { + // Pass CAIP-2 IDs through so non-EVM chains (e.g. Solana) survive. + return chainId; + }), + }); + const queryApiClient = { accounts: { - fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ - fullSupport: initialActiveChains.map((chainId) => { - const [, ref] = chainId.split(':'); - return parseInt(ref, 10); - }), - }), + fetchV2SupportedNetworks: fetchV2SupportedNetworksMock, }, }; @@ -241,6 +266,7 @@ function setupController( removeChannelCallbackMock, assetsUpdateHandler, activeChainsUpdateHandler, + fetchV2SupportedNetworksMock, triggerConnectionStateChange, triggerActiveChainsUpdate, }; @@ -299,6 +325,160 @@ describe('BackendWebsocketDataSource', () => { controller.destroy(); }); + describe('active chains migration gating', () => { + /** + * Init fetches supported networks while disconnected (so chains are stored + * but not claimed), then connect to reclaim filtered `#supportedChains`. + * + * @param options - Setup options forwarded to {@link setupController}. + * @returns The controller setup after chains have been claimed on connect. + */ + async function fetchAndClaimActiveChains( + options: Parameters[0] = {}, + ): Promise { + const setup = setupController({ + ...options, + initialActiveChains: options?.initialActiveChains ?? [], + connectionState: WebSocketState.DISCONNECTED, + }); + + await new Promise(process.nextTick); + + setup.getConnectionInfoMock.mockReturnValue({ + state: WebSocketState.CONNECTED, + url: 'wss://test.example.com', + reconnectAttempts: 0, + timeout: 30000, + reconnectDelay: 1000, + maxReconnectDelay: 30000, + requestTimeout: 30000, + }); + setup.triggerConnectionStateChange(WebSocketState.CONNECTED); + await new Promise(process.nextTick); + + return setup; + } + + it('filters out migration networks from active chains when the migration FF is unset', async () => { + const { controller, activeChainsUpdateHandler } = + await fetchAndClaimActiveChains({ + supportedNetworks: [1, SOLANA_CHAIN_ID], + }); + + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'BackendWebsocketDataSource', + [CHAIN_MAINNET], + [], + ); + + const chains = await controller.getActiveChains(); + expect(chains).toStrictEqual([CHAIN_MAINNET]); + + controller.destroy(); + }); + + it('filters out migration networks whose migration stage is Off', async () => { + const { controller, activeChainsUpdateHandler } = + await fetchAndClaimActiveChains({ + supportedNetworks: [1, SOLANA_CHAIN_ID], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.Off, + }, + }, + }); + + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'BackendWebsocketDataSource', + [CHAIN_MAINNET], + [], + ); + + const chains = await controller.getActiveChains(); + expect(chains).toStrictEqual([CHAIN_MAINNET]); + + controller.destroy(); + }); + + it.each([ + { + stageName: 'ReadAssetsControllerWithFallback', + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + { + stageName: 'ReadAssetsControllerWithoutFallback', + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + }, + { + stageName: 'ReadAssetsControllerOnly', + stage: SnapsAssetsMigrationStage.ReadAssetsControllerOnly, + }, + ])( + 'surfaces a migration network as an active chain when its migration stage is $stageName', + async ({ stage }) => { + const { controller, activeChainsUpdateHandler } = + await fetchAndClaimActiveChains({ + supportedNetworks: [1, SOLANA_CHAIN_ID], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { stage }, + }, + }); + + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'BackendWebsocketDataSource', + [CHAIN_MAINNET, SOLANA_CHAIN_ID], + [], + ); + + const chains = await controller.getActiveChains(); + expect(chains).toStrictEqual([CHAIN_MAINNET, SOLANA_CHAIN_ID]); + + controller.destroy(); + }, + ); + + it('gates migration networks independently per namespace', async () => { + const { controller } = await fetchAndClaimActiveChains({ + supportedNetworks: [1, SOLANA_CHAIN_ID, STELLAR_CHAIN_ID], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar]: { + stage: SnapsAssetsMigrationStage.Off, + }, + }, + }); + + const chains = await controller.getActiveChains(); + expect(chains).toStrictEqual([CHAIN_MAINNET, SOLANA_CHAIN_ID]); + + controller.destroy(); + }); + + it.each([ + { input: 1, expected: 'eip155:1' }, + { input: '137', expected: 'eip155:137' }, + { input: 'eip155:42161', expected: 'eip155:42161' }, + ])('converts chain ID $input to $expected', async ({ input, expected }) => { + const { controller, activeChainsUpdateHandler } = + await fetchAndClaimActiveChains({ + supportedNetworks: [input], + }); + + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'BackendWebsocketDataSource', + [expected], + [], + ); + + const chains = await controller.getActiveChains(); + expect(chains).toStrictEqual([expected]); + + controller.destroy(); + }); + }); + it('subscribe creates eip155 channel when no request chains match (eip155 account only)', async () => { const { controller, wsSubscribeMock } = setupController({ initialActiveChains: [CHAIN_MAINNET], diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index e15290bedb..5a9d4ce374 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -8,16 +8,14 @@ import type { BalanceUpdate, } from '@metamask/core-backend'; import type { ApiPlatformClient } from '@metamask/core-backend'; -import { - isCaipChainId, - KnownCaipNamespace, - toCaipChainId, -} from '@metamask/utils'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { AssetsControllerMessenger } from '../AssetsController.js'; import { projectLogger, createModuleLogger } from '../logger.js'; import type { ChainId, Caip19AssetId, DataResponse } from '../types.js'; +import { decimalToChainId } from '../utils/caip.js'; import { processAccountActivityBalanceUpdates } from '../utils/processAccountActivityBalanceUpdates.js'; +import { shouldSupportChain } from '../utils/snaps-assets-migration.js'; import { AbstractDataSource } from './AbstractDataSource.js'; import type { DataSourceState, @@ -39,7 +37,8 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // Allowed actions that BackendWebsocketDataSource can call export type BackendWebsocketDataSourceAllowedActions = - BackendWebSocketServiceActions; + | BackendWebSocketServiceActions + | RemoteFeatureFlagControllerGetStateAction; // Allowed events that BackendWebsocketDataSource can subscribe to export type BackendWebsocketDataSourceAllowedEvents = @@ -190,25 +189,6 @@ function haveAddressesChanged( ); } -/** - * Normalize API chain identifier to CAIP-2 ChainId. - * Passes through strings already in CAIP-2 form (e.g. eip155:1, solana:5eykt...). - * Converts bare decimals to eip155:decimal. - * Uses @metamask/utils for CAIP parsing. - * - * @param chainIdOrDecimal - Chain ID string (CAIP-2 or decimal) or decimal number. - * @returns CAIP-2 ChainId. - */ -function toChainId(chainIdOrDecimal: number | string): ChainId { - if (typeof chainIdOrDecimal === 'string') { - if (isCaipChainId(chainIdOrDecimal)) { - return chainIdOrDecimal; - } - return toCaipChainId(KnownCaipNamespace.Eip155, chainIdOrDecimal); - } - return toCaipChainId(KnownCaipNamespace.Eip155, String(chainIdOrDecimal)); -} - // Note: AccountActivityMessage and BalanceUpdate types are imported from @metamask/core-backend // ============================================================================ @@ -242,6 +222,7 @@ function toChainId(chainIdOrDecimal: number | string): ChainId { * - BackendWebSocketService:findSubscriptionsByChannelPrefix * - BackendWebSocketService:addChannelCallback * - BackendWebSocketService:removeChannelCallback + * - RemoteFeatureFlagController:getState */ const DEFAULT_CHAINS_REFRESH_INTERVAL_MS = 20 * 60 * 1000; // 20 minutes @@ -371,7 +352,17 @@ export class BackendWebsocketDataSource extends AbstractDataSource< async #fetchActiveChains(): Promise { const response = await this.#apiClient.accounts.fetchV2SupportedNetworks(); - return response.fullSupport.map(toChainId); + // Use fullSupport networks as active chains, gated by the Snaps → + // AssetsController migration FF: non-migration namespaces (e.g. `eip155`) + // are always surfaced, while migration networks (Solana, Stellar, Tron) are + // only surfaced once their per-network stage reaches + // ReadAssetsControllerWithFallback. + const { remoteFeatureFlags } = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ); + return response.fullSupport + .map(decimalToChainId) + .filter((chainId) => shouldSupportChain(chainId, remoteFeatureFlags)); } #subscribeToEvents(): void { diff --git a/packages/assets-controller/src/utils/caip.test.ts b/packages/assets-controller/src/utils/caip.test.ts new file mode 100644 index 0000000000..d48cd3809a --- /dev/null +++ b/packages/assets-controller/src/utils/caip.test.ts @@ -0,0 +1,21 @@ +import { decimalToChainId } from './caip.js'; + +describe('decimalToChainId', () => { + it('converts a decimal number to an eip155 CAIP chain ID', () => { + expect(decimalToChainId(1)).toBe('eip155:1'); + expect(decimalToChainId(137)).toBe('eip155:137'); + }); + + it('converts a decimal string to an eip155 CAIP chain ID', () => { + expect(decimalToChainId('1')).toBe('eip155:1'); + expect(decimalToChainId('42161')).toBe('eip155:42161'); + }); + + it('passes through already-formatted CAIP chain IDs', () => { + expect(decimalToChainId('eip155:1')).toBe('eip155:1'); + expect(decimalToChainId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')).toBe( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + expect(decimalToChainId('stellar:pubnet')).toBe('stellar:pubnet'); + }); +}); diff --git a/packages/assets-controller/src/utils/caip.ts b/packages/assets-controller/src/utils/caip.ts new file mode 100644 index 0000000000..c8ad0520e9 --- /dev/null +++ b/packages/assets-controller/src/utils/caip.ts @@ -0,0 +1,26 @@ +import { + isCaipChainId, + KnownCaipNamespace, + toCaipChainId, +} from '@metamask/utils'; + +import type { ChainId } from '../types.js'; + +/** + * Convert a decimal chain ID to a CAIP chain ID. + * + * Handles both decimal numbers and already-formatted CAIP chain IDs. + * + * @param decimalChainId - The decimal chain ID to convert to a CAIP chain ID. + * @returns The CAIP chain ID. + */ +export function decimalToChainId(decimalChainId: number | string): ChainId { + // Handle both decimal numbers and already-formatted CAIP chain IDs + if (typeof decimalChainId === 'string') { + if (isCaipChainId(decimalChainId)) { + return decimalChainId; + } + return toCaipChainId(KnownCaipNamespace.Eip155, decimalChainId); + } + return toCaipChainId(KnownCaipNamespace.Eip155, String(decimalChainId)); +} diff --git a/packages/assets-controller/src/utils/index.ts b/packages/assets-controller/src/utils/index.ts index ee56396dc8..84603ba824 100644 --- a/packages/assets-controller/src/utils/index.ts +++ b/packages/assets-controller/src/utils/index.ts @@ -26,3 +26,4 @@ export { buildNativeAssetsFromConstant, buildNativeAssetsFromApi, } from './native-assets.js'; +export { decimalToChainId } from './caip.js';