Skip to content
Open
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/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))
Expand Down
2 changes: 1 addition & 1 deletion packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +31,8 @@ type RootMessenger = Messenger<MockAnyNamespace, AllActions, AllEvents>;
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 = {
Expand All @@ -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;
};
Expand Down Expand Up @@ -106,11 +113,15 @@ function setupController(
options: {
initialActiveChains?: ChainId[];
connectionState?: WebSocketState;
supportedNetworks?: (number | string)[];
remoteFeatureFlags?: Record<string, unknown>;
} = {},
): SetupResult {
const {
initialActiveChains = [],
connectionState = WebSocketState.CONNECTED,
supportedNetworks,
remoteFeatureFlags = {},
} = options;

const rootMessenger = new Messenger<MockAnyNamespace, AllActions, AllEvents>({
Expand All @@ -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: [
Expand All @@ -135,6 +155,7 @@ function setupController(
'BackendWebSocketService:findSubscriptionsByChannelPrefix',
'BackendWebSocketService:addChannelCallback',
'BackendWebSocketService:removeChannelCallback',
'RemoteFeatureFlagController:getState',
],
events: ['BackendWebSocketService:connectionStateChanged'],
});
Expand Down Expand Up @@ -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,
},
};

Expand Down Expand Up @@ -241,6 +266,7 @@ function setupController(
removeChannelCallbackMock,
assetsUpdateHandler,
activeChainsUpdateHandler,
fetchV2SupportedNetworksMock,
triggerConnectionStateChange,
triggerActiveChainsUpdate,
};
Expand Down Expand Up @@ -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<typeof setupController>[0] = {},
): Promise<SetupResult> {
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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 =
Expand Down Expand Up @@ -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

// ============================================================================
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -371,7 +352,17 @@ export class BackendWebsocketDataSource extends AbstractDataSource<

async #fetchActiveChains(): Promise<ChainId[]> {
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',
);
Comment thread
stanleyyconsensys marked this conversation as resolved.
return response.fullSupport
.map(decimalToChainId)
Comment on lines +360 to +364

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is the same for accounts API

.filter((chainId) => shouldSupportChain(chainId, remoteFeatureFlags));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSocket ignores live flag changes

Medium Severity

BackendWebsocketDataSource now filters active chains with shouldSupportChain and RemoteFeatureFlagController:getState, but unlike AccountsApiDataSource it never subscribes to RemoteFeatureFlagController:stateChange. After a migration flag turns off, the websocket source can keep claiming gated chains until the 20-minute refresh or reconnect, so account-activity ingestion may continue when it should not.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5baa62b. Configure here.

}

#subscribeToEvents(): void {
Expand Down
Loading
Loading