Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
10 changes: 8 additions & 2 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ 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,
filterResponseToKnownAssets,
} from './AccountsApiDataSource';

type AllActions = AccountsApiDataSourceAllowedActions;
type AllEvents = never;
type AllEvents = AccountsApiDataSourceAllowedEvents;
type RootMessenger = Messenger<MockAnyNamespace, AllActions, AllEvents>;

const CHAIN_MAINNET = 'eip155:1' as ChainId;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand All @@ -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(
Expand All @@ -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' },
Expand Down
Loading
Loading