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 @@ -11,11 +11,16 @@ 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) ([#9534](https://github.com/MetaMask/core/pull/9534))
- `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

- `TokenDataSource` EVM spam filtering now uses per-chain floors from Token API `GET /v1/suggestedOccurrenceFloors` (`queryApiClient.token.fetchV1SuggestedOccurrenceFloors`) instead of a hardcoded minimum of 3 occurrences. Chains missing from the response (or a failed floors fetch) still fall back to 3 ([#9537](https://github.com/MetaMask/core/pull/9537))
- `TokensApiClient` (used by `RpcDataSource` / `TokenDetector`) now sets the token-list `occurrenceFloor` query param from the same Token API `GET /v1/suggestedOccurrenceFloors` endpoint (cached 1h), replacing the hardcoded Linea/MegaETH/Tempo special cases. Missing chains or failed fetches fall back to 3 ([#9537](https://github.com/MetaMask/core/pull/9537))
- Add `@metamask/remote-feature-flag-controller` as a dependency ([#9534](https://github.com/MetaMask/core/pull/9534))
- 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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +26,10 @@ import type {
AssetsControllerStateInternal,
} from '../types';
import { fetchWithTimeout, normalizeAssetId } from '../utils';
import {
getMigrationStages,
shouldSupportChain,
} from '../utils/snaps-assets-migration';
import type {
DataSourceState,
SubscriptionRequest,
Expand All @@ -44,10 +52,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
// ============================================================================
Expand Down Expand Up @@ -230,9 +245,42 @@ 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) => getMigrationStages(state.remoteFeatureFlags).join(','),
);

this.#initializeActiveChains().catch(console.error);
}

/**
* 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<void> {
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
Expand Down Expand Up @@ -262,6 +310,22 @@ export class AccountsApiDataSource extends AbstractDataSource<
}
}

/**
* Read the remote feature flags from the RemoteFeatureFlagController, or
* `undefined` when the controller is unavailable (fail-safe: migration
* networks then resolve to `Off` and are excluded).
*
* @returns The remote feature flags, or `undefined` when unavailable.
*/
#getRemoteFeatureFlags(): FeatureFlags | undefined {
try {
return this.#messenger.call('RemoteFeatureFlagController:getState')
.remoteFeatureFlags;
} catch {
return undefined;
}
}

// ============================================================================
// INITIALIZATION
// ============================================================================
Expand Down Expand Up @@ -323,13 +387,15 @@ export class AccountsApiDataSource extends AbstractDataSource<
async #fetchActiveChains(): Promise<ChainId[]> {
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.
const remoteFeatureFlags = this.#getRemoteFeatureFlags();
return response.fullSupport
.map(decimalToChainId)
.filter((chainId) => shouldSupportChain(chainId, remoteFeatureFlags));
}

// ============================================================================
Expand Down Expand Up @@ -798,12 +864,10 @@ export class AccountsApiDataSource extends AbstractDataSource<
// ============================================================================

destroy(): void {
// Clean up timers
if (this.#chainsRefreshTimer) {
clearInterval(this.#chainsRefreshTimer);
}

// Clean up subscriptions
super.destroy();
}
}
Loading