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
3 changes: 3 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **BREAKING:** First-init-fetch measurement moved from MetaMetrics to Sentry. Option `trackMetaMetricsEvent` is replaced by `trace: TraceCallback`. Type `AssetsControllerFirstInitFetchMetaMetricsPayload` is removed; trace request data is not typed in this package. ([#8147](https://github.com/MetaMask/core/pull/8147))
- `TokenDataSource` now always includes native token asset IDs (from `NetworkEnablementController.nativeAssetIdentifiers`) in metadata fetch calls, ensuring native tokens always have up-to-date metadata ([#8227](https://github.com/MetaMask/core/pull/8227))
- `TokenDataSource` now filters out non-native tokens with fewer than 3 occurrences from metadata responses, and also removes their balances and detected asset entries, to prevent spam tokens from being stored in state ([#8227](https://github.com/MetaMask/core/pull/8227))
- `TokenDataSource` now requests `includeOccurrences` when fetching v3 asset metadata ([#8227](https://github.com/MetaMask/core/pull/8227))

## [2.4.0]

Expand Down
6 changes: 6 additions & 0 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,12 @@ export class AssetsController extends BaseController<
});
this.#tokenDataSource = new TokenDataSource({
queryApiClient,
getNativeAssetIds: (): string[] => {
const { nativeAssetIdentifiers } = this.messenger.call(
'NetworkEnablementController:getState',
);
return Object.values(nativeAssetIdentifiers);
},
});
this.#priceDataSource = new PriceDataSource({
queryApiClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,14 @@ function setupController(
options: {
supportedNetworks?: string[];
assetsResponse?: V3AssetResponse[];
nativeAssetIds?: string[];
} = {},
): SetupResult {
const { supportedNetworks = ['eip155:1'], assetsResponse = [] } = options;
const {
supportedNetworks = ['eip155:1'],
assetsResponse = [],
nativeAssetIds = [],
} = options;

const rootMessenger = new Messenger<MockAnyNamespace, AllActions, AllEvents>({
namespace: MOCK_ANY_NAMESPACE,
Expand All @@ -120,6 +125,7 @@ function setupController(
const controller = new TokenDataSource({
queryApiClient:
apiClient as unknown as TokenDataSourceOptions['queryApiClient'],
getNativeAssetIds: (): string[] => nativeAssetIds,
});

return {
Expand Down Expand Up @@ -199,6 +205,7 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toStrictEqual({
Expand Down Expand Up @@ -315,6 +322,7 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
});
Expand Down Expand Up @@ -348,6 +356,7 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
});
Expand Down Expand Up @@ -542,6 +551,7 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toBeDefined();
Expand Down Expand Up @@ -575,6 +585,7 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
});
Expand Down Expand Up @@ -610,6 +621,7 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
});
Expand Down Expand Up @@ -643,7 +655,265 @@ describe('TokenDataSource', () => {
includeMetadata: true,
includeRwaData: true,
includeAggregators: true,
includeOccurrences: true,
},
);
});

it('middleware filters out non-native assets with occurrences < 3', async () => {
const lowOccurrenceAsset =
'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId;

const { controller } = setupController({
supportedNetworks: ['eip155:1'],
assetsResponse: [
createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 5 }),
createMockAssetResponse(lowOccurrenceAsset, { occurrences: 2 }),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [MOCK_TOKEN_ASSET, lowOccurrenceAsset],
},
assetsBalance: {
'mock-account-id': {
[MOCK_TOKEN_ASSET]: { amount: '100' },
[lowOccurrenceAsset]: { amount: '50' },
},
},
},
});

await controller.assetsMiddleware(context, next);

expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toBeDefined();
expect(context.response.assetsInfo?.[lowOccurrenceAsset]).toBeUndefined();

const accountBalances = context.response.assetsBalance?.[
'mock-account-id'
] as Record<string, unknown> | undefined;
expect(accountBalances?.[MOCK_TOKEN_ASSET]).toBeDefined();
expect(accountBalances?.[lowOccurrenceAsset]).toBeUndefined();

expect(context.response.detectedAssets?.['mock-account-id']).toContain(
MOCK_TOKEN_ASSET,
);
expect(context.response.detectedAssets?.['mock-account-id']).not.toContain(
lowOccurrenceAsset,
);
});

it('middleware filters out non-native assets with undefined occurrences', async () => {
const noOccurrenceAsset =
'eip155:1/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId;

const { controller } = setupController({
supportedNetworks: ['eip155:1'],
assetsResponse: [
createMockAssetResponse(noOccurrenceAsset, { occurrences: undefined }),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [noOccurrenceAsset],
},
},
});

await controller.assetsMiddleware(context, next);

expect(context.response.assetsInfo?.[noOccurrenceAsset]).toBeUndefined();
});

