diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 9a4c4e67ece..0a576dcf518 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AccountsApiDataSource` now selects the Accounts API balances endpoint version from the `RemoteFeatureFlagController` (`assetsAccountsApiV6` flag, read per fetch off the shared messenger, default v5) so the v6 endpoint is gated consistently across clients (extension, mobile) without each client wiring a getter. The flag is read as a JSON variation shaped `{ value: boolean }` (same shape as `backendWebSocketConnection`). Adds a required `messenger` option and `RemoteFeatureFlagController:getState` to `AccountsApiDataSourceAllowedActions`. Only `category: 'token'` rows from the v6 response are consumed (DeFi positions are ignored) to preserve parity with v5 ([#9344](https://github.com/MetaMask/core/pull/9344)) - Add `getAsset(accountId, assetId)` method and `AssetsController:getAsset` messenger action that returns the combined `Asset` (balance, metadata, price) for a single account/asset pair from controller state, or `undefined` when a complete renderable asset is not available ([#9521](https://github.com/MetaMask/core/pull/9521)) +- Add stage-gated ingestion of the Snaps → AssetsController migration networks (Solana, Stellar, Tron) ([WPN-1489](https://consensyssoftware.atlassian.net/browse/WPN-1489)) + - `AssetsController` now resolves the per-network migration stage from `RemoteFeatureFlagController` state (read via the `RemoteFeatureFlagController:getState` messenger action) using the per-network flags `networkAssetsSnapsMigrationSolana`, `networkAssetsSnapsMigrationStellar`, and `networkAssetsSnapsMigrationTron`. The controller ingests those networks via the Account Activity WebSocket + AccountsAPI only from `SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback` onward, and leaves them to the Snap when the stage is `Off` (also the fail-safe when the flag is missing). Non-migration namespaces (e.g. `eip155`) are never gated. + - `AccountsApiDataSource` now gates the supported networks it surfaces as active chains on the same per-network migration stage instead of the previous hardcoded `eip155`-only filter. Non-migration namespaces (e.g. `eip155`) are always surfaced, while migration networks (Solana, Stellar, Tron) are only surfaced once their stage reaches `SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback`. + - Export the `SnapsAssetsMigrationStage` enum, the `SNAPS_ASSETS_MIGRATION_FLAG_KEYS` and `SNAPS_ASSETS_MIGRATION_NAMESPACES` constants, and the `getSnapsAssetsMigrationNamespace` / `parseSnapsAssetsMigrationStage` / `isMigrationStageActive` / `isSnapsAssetsMigrationNamespace` helpers. ### Changed +- Add `@metamask/remote-feature-flag-controller` as a dependency ([WPN-1489](https://consensyssoftware.atlassian.net/browse/WPN-1489)) - Bump `@metamask/network-enablement-controller` from `^5.5.0` to `^5.6.0` ([#9520](https://github.com/MetaMask/core/pull/9520)) - Bump `@metamask/phishing-controller` from `^17.2.1` to `^17.3.0` ([#9532](https://github.com/MetaMask/core/pull/9532)) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 0ed051dcce9..3538a0f0611 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -47,7 +47,10 @@ import type { } from '@metamask/permission-controller'; import { PhishingControllerBulkScanTokensAction } from '@metamask/phishing-controller'; import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; import type { SnapControllerGetRunnableSnapsAction, SnapControllerHandleRequestAction, @@ -353,7 +356,10 @@ type AllowedEvents = // BackendWebsocketDataSource | BackendWebSocketServiceEvents // AccountActivityService (real-time balance updates for unified assets) - | AccountActivityServiceBalanceUpdatedEvent; + | AccountActivityServiceBalanceUpdatedEvent + // AccountsApiDataSource subscribes to react to Snaps → AssetsController + // migration flag changes (which gate the chains it surfaces as active) + | RemoteFeatureFlagControllerStateChangeEvent; export type AssetsControllerMessenger = Messenger< typeof CONTROLLER_NAME, diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index f01829bf159..53233481186 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -15,9 +15,14 @@ import type { Context, AssetsControllerStateInternal, } from '../types'; +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, +} from '../utils/snaps-assets-migration'; import type { AccountsApiDataSourceOptions, AccountsApiDataSourceAllowedActions, + AccountsApiDataSourceAllowedEvents, } from './AccountsApiDataSource'; import { AccountsApiDataSource, @@ -25,7 +30,7 @@ import { } from './AccountsApiDataSource'; type AllActions = AccountsApiDataSourceAllowedActions; -type AllEvents = never; +type AllEvents = AccountsApiDataSourceAllowedEvents; type RootMessenger = Messenger; const CHAIN_MAINNET = 'eip155:1' as ChainId; @@ -186,7 +191,8 @@ async function setupController( remoteFeatureFlags === undefined ? [] : ['RemoteFeatureFlagController:getState'], - events: [], + // eslint-disable-next-line no-restricted-syntax + events: ['RemoteFeatureFlagController:stateChange'], }); const assetsUpdateHandler = jest.fn().mockResolvedValue(undefined); @@ -302,6 +308,67 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + describe('RemoteFeatureFlagController:stateChange subscription', () => { + it('refreshes active chains when a migration stage changes', async () => { + const { controller, apiClient, messenger } = await setupController({ + remoteFeatureFlags: {}, + }); + + apiClient.accounts.fetchV2SupportedNetworks.mockClear(); + + messenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + }, + cacheTimestamp: 0, + }, + [], + ); + + await new Promise(process.nextTick); + + expect(apiClient.accounts.fetchV2SupportedNetworks).toHaveBeenCalledTimes( + 1, + ); + + controller.destroy(); + }); + + it('does not refresh active chains when an unrelated flag changes', async () => { + const { controller, apiClient, messenger } = await setupController({ + remoteFeatureFlags: {}, + }); + + // Establish the baseline migration-stage signature. + messenger.publish( + 'RemoteFeatureFlagController:stateChange', + { remoteFeatureFlags: {}, cacheTimestamp: 0 }, + [], + ); + await new Promise(process.nextTick); + apiClient.accounts.fetchV2SupportedNetworks.mockClear(); + + // An unrelated flag change keeps the migration-stage signature identical, + // so the selector-gated handler must not fire. + messenger.publish( + 'RemoteFeatureFlagController:stateChange', + { remoteFeatureFlags: { someUnrelatedFlag: true }, cacheTimestamp: 0 }, + [], + ); + await new Promise(process.nextTick); + + expect( + apiClient.accounts.fetchV2SupportedNetworks, + ).not.toHaveBeenCalled(); + + controller.destroy(); + }); + }); + it('exposes assetsMiddleware and getActiveChains on instance', async () => { const { controller } = await setupController(); @@ -314,10 +381,33 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('filters out non-EVM chains from active chains', async () => { + it('filters out migration networks from active chains when the migration FF is unset', async () => { + const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const { controller, activeChainsUpdateHandler } = await setupController({ + supportedChains: [1, SOLANA_CHAIN_ID as unknown as number], + }); + + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'AccountsApiDataSource', + [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 SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; const { controller, activeChainsUpdateHandler } = await setupController({ supportedChains: [1, SOLANA_CHAIN_ID as unknown as number], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.Off, + }, + }, }); expect(activeChainsUpdateHandler).toHaveBeenCalledWith( @@ -332,6 +422,69 @@ describe('AccountsApiDataSource', () => { 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 SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const { controller, activeChainsUpdateHandler } = await setupController({ + supportedChains: [1, SOLANA_CHAIN_ID as unknown as number], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { stage }, + }, + }); + + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'AccountsApiDataSource', + [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 SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const STELLAR_CHAIN_ID = 'stellar:pubnet'; + const { controller } = await setupController({ + supportedChains: [ + 1, + SOLANA_CHAIN_ID as unknown as number, + STELLAR_CHAIN_ID as unknown as number, + ], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar]: { + stage: SnapsAssetsMigrationStage.Off, + }, + }, + }); + + // Solana is staged on, Stellar is Off — only Solana joins EVM chains. + 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' }, diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 6ac6167b0e4..6086df66e39 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -3,7 +3,11 @@ import type { V6AccountBalancesEntry, } from '@metamask/core-backend'; import { ApiPlatformClient } from '@metamask/core-backend'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { + FeatureFlags, + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; import { isCaipChainId, KnownCaipNamespace, @@ -22,6 +26,14 @@ import type { AssetsControllerStateInternal, } from '../types'; import { fetchWithTimeout, normalizeAssetId } from '../utils'; +import type { SnapsAssetsMigrationStage } from '../utils/snaps-assets-migration'; +import { + getSnapsAssetsMigrationNamespace, + isMigrationStageActive, + parseSnapsAssetsMigrationStage, + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SNAPS_ASSETS_MIGRATION_NAMESPACES, +} from '../utils/snaps-assets-migration'; import type { DataSourceState, SubscriptionRequest, @@ -44,10 +56,17 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // Allowed actions that AccountsApiDataSource can call. Balances are fetched via // ApiPlatformClient directly (no BackendApiClient actions needed); the messenger -// is only used to read the Accounts API v6 balances feature flag. +// is used to read the Accounts API v6 balances feature flag and to subscribe to +// `RemoteFeatureFlagController:stateChange` (see constructor) so migration flag +// changes refresh the active chains. export type AccountsApiDataSourceAllowedActions = RemoteFeatureFlagControllerGetStateAction; +// Allowed events that AccountsApiDataSource subscribes to. Migration flag +// changes trigger a refresh of the chains surfaced as active. +export type AccountsApiDataSourceAllowedEvents = + RemoteFeatureFlagControllerStateChangeEvent; + // ============================================================================ // STATE // ============================================================================ @@ -230,9 +249,62 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#apiClient = options.queryApiClient; + // The Snaps → AssetsController migration flags gate which migration networks + // (Solana, Stellar, Tron) are surfaced as active chains (see + // `#shouldSupportChain`). Mirror core-backend's AccountActivityService and + // react to remote feature flag changes so newly-enabled chains are picked up + // (and disabled ones dropped) without waiting for the periodic refresh. + this.#messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + // Promise result intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => await this.#handleMigrationFeatureFlagsChanged(), + // Only react to changes in the set of migration stages. The messenger + // compares selector results with strict equality, so the selector must + // return a primitive rather than a fresh object. + (state) => this.#getMigrationStages(state.remoteFeatureFlags).join(','), + ); + this.#initializeActiveChains().catch(console.error); } + /** + * Get the Snaps → AssetsController migration stage for each migration network + * (Solana, Stellar, Tron). Used by the `RemoteFeatureFlagController:stateChange` + * selector so the handler only fires when a migration stage actually changes. + * + * @param remoteFeatureFlags - The remote feature flags state to resolve stages from. + * @returns An array of each migration namespace's resolved stage. + */ + #getMigrationStages( + remoteFeatureFlags: FeatureFlags = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ).remoteFeatureFlags, + ): SnapsAssetsMigrationStage[] { + return SNAPS_ASSETS_MIGRATION_NAMESPACES.map((namespace) => + parseSnapsAssetsMigrationStage( + remoteFeatureFlags[SNAPS_ASSETS_MIGRATION_FLAG_KEYS[namespace]], + ), + ); + } + + /** + * Handle a change to the Snaps → AssetsController migration flags: re-fetch + * active chains so newly-enabled migration networks are surfaced (and disabled + * ones dropped) without waiting for the periodic refresh. The refresh invokes + * `onActiveChainsUpdated` when the set changes, which drives re-subscription. + */ + async #handleMigrationFeatureFlagsChanged(): Promise { + try { + await this.#refreshActiveChains(); + } catch (error) { + log('Failed to refresh active chains after feature flag change', { + error, + }); + } + } + /** * Whether the Accounts API v6 balances endpoint is enabled, read from the * RemoteFeatureFlagController (`assetsAccountsApiV6`). Read on demand (per @@ -262,6 +334,41 @@ export class AccountsApiDataSource extends AbstractDataSource< } } + /** + * Whether a supported network from the Accounts API should be surfaced as an + * active chain, gated by the Snaps → AssetsController migration FF. + * + * Non-migration namespaces (e.g. `eip155`) are always surfaced. Migration + * networks (Solana, Stellar, Tron) are only surfaced once their per-network + * stage reaches {@link SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback}; + * they are excluded when the stage is `Off` (also the fail-safe when the flag + * is missing or the controller is unavailable). + * + * @param chainId - The CAIP-2 chain id from the supported-networks response. + * @returns `true` when the chain should be an active chain for the Accounts API. + */ + #shouldSupportChain(chainId: ChainId): boolean { + const namespace = getSnapsAssetsMigrationNamespace(chainId); + // Non-migration namespaces (e.g. `eip155`) are never gated. + if (!namespace) { + return true; + } + + let flags: FeatureFlags | undefined; + try { + ({ remoteFeatureFlags: flags } = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + )); + } catch { + // Controller unavailable — fall through to Off (chain excluded). + } + + const stage = parseSnapsAssetsMigrationStage( + flags?.[SNAPS_ASSETS_MIGRATION_FLAG_KEYS[namespace]], + ); + return isMigrationStageActive(stage); + } + // ============================================================================ // INITIALIZATION // ============================================================================ @@ -323,13 +430,14 @@ export class AccountsApiDataSource extends AbstractDataSource< async #fetchActiveChains(): Promise { const response = await this.#apiClient.accounts.fetchV2SupportedNetworks(); - // Use fullSupport networks as active chains - return ( - response.fullSupport - .map(decimalToChainId) - // TODO Restore solana when there is a fix for how we handle non-evm chains here - .filter((chainId) => chainId.startsWith('eip155:')) - ); + // 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. + return response.fullSupport + .map(decimalToChainId) + .filter((chainId) => this.#shouldSupportChain(chainId)); } // ============================================================================ @@ -798,12 +906,10 @@ export class AccountsApiDataSource extends AbstractDataSource< // ============================================================================ destroy(): void { - // Clean up timers if (this.#chainsRefreshTimer) { clearInterval(this.#chainsRefreshTimer); } - // Clean up subscriptions super.destroy(); } } diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 4f69c989517..c99f25c2198 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -14,6 +14,18 @@ export { } from './defaults'; export type { PendingTokenMetadata } from './AssetsController'; +// Snaps → AssetsController assets migration +export { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SNAPS_ASSETS_MIGRATION_NAMESPACES, + SnapsAssetsMigrationStage, + getSnapsAssetsMigrationNamespace, + isMigrationStageActive, + isSnapsAssetsMigrationNamespace, + parseSnapsAssetsMigrationStage, +} from './utils/snaps-assets-migration'; +export type { SnapsAssetsMigrationNamespace } from './utils/snaps-assets-migration'; + // State and messenger types export type { AssetsControllerState, diff --git a/packages/assets-controller/src/utils/snaps-assets-migration.test.ts b/packages/assets-controller/src/utils/snaps-assets-migration.test.ts new file mode 100644 index 00000000000..01ea7113155 --- /dev/null +++ b/packages/assets-controller/src/utils/snaps-assets-migration.test.ts @@ -0,0 +1,166 @@ +import type { Json } from '@metamask/utils'; + +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SNAPS_ASSETS_MIGRATION_NAMESPACES, + SnapsAssetsMigrationStage, + getSnapsAssetsMigrationNamespace, + isMigrationStageActive, + isSnapsAssetsMigrationNamespace, + parseSnapsAssetsMigrationStage, +} from './snaps-assets-migration'; +import type { ChainId } from '../types'; + +const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; +const STELLAR_CHAIN_ID = 'stellar:pubnet' as ChainId; +const TRON_CHAIN_ID = 'tron:728126428' as ChainId; + +describe('snaps-assets-migration', () => { + describe('SnapsAssetsMigrationStage', () => { + it('orders stages so ingestion turns on at ReadAssetsControllerWithFallback', () => { + expect(SnapsAssetsMigrationStage.Off).toBe(0); + expect(SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback).toBe(1); + expect(SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback).toBe( + 2, + ); + expect(SnapsAssetsMigrationStage.ReadAssetsControllerOnly).toBe(3); + }); + }); + + describe('SNAPS_ASSETS_MIGRATION_FLAG_KEYS', () => { + it('maps each migration namespace to its per-network flag key', () => { + expect(SNAPS_ASSETS_MIGRATION_FLAG_KEYS).toStrictEqual({ + solana: 'networkAssetsSnapsMigrationSolana', + stellar: 'networkAssetsSnapsMigrationStellar', + tron: 'networkAssetsSnapsMigrationTron', + }); + }); + }); + + describe('SNAPS_ASSETS_MIGRATION_NAMESPACES', () => { + it('covers Solana, Stellar and Tron', () => { + expect([...SNAPS_ASSETS_MIGRATION_NAMESPACES]).toStrictEqual([ + 'solana', + 'stellar', + 'tron', + ]); + }); + }); + + describe('isMigrationStageActive', () => { + it('is inactive when the stage is Off', () => { + expect(isMigrationStageActive(SnapsAssetsMigrationStage.Off)).toBe(false); + }); + + it.each([ + SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + SnapsAssetsMigrationStage.ReadAssetsControllerOnly, + ])('is active from stage %d', (stage) => { + expect(isMigrationStageActive(stage)).toBe(true); + }); + }); + + describe('isSnapsAssetsMigrationNamespace', () => { + it.each([[SOLANA_CHAIN_ID], [STELLAR_CHAIN_ID], [TRON_CHAIN_ID]])( + 'returns true for migration network %s', + (chainId) => { + expect(isSnapsAssetsMigrationNamespace(chainId)).toBe(true); + }, + ); + + it.each([ + ['eip155:1'], + ['eip155:42161'], + ['bip122:000000000019d6689c085ae165831e93'], + ])('returns false for non-migration network %s', (chainId) => { + expect(isSnapsAssetsMigrationNamespace(chainId as ChainId)).toBe(false); + }); + + it('returns false for a malformed chain id', () => { + expect(isSnapsAssetsMigrationNamespace('not-a-chain-id' as ChainId)).toBe( + false, + ); + }); + }); + + describe('getSnapsAssetsMigrationNamespace', () => { + it.each([ + [SOLANA_CHAIN_ID, 'solana'], + [STELLAR_CHAIN_ID, 'stellar'], + [TRON_CHAIN_ID, 'tron'], + ] as const)('resolves %s to the %s namespace', (chainId, namespace) => { + expect(getSnapsAssetsMigrationNamespace(chainId)).toBe(namespace); + }); + + it.each([ + ['eip155:1'], + ['bip122:000000000019d6689c085ae165831e93'], + ['not-a-chain-id'], + ])('returns undefined for non-migration chain %s', (chainId) => { + expect(getSnapsAssetsMigrationNamespace(chainId as ChainId)).toBeUndefined(); + }); + }); + + describe('parseSnapsAssetsMigrationStage', () => { + it('reads the stage from a resolved flag value', () => { + expect( + parseSnapsAssetsMigrationStage({ + featureVersion: '1', + minimumSnapVersion: '1.20.0', + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }), + ).toBe(SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback); + }); + + it('reads the stage from a threshold-scoped `{ name, value }` variant', () => { + expect( + parseSnapsAssetsMigrationStage({ + name: 's1', + value: { + featureVersion: '1', + minimumSnapVersion: '2.9.0', + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + }), + ).toBe(SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback); + }); + + it.each([ + SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + SnapsAssetsMigrationStage.ReadAssetsControllerOnly, + ])('preserves valid stage %d', (stage) => { + expect(parseSnapsAssetsMigrationStage({ stage })).toBe(stage); + }); + + it.each([ + SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + SnapsAssetsMigrationStage.ReadAssetsControllerOnly, + ])('preserves valid nested stage %d', (stage) => { + expect(parseSnapsAssetsMigrationStage({ name: 's1', value: { stage } })).toBe( + stage, + ); + }); + + it.each<[string, Json | undefined]>([ + ['undefined value', undefined], + ['stage Off', { stage: SnapsAssetsMigrationStage.Off }], + ['nested stage Off', { value: { stage: SnapsAssetsMigrationStage.Off } }], + ['non-object value', true], + ['null value', null], + ['array value', []], + ['missing stage', { featureVersion: '1' }], + ['nested missing stage', { name: 's1', value: { featureVersion: '1' } }], + ['unknown stage number', { stage: 99 }], + ['nested unknown stage number', { value: { stage: 99 } }], + ['non-numeric stage', { stage: '1' }], + ['nested non-numeric stage', { value: { stage: '1' } }], + ])('returns Off for %s', (_label, flagValue) => { + expect(parseSnapsAssetsMigrationStage(flagValue)).toBe( + SnapsAssetsMigrationStage.Off, + ); + }); + }); +}); diff --git a/packages/assets-controller/src/utils/snaps-assets-migration.ts b/packages/assets-controller/src/utils/snaps-assets-migration.ts new file mode 100644 index 00000000000..c7c8b5d9a40 --- /dev/null +++ b/packages/assets-controller/src/utils/snaps-assets-migration.ts @@ -0,0 +1,153 @@ +import type { Json } from '@metamask/utils'; +import { parseCaipChainId } from '@metamask/utils'; + +import type { ChainId } from '../types'; + +/** + * Per-network rollout stage for the Snaps → AssetsController assets migration. + * + * The stage is resolved per CAIP-2 chain id, so each migration network (Solana, + * Stellar, Tron) advances and rolls back independently. A network with no flag + * (or an unknown/malformed value) resolves to {@link Off}. + */ +export enum SnapsAssetsMigrationStage { + /** + * Status quo — the Snap uses its own AssetsService and the AssetsController + * does NOT ingest for the network. + */ + Off = 0, + /** + * The AssetsController ingests for the network (Account Activity WebSocket + + * AccountsAPI) and reads route through it, falling back to the Snap's + * AssetsService when the controller has no value. + */ + ReadAssetsControllerWithFallback = 1, + /** + * Reads come from the AssetsController only (no fallback), but the Snap still + * tracks/persists in the background so rollback stays safe. + */ + ReadAssetsControllerWithoutFallback = 2, + /** + * The Snap stops tracking/persisting; the AssetsController is the only source. + */ + ReadAssetsControllerOnly = 3, +} + +/** + * CAIP-2 namespaces covered by the Snaps → AssetsController assets migration. + * Only chains in these namespaces are stage-gated; every other namespace + * (e.g. `eip155`) keeps its existing, ungated ingestion behavior. + */ +export const SNAPS_ASSETS_MIGRATION_NAMESPACES = [ + 'solana', + 'stellar', + 'tron', +] as const; + +export type SnapsAssetsMigrationNamespace = + (typeof SNAPS_ASSETS_MIGRATION_NAMESPACES)[number]; + +/** + * Per-network remote feature flag keys (LaunchDarkly) that drive the migration. + * Each flag's resolved value carries the {@link SnapsAssetsMigrationStage} for + * that network — see {@link parseSnapsAssetsMigrationStage}. + */ +export const SNAPS_ASSETS_MIGRATION_FLAG_KEYS: Record< + SnapsAssetsMigrationNamespace, + string +> = { + solana: 'networkAssetsSnapsMigrationSolana', + stellar: 'networkAssetsSnapsMigrationStellar', + tron: 'networkAssetsSnapsMigrationTron', +}; + +/** + * Resolve the CAIP-2 namespace of a chain when it belongs to a migration + * network (Solana, Stellar, Tron). + * + * @param chainId - The CAIP-2 chain id. + * @returns The migration namespace, or `undefined` for any other chain. + */ +export function getSnapsAssetsMigrationNamespace( + chainId: ChainId, +): SnapsAssetsMigrationNamespace | undefined { + try { + const { namespace } = parseCaipChainId(chainId); + return (SNAPS_ASSETS_MIGRATION_NAMESPACES as readonly string[]).includes( + namespace, + ) + ? (namespace as SnapsAssetsMigrationNamespace) + : undefined; + } catch { + return undefined; + } +} + +/** + * Whether a CAIP-2 chain id belongs to a network covered by the assets + * migration (Solana, Stellar, Tron). + * + * @param chainId - The CAIP-2 chain id to check. + * @returns True when the chain's namespace is a migration namespace. + */ +export function isSnapsAssetsMigrationNamespace(chainId: ChainId): boolean { + return getSnapsAssetsMigrationNamespace(chainId) !== undefined; +} + +/** + * Coerce a resolved remote feature flag value into a {@link SnapsAssetsMigrationStage}. + * + * `RemoteFeatureFlagController` resolves a threshold-scoped flag to a + * `{ name, value }` variant, so the migration payload + * `{ featureVersion, minimumSnapVersion, stage }` (and thus `stage`) lives under + * `value` — the same nesting the sibling `assetsAccountsApiV6` flag uses. The + * top-level object is used as a fallback so a plain, unscoped `{ stage }` + * variation still resolves. Anything missing, malformed, or carrying an unknown + * `stage` resolves to {@link SnapsAssetsMigrationStage.Off}. + * + * @param flagValue - The resolved remote feature flag value for a network. + * @returns The migration stage. + */ +export function parseSnapsAssetsMigrationStage( + flagValue: Json | undefined, +): SnapsAssetsMigrationStage { + if ( + typeof flagValue !== 'object' || + flagValue === null || + Array.isArray(flagValue) + ) { + return SnapsAssetsMigrationStage.Off; + } + + const { value } = flagValue as { value?: unknown }; + const payload = + typeof value === 'object' && value !== null && !Array.isArray(value) + ? value + : flagValue; + + const { stage } = payload as { stage?: unknown }; + switch (stage) { + case SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback: + case SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback: + case SnapsAssetsMigrationStage.ReadAssetsControllerOnly: + return stage; + default: + return SnapsAssetsMigrationStage.Off; + } +} + +/** + * Whether a network's migration stage is active, i.e. the AssetsController + * (rather than the Snap) is the source of assets for the network. + * + * A stage is active from {@link SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback} + * onward and inactive at {@link SnapsAssetsMigrationStage.Off}. + * + * @param stage - The resolved per-network migration stage. + * @returns True when the migration is active for the network. + */ +export function isMigrationStageActive( + stage: SnapsAssetsMigrationStage, +): boolean { + return stage >= SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback; +} diff --git a/packages/assets-controller/tsconfig.build.json b/packages/assets-controller/tsconfig.build.json index 311966d77d3..0a9c261be38 100644 --- a/packages/assets-controller/tsconfig.build.json +++ b/packages/assets-controller/tsconfig.build.json @@ -39,6 +39,9 @@ { "path": "../preferences-controller/tsconfig.build.json" }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, { "path": "../accounts-controller/tsconfig.build.json" }, diff --git a/packages/assets-controller/tsconfig.json b/packages/assets-controller/tsconfig.json index da1fdf60ab9..7d3f5e8f7e3 100644 --- a/packages/assets-controller/tsconfig.json +++ b/packages/assets-controller/tsconfig.json @@ -34,6 +34,9 @@ { "path": "../preferences-controller" }, + { + "path": "../remote-feature-flag-controller" + }, { "path": "../accounts-controller" },