From 0bb0141f7bb22369ba5564d693f9e5a1146991d2 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Wed, 5 Aug 2026 10:07:05 +0100 Subject: [PATCH 1/4] fix(AssetsController): remove ws metadata population this population is poisoning (invalid metadata) and skips our detection pathways --- packages/assets-controller/CHANGELOG.md | 2 + .../assets-controller/src/AssetsController.ts | 2 - .../AccountActivityDataSource.test.ts | 54 +++++++++---------- .../data-sources/AccountActivityDataSource.ts | 33 ++++-------- 4 files changed, 36 insertions(+), 55 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a7b36ae4a51..0bda1d8734f 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Stop publishing asset metadata (`assetsInfo`) from the websocket/account-activity balance-update path (`AccountActivityDataSource`); only balances are published and metadata is resolved from the Token API (the metadata source of truth) by `TokenDataSource` in the same pipeline pass + - The websocket payload's asset `unit` echoes on-chain contract symbols, which are attacker-controlled for airdropped tokens (e.g. scam-URL token names). Publishing them poisoned `state.assetsInfo`, which marked spam assets as "known" and permanently exempted them from spam filtering on subsequent updates. The payload's `decimals` is still used to convert raw amounts to human-readable balances. - Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) - Bump `@metamask/core-backend` from `^8.1.0` to `^8.1.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) - Bump `@metamask/config-registry-controller` from `^2.0.0` to `^2.0.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7b4dd199139..ed7fa8b9fcb 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -896,8 +896,6 @@ export class AssetsController extends BaseController< this.#accountActivityDataSource = new AccountActivityDataSource({ messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, - getAssetType: (assetId: Caip19AssetId): 'native' | 'erc20' | 'spl' => - this.#getAssetType(assetId), onAssetsUpdate: (response, request): Promise => this.handleAssetsUpdate( response, diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts index 2d522e7f681..4969debf0cb 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts @@ -89,9 +89,8 @@ function createBalanceUpdate(overrides?: { } type SetupOptions = { - groupAccounts?: InternalAccount[]; + groupAccounts?: InternalAccount[] | (() => InternalAccount[]); selectedAccount?: InternalAccount | null; - getAssetType?: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; onAssetsUpdate?: jest.Mock; onActiveChainsUpdated?: jest.Mock; state?: { activeChains?: ChainId[] }; @@ -102,7 +101,6 @@ type SetupResult = { rootMessenger: RootMessenger; onAssetsUpdate: jest.Mock; onActiveChainsUpdated: jest.Mock; - getAssetType: jest.Mock; triggerBalanceUpdated: (payload: { address: string; chain: string; @@ -154,21 +152,17 @@ function setup(options: SetupOptions = {}): SetupResult { rootMessenger.registerActionHandler( 'AccountTreeController:getAccountsFromSelectedAccountGroup', - () => groupAccounts, + () => + typeof groupAccounts === 'function' ? groupAccounts() : groupAccounts, ); rootMessenger.registerActionHandler( 'AccountsController:getSelectedAccount', () => selectedAccount as InternalAccount, ); - const getAssetType = jest - .fn() - .mockImplementation(options.getAssetType ?? ((): 'native' => 'native')); - const dataSource = new AccountActivityDataSource({ messenger: assetsControllerMessenger, onActiveChainsUpdated, - getAssetType, onAssetsUpdate, state, }); @@ -199,7 +193,6 @@ function setup(options: SetupOptions = {}): SetupResult { rootMessenger, onAssetsUpdate, onActiveChainsUpdated, - getAssetType, triggerBalanceUpdated, triggerStatusChanged, cleanup, @@ -291,14 +284,6 @@ describe('AccountActivityDataSource', () => { [ETH_ASSET]: { amount: '1' }, }, }, - assetsInfo: { - [ETH_ASSET]: { - type: 'native', - symbol: 'ETH', - name: 'ETH', - decimals: 18, - }, - }, }); expect(request).toStrictEqual({ accountsWithSupportedChains: [ @@ -311,7 +296,7 @@ describe('AccountActivityDataSource', () => { cleanup(); }); - it('converts a hex postBalance to a human-readable amount', async () => { + it('does not publish asset metadata from the websocket payload', async () => { const account = createMockAccount(); const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ groupAccounts: [account], @@ -321,7 +306,12 @@ describe('AccountActivityDataSource', () => { address: EVM_ADDRESS, chain: CHAIN_MAINNET, updates: [ - createBalanceUpdate({ postBalance: { amount: '0x10aa6d94e80' } }), + createBalanceUpdate({ + // On-chain symbol/name can be attacker-controlled (e.g. scam + // URLs), so it must never reach state; the Token API is the + // metadata source of truth. + asset: { unit: 'www.scam-url.example' }, + }), ], }); @@ -329,27 +319,32 @@ describe('AccountActivityDataSource', () => { expect(onAssetsUpdate).toHaveBeenCalledTimes(1); const [response] = onAssetsUpdate.mock.calls[0]; - expect(response.assetsBalance[account.id][ETH_ASSET]).toStrictEqual({ - amount: '0.00000114526056', - }); + expect(response.assetsInfo).toBeUndefined(); cleanup(); }); - it('resolves the asset type via the injected getAssetType', async () => { - const { getAssetType, triggerBalanceUpdated, cleanup } = setup({ - getAssetType: () => 'erc20', + it('converts a hex postBalance to a human-readable amount', async () => { + const account = createMockAccount(); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], }); triggerBalanceUpdated({ address: EVM_ADDRESS, chain: CHAIN_MAINNET, - updates: [createBalanceUpdate()], + updates: [ + createBalanceUpdate({ postBalance: { amount: '0x10aa6d94e80' } }), + ], }); await Promise.resolve(); - expect(getAssetType).toHaveBeenCalledWith(ETH_ASSET); + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsBalance[account.id][ETH_ASSET]).toStrictEqual({ + amount: '0.00000114526056', + }); cleanup(); }); @@ -546,7 +541,7 @@ describe('AccountActivityDataSource', () => { it('swallows synchronous errors thrown while handling the event', async () => { const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ - getAssetType: () => { + groupAccounts: () => { throw new Error('boom'); }, }); @@ -771,7 +766,6 @@ describe('AccountActivityDataSource', () => { const dataSource = createAccountActivityDataSource({ messenger: assetsControllerMessenger, onActiveChainsUpdated: jest.fn(), - getAssetType: () => 'native', onAssetsUpdate: jest.fn(), }); diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts index 13b701d80f5..b6c9ffa15cd 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts @@ -11,7 +11,6 @@ import type { AssetsControllerMessenger } from '../AssetsController.js'; import { projectLogger, createModuleLogger } from '../logger.js'; import type { AssetBalance, - AssetMetadata, ChainId, Caip19AssetId, DataRequest, @@ -36,15 +35,23 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); * Convert AccountActivityMessage balance updates into a {@link DataResponse} * for AssetsController. * + * Only balances are published. The websocket payload's asset `unit` echoes the + * on-chain contract symbol, which is attacker-controlled for airdropped tokens + * (e.g. scam-URL token names). Publishing it as metadata poisoned + * `state.assetsInfo`, marking spam assets as "known" and exempting them from + * TokenDataSource's spam filtering on all subsequent updates. Metadata is + * intentionally left for TokenDataSource to resolve from the Token API — the + * metadata source of truth — during the same pipeline pass. The payload's + * `decimals` is still used locally to convert raw amounts to human-readable + * balances. + * * @param updates - Balance updates from account-activity websocket payload. * @param accountId - Internal account UUID. - * @param getAssetType - Resolver for asset metadata type. * @returns DataResponse with merge mode when balances are present. */ function processAccountActivityBalanceUpdates( updates: BalanceUpdate[], accountId: string, - getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl', ): DataResponse { const assetsBalance = Object.create(null) as Record< string, @@ -54,10 +61,6 @@ function processAccountActivityBalanceUpdates( Caip19AssetId, AssetBalance >; - const assetsMetadata = Object.create(null) as Record< - Caip19AssetId, - AssetMetadata - >; for (const update of updates) { const { asset, postBalance } = update; @@ -83,19 +86,11 @@ function processAccountActivityBalanceUpdates( assetsBalance[accountId][assetId] = { amount: humanReadableAmount, }; - - assetsMetadata[assetId] = { - type: getAssetType(assetId), - symbol: asset.unit, - name: asset.unit, - decimals: asset.decimals, - }; } const response: DataResponse = { updateMode: 'merge' }; if (Object.keys(assetsBalance[accountId]).length > 0) { response.assetsBalance = assetsBalance; - response.assetsInfo = assetsMetadata; } return response; @@ -133,8 +128,6 @@ export type AccountActivityDataSourceOptions = { chains: ChainId[], previousChains: ChainId[], ) => void; - /** Returns the asset type ('native' | 'erc20' | 'spl') for a given CAIP-19 asset ID. */ - getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; /** * Pushes decoded balance updates to the controller (bound to * `AssetsController.handleAssetsUpdate`). AADS is event-driven and never @@ -196,10 +189,6 @@ export class AccountActivityDataSource extends AbstractDataSource< previousChains: ChainId[], ) => void; - readonly #getAssetType: ( - assetId: Caip19AssetId, - ) => 'native' | 'erc20' | 'spl'; - readonly #onAssetsUpdate: ( response: DataResponse, request?: DataRequest, @@ -219,7 +208,6 @@ export class AccountActivityDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; - this.#getAssetType = options.getAssetType; this.#onAssetsUpdate = options.onAssetsUpdate; this.#onBalanceUpdatedBound = this.#onBalanceUpdated.bind(this); @@ -285,7 +273,6 @@ export class AccountActivityDataSource extends AbstractDataSource< const response = processAccountActivityBalanceUpdates( updates, account.id, - (assetId) => this.#getAssetType(assetId), ); if (!response.assetsBalance) { From fa03cc80b914966efa239a5c34005eff632c36fa Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Wed, 5 Aug 2026 10:31:57 +0100 Subject: [PATCH 2/4] fix(AssetsController): prevent websocket metadata poisoning by stopping `assetsInfo` publication during balance updates; metadata is now sourced from the Token API. Bump dependencies for `transaction-controller`, `core-backend`, and `config-registry-controller`. --- packages/assets-controller/CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index d46dc809acd..e9656439d40 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,8 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Stop publishing asset metadata (`assetsInfo`) from the websocket/account-activity balance-update path (`AccountActivityDataSource`); only balances are published and metadata is resolved from the Token API (the metadata source of truth) by `TokenDataSource` in the same pipeline pass - - The websocket payload's asset `unit` echoes on-chain contract symbols, which are attacker-controlled for airdropped tokens (e.g. scam-URL token names). Publishing them poisoned `state.assetsInfo`, which marked spam assets as "known" and permanently exempted them from spam filtering on subsequent updates. The payload's `decimals` is still used to convert raw amounts to human-readable balances. - Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) - Bump `@metamask/core-backend` from `^8.1.0` to `^8.1.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) - Bump `@metamask/config-registry-controller` from `^2.0.0` to `^2.0.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) @@ -22,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/keyring-snap-client` from `^9.2.1` to `^10.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +### Fixed + +- Stop publishing `assetsInfo` from websocket balance updates; metadata is now resolved from the Token API by `TokenDataSource` to prevent WS poisoning (incorrect WS symbols and no detection metadata). Prevents bypassing token detection spam filtering ([#9790](https://github.com/MetaMask/core/pull/9790)) + ## [13.1.0] ### Added From 0cb383dc387becce079cd7e0d9a694d3e33a524f Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Wed, 5 Aug 2026 14:16:58 +0100 Subject: [PATCH 3/4] Update CHANGELOG.md to reflect the fix for websocket metadata poisoning by stopping `assetsInfo` publication during balance updates. Metadata is now sourced from the Token API to enhance token detection and prevent spam filtering. Bump dependencies for `@metamask/keyring-controller` and `@metamask/network-enablement-controller`. --- packages/assets-controller/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index c2130f0a39f..08c2f98b5f1 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) - Bump `@metamask/network-enablement-controller` from `^6.0.2` to `^6.0.3` ([#9791](https://github.com/MetaMask/core/pull/9791)) +### Fixed + +- Stop publishing `assetsInfo` from websocket balance updates; metadata is now resolved from the Token API by `TokenDataSource` to prevent WS poisoning (incorrect WS symbols and no detection metadata). Prevents bypassing token detection spam filtering ([#9790](https://github.com/MetaMask/core/pull/9790)) + ## [13.1.1] ### Changed @@ -29,10 +33,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/keyring-snap-client` from `^9.2.1` to `^10.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) -### Fixed - -- Stop publishing `assetsInfo` from websocket balance updates; metadata is now resolved from the Token API by `TokenDataSource` to prevent WS poisoning (incorrect WS symbols and no detection metadata). Prevents bypassing token detection spam filtering ([#9790](https://github.com/MetaMask/core/pull/9790)) - ## [13.1.0] ### Added From f931f96a034f95aa7cafa4bf103bb4d6101f6cb7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 17:08:50 +0000 Subject: [PATCH 4/4] fix(assets-controller): merge duplicate Fixed sections in changelog Co-authored-by: Prithpal Sooriya --- packages/assets-controller/CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index fc1ba9a3f4b..457532b8fc9 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -19,9 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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)) - -### Fixed - - Stop publishing `assetsInfo` from websocket balance updates; metadata is now resolved from the Token API by `TokenDataSource` to prevent WS poisoning (incorrect WS symbols and no detection metadata). Prevents bypassing token detection spam filtering ([#9790](https://github.com/MetaMask/core/pull/9790)) ## [13.1.1]