Skip to content
Merged
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 @@ -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/9534))
- `AssetsController` and `AccountsApiDataSource` 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

- `AccountsApiDataSource` `forceUpdate` balance fetches now use `staleTime`/`gcTime` of `100`ms (previously `0`/`0`) so bursts of near-simultaneous forced refreshes are de-duplicated by TanStack Query into a single Accounts API request instead of one request per trigger ([#9591](https://github.com/MetaMask/core/pull/9591))
Expand Down
20 changes: 9 additions & 11 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ async function withController<ReturnValue>(
state = {},
isBasicFunctionality = (): boolean => true,
clientControllerState,
remoteFeatureFlags,
remoteFeatureFlags = {},
queryApiClient = createMockQueryApiClient(),
controllerOptions = {},
},
Expand Down Expand Up @@ -238,16 +238,14 @@ async function withController<ReturnValue>(
);
}

if (remoteFeatureFlags !== undefined) {
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('RemoteFeatureFlagController:getState', () => ({
remoteFeatureFlags,
cacheTimestamp: 0,
}));
}
(
messenger as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('RemoteFeatureFlagController:getState', () => ({
remoteFeatureFlags,
cacheTimestamp: 0,
}));

const controller = new AssetsController({
messenger: messenger as unknown as AssetsControllerMessenger,
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 @@ -356,7 +359,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 @@ -152,7 +157,7 @@ async function setupController(
unprocessedNetworks = [],
fetchTimeoutMs,
v6Accounts = [],
remoteFeatureFlags,
remoteFeatureFlags = {},
} = options;

const rootMessenger = new Messenger<MockAnyNamespace, AllActions, AllEvents>({
Expand All @@ -169,24 +174,20 @@ async function setupController(
parent: rootMessenger,
});

if (remoteFeatureFlags !== undefined) {
(
rootMessenger as unknown as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('RemoteFeatureFlagController:getState', () => ({
remoteFeatureFlags,
cacheTimestamp: 0,
}));
}
(
rootMessenger as unknown as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('RemoteFeatureFlagController:getState', () => ({
remoteFeatureFlags,
cacheTimestamp: 0,
}));

rootMessenger.delegate({
messenger: controllerMessenger,
actions:
remoteFeatureFlags === undefined
? []
: ['RemoteFeatureFlagController:getState'],
events: [],
actions: ['RemoteFeatureFlagController:getState'],
// eslint-disable-next-line no-restricted-syntax
events: ['RemoteFeatureFlagController:stateChange'],
});

const assetsUpdateHandler = jest.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -302,6 +303,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 +376,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 +417,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 Expand Up @@ -823,9 +971,18 @@ describe('AccountsApiDataSource', () => {
parent: rootMessenger,
});

(
rootMessenger as unknown as {
registerActionHandler: (a: string, h: () => unknown) => void;
}
).registerActionHandler('RemoteFeatureFlagController:getState', () => ({
remoteFeatureFlags: {},
cacheTimestamp: 0,
}));

rootMessenger.delegate({
messenger: controllerMessenger,
actions: [],
actions: ['RemoteFeatureFlagController:getState'],
events: [],
});

Expand Down
Loading