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

### Fixed

- Apply occurrence-floor spam filtering to tokens received through account-activity (websocket) updates: new tokens are enriched with Token API occurrence counts before detection and dropped when below the per-chain suggested floor, and stub metadata of filtered-out assets is stripped from the pipeline response so spam tokens never persist to state ([#9768](https://github.com/MetaMask/core/pull/9768))
- Preserve pooled-staking balances across Accounts API chain-slice updates (e.g. network switch / `replaceCoveredChainBalances`): exclude staking contract asset IDs from `AccountsApiDataSource` v5/v6 balance processing, and keep prior staked amounts when a merge replace omits them so Accounts API cannot reset staked ETH to missing/0 ([#9753](https://github.com/MetaMask/core/pull/9753))

## [13.1.1]
Expand Down
16 changes: 16 additions & 0 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3874,10 +3874,26 @@ export class AssetsController extends BaseController<
sourceId === 'AccountsApiDataSource' ||
sourceId === 'AccountActivityDataSource';

// Websocket updates can carry brand-new spam airdrops: enrich them
// with Token API occurrences and drop below-floor tokens BEFORE
// detection, so spam is never detected, enriched, priced or persisted.
const shouldFilterOccurrences =
sourceId === 'AccountActivityDataSource' &&
this.#isBasicFunctionality();

const enrichmentSources: AssetsDataSource[] = [
...(shouldGraduateCustomAssets
? [this.#customAssetGraduationMiddleware]
: []),
...(shouldFilterOccurrences
? [
{
getName: () => 'OccurrenceFloorFilter',
assetsMiddleware:
this.#tokenDataSource.occurrenceFilterMiddleware,
},
]
: []),
this.#detectionMiddleware,
];
if (this.#isBasicFunctionality()) {
Expand Down
106 changes: 106 additions & 0 deletions packages/assets-controller/src/data-sources/TokenDataSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,112 @@ describe('TokenDataSource', () => {
);
});

it('middleware strips stub metadata of filtered-out assets so it does not persist', async () => {
const spamAsset =
'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId;

const { controller } = setupController({
messenger: createTestMessenger(),
supportedNetworks: ['eip155:1'],
assetsResponse: [createMockAssetResponse(spamAsset, { occurrences: 1 })],
suggestedOccurrenceFloors: { '1': 3 },
});

const next = jest.fn().mockResolvedValue(undefined);
// Websocket-shaped update: detected asset with a seeded metadata stub
// (name/symbol, no image). If the stub survived filtering it would
// persist to state and mask re-detection of the spam token.
const context = createMiddlewareContext({
response: {
detectedAssets: {
'mock-account-id': [spamAsset],
},
assetsBalance: {
'mock-account-id': {
[spamAsset]: { amount: '50' },
},
},
assetsInfo: {
[spamAsset]: {
type: 'erc20',
name: 'Spam Token',
symbol: 'SPAM',
decimals: 18,
},
},
},
});

await controller.assetsMiddleware(context, next);

expect(
(
context.response.assetsBalance?.['mock-account-id'] as Record<
string,
unknown
>
)?.[spamAsset],
).toBeUndefined();
expect(context.response.assetsInfo?.[spamAsset]).toBeUndefined();
});

it('occurrenceFilterMiddleware drops new low-occurrence tokens before detection', async () => {
const spamAsset =
'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId;

const { controller, apiClient } = setupController({
messenger: createTestMessenger(),
supportedNetworks: ['eip155:1'],
assetsResponse: [
createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 5 }),
createMockAssetResponse(spamAsset, { occurrences: 1 }),
],
suggestedOccurrenceFloors: { '1': 3 },
});

const next = jest.fn().mockResolvedValue(undefined);
// Websocket-shaped update: brand-new tokens (absent from state) with
// seeded metadata stubs. Runs BEFORE DetectionMiddleware.
const context = createMiddlewareContext({
request: createDataRequest({ dataTypes: ['balance'] }),
response: {
assetsBalance: {
'mock-account-id': {
[MOCK_TOKEN_ASSET]: { amount: '100' },
[spamAsset]: { amount: '50' },
},
},
assetsInfo: {
[spamAsset]: {
type: 'erc20',
name: 'Spam Token',
symbol: 'SPAM',
decimals: 18,
},
},
},
getAssetsState: jest.fn().mockReturnValue({
assetsBalance: {},
assetsInfo: {},
}),
});

await controller.occurrenceFilterMiddleware(context, next);

expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith(
expect.arrayContaining([MOCK_TOKEN_ASSET, spamAsset]),
{ includeOccurrences: true },
);

const accountBalances = context.response.assetsBalance?.[
'mock-account-id'
] as Record<string, unknown>;
expect(accountBalances[MOCK_TOKEN_ASSET]).toBeDefined();
expect(accountBalances[spamAsset]).toBeUndefined();
expect(context.response.assetsInfo?.[spamAsset]).toBeUndefined();
expect(next).toHaveBeenCalled();
});

it('middleware uses per-chain suggested occurrence floors from Token API', async () => {
// Monad (143) suggests floor 1 — a token with occurrences=1 should pass.
const monadToken =
Expand Down
148 changes: 148 additions & 0 deletions packages/assets-controller/src/data-sources/TokenDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,140 @@ export class TokenDataSource {
return assets.filter((asset) => !rejectedAssets.has(asset));
}

/**
* Middleware that runs BEFORE DetectionMiddleware for account-activity
* (websocket) updates. New-to-state EVM ERC-20 balances are enriched with
* their Token API occurrence counts first, and tokens below the per-chain
* suggested occurrence floor are dropped from the response (balances and
* stub metadata) so spam airdrops never reach detection, metadata
* enrichment, pricing, or state. Custom assets and mUSD are exempt, and
* assets unknown to the Token API are kept (fail open), mirroring
* {@link TokenDataSource.assetsMiddleware} filtering semantics.
*
* @returns The middleware function for the assets pipeline.
*/
get occurrenceFilterMiddleware(): Middleware {
return forDataTypes(['balance'], async (ctx, next) => {
const { response } = ctx;
const {
assetsBalance: stateBalances,
assetsInfo: stateMetadata,
customAssets,
} = ctx.getAssetsState();

const customAssetIds = new Set(
Object.values(customAssets ?? {})
.flat()
.map((id) => id.toLowerCase()),
);

// Candidates: EVM ERC-20s that are genuinely new (absent from state
// balances and metadata) — the same assets DetectionMiddleware would
// mark as newly detected right after this middleware.
const candidateByLowerId = new Map<string, string>();
for (const [accountId, accountBalances] of Object.entries(
response.assetsBalance ?? {},
)) {
const stateAccountBalances = stateBalances[accountId] ?? {};
for (const assetId of Object.keys(accountBalances)) {
const caipAssetId = assetId as Caip19AssetId;
const lowerId = assetId.toLowerCase();
if (
stateAccountBalances[caipAssetId] !== undefined ||
stateMetadata[caipAssetId] !== undefined ||
customAssetIds.has(lowerId) ||
lowerId.includes(`/erc20:${MUSD_ADDRESS_LOWERCASE}`)
) {
continue;
}
try {
const { assetNamespace, chain } = parseCaipAssetType(caipAssetId);
if (
assetNamespace === CaipAssetNamespace.Erc20 &&
chain.namespace === KnownCaipNamespace.Eip155
) {
candidateByLowerId.set(lowerId, assetId);
}
} catch {
// Unparseable IDs are left for downstream middleware to handle.
}
}
}

if (candidateByLowerId.size === 0) {
return next(ctx);
}

try {
const [occurrenceResponse, suggestedOccurrenceFloors] =
await Promise.all([
reduceInBatchesSerially<string, V3AssetResponse[]>({
values: [...candidateByLowerId.values()],
batchSize: TOKENS_API_BATCH_SIZE,
eachBatch: async (workingResult, batch) => {
const batchResponse = await fetchWithTimeout(
() =>
this.#apiClient.tokens.fetchV3Assets(batch, {
includeOccurrences: true,
}),
this.#fetchTimeoutMs,
);
return [
...(workingResult as V3AssetResponse[]),
...batchResponse,
];
},
initialResult: [],
}),
this.#getSuggestedOccurrenceFloors(),
]);

// Only assets the API knows can be judged; missing ones are kept.
const spamAssetIds = new Set<string>();
for (const assetData of occurrenceResponse) {
const candidateId = candidateByLowerId.get(
assetData.assetId.toLowerCase(),
);
if (
candidateId !== undefined &&
(assetData.occurrences ?? 0) <
getOccurrenceFloorForAsset(candidateId, suggestedOccurrenceFloors)
) {
spamAssetIds.add(candidateId);
}
}

if (spamAssetIds.size > 0) {
for (const accountBalances of Object.values(
response.assetsBalance ?? {},
)) {
for (const assetId of spamAssetIds) {
delete (accountBalances as Record<string, unknown>)[assetId];
}
}
if (response.assetsInfo) {
const spamLowerIds = new Set(
[...spamAssetIds].map((id) => id.toLowerCase()),
);
for (const assetId of Object.keys(response.assetsInfo)) {
if (spamLowerIds.has(assetId.toLowerCase())) {
delete response.assetsInfo[assetId as Caip19AssetId];
}
}
}
log('Filtered low-occurrence websocket assets', {
assetIds: [...spamAssetIds],
});
}
} catch (error) {
// Fail open — keep all assets when occurrences cannot be fetched.
log('Failed to fetch occurrences for websocket update', { error });
}

return next(ctx);
});
}

/**
* Get the middleware for enriching responses with token metadata.
*
Expand Down Expand Up @@ -607,6 +741,20 @@ export class TokenDataSource {
);
}
}

// Drop stub metadata (e.g. websocket-seeded name/symbol) for
// filtered-out assets so it never persists to state — a persisted
// stub would make the asset look "known" on the next update and
// let its balance skip spam filtering as a heal. Case-insensitive
// because the API may return asset IDs in a different case.
const filteredOutLower = new Set(
[...filteredOutAssets].map((id) => id.toLowerCase()),
);
for (const assetId of Object.keys(response.assetsInfo)) {
if (filteredOutLower.has(assetId.toLowerCase())) {
delete response.assetsInfo[assetId as Caip19AssetId];
}
}
}
} catch (error) {
log('Failed to fetch metadata', { error });
Expand Down
Loading