it('middleware keeps native assets regardless of occurrences', async () => {
const { controller } = setupController({
supportedNetworks: ['eip155:1'],
nativeAssetIds: [MOCK_NATIVE_ASSET],
assetsResponse: [
createMockAssetResponse(MOCK_NATIVE_ASSET, {
name: 'Ethereum',
symbol: 'ETH',
occurrences: 0,
}),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {},
});

await controller.assetsMiddleware(context, next);

expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined();
expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]?.type).toBe(
'native',
);
});

it('middleware always includes native asset IDs in the fetch', async () => {
const { controller, apiClient } = setupController({
supportedNetworks: ['eip155:1'],
nativeAssetIds: [MOCK_NATIVE_ASSET],
assetsResponse: [
createMockAssetResponse(MOCK_TOKEN_ASSET),
createMockAssetResponse(MOCK_NATIVE_ASSET, {
name: 'Ethereum',
symbol: 'ETH',
}),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [MOCK_TOKEN_ASSET],
},
},
});

await controller.assetsMiddleware(context, next);

expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith(
expect.arrayContaining([MOCK_TOKEN_ASSET, MOCK_NATIVE_ASSET]),
expect.objectContaining({ includeIconUrl: true }),
);
expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined();
expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]?.type).toBe(
'native',
);
});

it('middleware fetches native asset IDs even when detectedAssets is undefined', async () => {
const { controller, apiClient } = setupController({
supportedNetworks: ['eip155:1'],
nativeAssetIds: [MOCK_NATIVE_ASSET],
assetsResponse: [
createMockAssetResponse(MOCK_NATIVE_ASSET, {
name: 'Ethereum',
symbol: 'ETH',
}),
],
});

const next = jest.fn().mockResolvedValue(undefined);
// detectedAssets is intentionally omitted (undefined) to mirror the real-world
// case where DetectionMiddleware finds zero balances and zero custom assets
const context = createMiddlewareContext({
response: {},
});

await controller.assetsMiddleware(context, next);

expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith(
[MOCK_NATIVE_ASSET],
expect.objectContaining({ includeIconUrl: true }),
);
expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined();
});

it('middleware fetches native asset IDs when detectedAssets is an empty object', async () => {
const { controller, apiClient } = setupController({
supportedNetworks: ['eip155:1'],
nativeAssetIds: [MOCK_NATIVE_ASSET],
assetsResponse: [
createMockAssetResponse(MOCK_NATIVE_ASSET, {
name: 'Ethereum',
symbol: 'ETH',
}),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [],
},
},
});

await controller.assetsMiddleware(context, next);

expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith(
[MOCK_NATIVE_ASSET],
expect.objectContaining({ includeIconUrl: true }),
);
expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined();
});

it('middleware deduplicates native asset IDs with detected assets', async () => {
const { controller, apiClient } = setupController({
supportedNetworks: ['eip155:1'],
nativeAssetIds: [MOCK_NATIVE_ASSET],
assetsResponse: [
createMockAssetResponse(MOCK_NATIVE_ASSET, {
name: 'Ethereum',
symbol: 'ETH',
}),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [MOCK_NATIVE_ASSET],
},
},
});

await controller.assetsMiddleware(context, next);

expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith(
[MOCK_NATIVE_ASSET],
expect.objectContaining({ includeIconUrl: true }),
);
});

it('middleware includes multiple native asset IDs across chains', async () => {
const polygonNativeAsset = 'eip155:137/slip44:966' as Caip19AssetId;

const { controller, apiClient } = setupController({
supportedNetworks: ['eip155:1', 'eip155:137'],
nativeAssetIds: [MOCK_NATIVE_ASSET, polygonNativeAsset],
assetsResponse: [
createMockAssetResponse(MOCK_NATIVE_ASSET, {
name: 'Ethereum',
symbol: 'ETH',
}),
createMockAssetResponse(polygonNativeAsset, {
name: 'Polygon',
symbol: 'POL',
}),
],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [MOCK_TOKEN_ASSET],
},
},
});

await controller.assetsMiddleware(context, next);

expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith(
expect.arrayContaining([
MOCK_TOKEN_ASSET,
MOCK_NATIVE_ASSET,
polygonNativeAsset,
]),
expect.objectContaining({ includeIconUrl: true }),
);
});
});
Loading
Loading