Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4165ceb
feat(assets-controller): implement spam asset cleanup functionality
Prithpal-Sooriya Aug 26, 2026
3070572
docs: update changelog
Prithpal-Sooriya Aug 26, 2026
f8cb83d
Merge remote-tracking branch 'origin/main' into fix(assets-controller…
Prithpal-Sooriya Aug 26, 2026
b2f3bd0
test(assets-controller): add spam cleanup tests and fix import order
cursoragent Aug 26, 2026
243d3f0
revert: remove spam cleanup tests added by agent
cursoragent Aug 26, 2026
f662ba2
test: add comprehensive spam cleanup tests
Prithpal-Sooriya Aug 26, 2026
dfeb25f
test: fix failing RpcDataSource test
Prithpal-Sooriya Aug 26, 2026
1ff7346
style(assets-controller): fix formatting for lint:misc:check
cursoragent Aug 26, 2026
d4a9f8f
fix(assets-controller): preserve concurrent state updates during spam…
Prithpal-Sooriya Aug 26, 2026
5a591f0
Merge branch 'fix(assets-controller)/add-asset-cleanup-process-on-unl…
Prithpal-Sooriya Aug 26, 2026
5276b78
refactor: fix lint
Prithpal-Sooriya Aug 26, 2026
cd38c21
refactor: remove slop changes, this cleans up the patching logic.
Prithpal-Sooriya Aug 26, 2026
ce57734
fix(assets-controller): restore concurrent-safe spam cleanup and fix CI
cursoragent Aug 26, 2026
5f65315
fix(assets-controller): avoid TS stack depth error in spam cleanup up…
cursoragent Aug 26, 2026
7725ce1
refactor: cleanup code to better handle concurrent updates.
Prithpal-Sooriya Aug 26, 2026
76bded6
Merge branch 'fix(assets-controller)/add-asset-cleanup-process-on-unl…
Prithpal-Sooriya Aug 26, 2026
312fe57
refactor: better handle when to run cleanup logic.
Prithpal-Sooriya Aug 26, 2026
e7cf026
fix(assets-controller): run spam cleanup when lifecycle becomes active
cursoragent Aug 26, 2026
e916d9e
refactor: rework unlock to be awaited and use keyring unlock check
Prithpal-Sooriya Aug 26, 2026
c2d9d46
Merge branch 'fix(assets-controller)/add-asset-cleanup-process-on-unl…
Prithpal-Sooriya Aug 26, 2026
e68ab14
revert: remove cursor babysit slop
Prithpal-Sooriya Aug 26, 2026
7ed333e
refactor(assets-controller): enhance mock asset controller messenger
Prithpal-Sooriya Aug 26, 2026
9ff0567
test: Add realistic integration test around scam tokens
Prithpal-Sooriya Aug 27, 2026
bd384ed
style(assets-controller): fix formatting for lint:misc:check
cursoragent Aug 27, 2026
c20d07e
feat: add feature flagging for cleanup
Prithpal-Sooriya Aug 27, 2026
db4b816
Merge branch 'fix(assets-controller)/add-asset-cleanup-process-on-unl…
Prithpal-Sooriya Aug 27, 2026
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: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,6 @@
"count": 3
}
},
"packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"packages/assets-controller/src/data-sources/AccountsApiDataSource.ts": {
"no-restricted-syntax": {
"count": 1
Expand Down
4 changes: 4 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Clean up spam assets on keyring unlock, gated behind the `assetsUnifyState` remote feature flag's `useUnlockCleanup` property (disabled unless the flag explicitly enables it) ([#9973](https://github.com/MetaMask/core/pull/9973))

## [14.0.2]

### Changed
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"@types/lodash": "^4.14.191",
"deepmerge": "^4.2.2",
"jest": "^30.4.2",
"nock": "^13.3.1",
"ts-jest": "^29.4.11",
"tsx": "^4.20.5",
"typedoc": "^0.25.13",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import type { ApiPlatformClient } from '@metamask/core-backend';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { FeatureFlags } from '@metamask/remote-feature-flag-controller';

import {
createMockAssetControllerMessenger,
createMockInternalAccount,
registerAssetsControllerActions,
} from './__fixtures__/MockAssetControllerMessenger.js';
import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js';
import {
createTestApiClient,
waitForTokenApiRequests,
} from './__fixtures__/mockTokenApi.js';
import { mockSweepApis } from './__fixtures__/scam-token-cleanup/api-responses/index.js';
import {
SCAM_WALLET_ACCOUNT_ADDRESS,
SCAM_WALLET_ACCOUNT_ID,
SCAM_WALLET_CUSTOM_ASSETS,
SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS,
SCAM_WALLET_SPAM_ASSET_IDS,
SCAM_WALLET_SURVIVING_ASSET_IDS,
buildScamWalletState,
} from './__fixtures__/scam-token-cleanup/scamWalletState.js';
import { waitFor } from './__fixtures__/test-utils.js';
import { AssetsController } from './AssetsController.js';
import type { AssetsControllerState } from './AssetsController.js';

/**
* True-to-life integration coverage for the unlock-time scam-token sweep.
* Unlike `AssetsController.spam-cleanup.test.ts`, which drives a hand-built
* wallet and hand-written API bodies, this boots the controller from the
* example scam-token wallet state (see
* `__fixtures__/scam-token-cleanup/scamWalletState.ts`) and answers the
* Token/Tokens API from captured live responses (see
* `__fixtures__/scam-token-cleanup/api-responses/`). Only the messenger and
* the HTTP boundary are mocked.
*/

/**
* Mirror & subset of `assetsUnifyState`
*/
const UNLOCK_CLEANUP_ENABLED_FLAGS = {
assetsUnifyState: { useUnlockCleanup: true },
};

type WithControllerOptions = {
state?: Partial<AssetsControllerState>;
queryApiClient?: ApiPlatformClient;
isBasicFunctionality?: () => boolean;
captureException?: (error: Error) => void;
remoteFeatureFlags?: FeatureFlags;
};

type WithControllerCallback<ReturnValue> = (args: {
controller: AssetsController;
messenger: MockRootMessenger;
}) => Promise<ReturnValue>;

async function withController<ReturnValue>(
{
state = buildScamWalletState(),
queryApiClient = createTestApiClient(),
isBasicFunctionality = (): boolean => true,
captureException,
remoteFeatureFlags = UNLOCK_CLEANUP_ENABLED_FLAGS,
}: WithControllerOptions,
fn: WithControllerCallback<ReturnValue>,
): Promise<ReturnValue> {
const { rootMessenger, assetsControllerMessenger } =
createMockAssetControllerMessenger({ delegateGetState: false });

// Every account the wallet tracks balances for: the synthetic catch-all
// account plus the real custom-asset owner.
const accounts = [
createMockInternalAccount({
id: SCAM_WALLET_ACCOUNT_ID,
address: SCAM_WALLET_ACCOUNT_ADDRESS,
metadata: { name: 'Spam Wallet' } as InternalAccount['metadata'],
}),
...Object.keys(SCAM_WALLET_CUSTOM_ASSETS).map((accountId) =>
createMockInternalAccount({
id: accountId,
address: '0x5c269fd64c004dd3df2c44ca5d25fbe7ab959e02',
metadata: { name: 'Imported USDC' } as InternalAccount['metadata'],
}),
),
];

registerAssetsControllerActions(rootMessenger, {
accounts,
enabledNetworkMap: { eip155: { '1': true, '10': true, '8453': true } },
nativeAssetIdentifiers: SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS,
remoteFeatureFlags,
});

const controller = new AssetsController({
messenger: assetsControllerMessenger,
state,
queryApiClient,
isBasicFunctionality,
captureException,
});

try {
return await fn({ controller, messenger: rootMessenger });
} finally {
controller.destroy();
}
}

describe('AssetsController scam-token cleanup (example state)', () => {
it('sweeps the airdrop scam tokens out of the example wallet on unlock', async () => {
mockSweepApis();

await withController({}, async ({ controller, messenger }) => {
messenger.publish('KeyringController:unlock');
await waitForTokenApiRequests();

await waitFor(() => {
// Every captured sub-floor airdrop is gone.
for (const spamId of SCAM_WALLET_SPAM_ASSET_IDS) {
expect(controller.state.assetsInfo[spamId]).toBeUndefined();
}
// And the surviving set is exactly the genuine holdings, the custom
// import, the mUSD entries and every native / non-EVM asset.
expect(Object.keys(controller.state.assetsInfo).sort()).toStrictEqual(
[...SCAM_WALLET_SURVIVING_ASSET_IDS].sort(),
);
});
});
});

it('keeps the hand-imported custom asset and clears spam from balances', async () => {
mockSweepApis();
const state = buildScamWalletState();

await withController({ state }, async ({ controller, messenger }) => {
messenger.publish('KeyringController:unlock');
await waitForTokenApiRequests();

await waitFor(() => {
// The custom Arbitrum USDC survives whatever its occurrence count.
expect(controller.state.customAssets).toStrictEqual(
SCAM_WALLET_CUSTOM_ASSETS,
);
// No swept asset is left behind in any account's balances.
for (const balances of Object.values(controller.state.assetsBalance)) {
for (const spamId of SCAM_WALLET_SPAM_ASSET_IDS) {
expect(balances[spamId]).toBeUndefined();
}
}
});
});
});

it('asks the Tokens API about every sweepable ERC-20 and nothing else', async () => {
const { requestedBatches } = mockSweepApis();

await withController({}, async ({ messenger }) => {
messenger.publish('KeyringController:unlock');
await waitForTokenApiRequests();

await waitFor(() => {
const requested = requestedBatches.flat();
// Natives, non-EVM assets and the default-tracked mUSD are never sent.
expect(requested).not.toContain(
'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA',
);
expect(requested.some((id) => !id.startsWith('eip155:'))).toBe(false);
// Spam and genuine sweepable ERC-20s are both queried.
expect(requested.length).toBeGreaterThan(0);
});
});
});

it('does not sweep before the wallet is unlocked', async () => {
mockSweepApis();

await withController({}, async ({ controller }) => {
await new Promise((resolve) => setTimeout(resolve, 250));

// The full 83-asset registry is untouched until unlock.
expect(Object.keys(controller.state.assetsInfo)).toHaveLength(83);
});
});

it('does not sweep when the useUnlockCleanup feature flag is off', async () => {
mockSweepApis();

await withController(
{
remoteFeatureFlags: {
assetsUnifyState: { useUnlockCleanup: false },
},
},
async ({ controller, messenger }) => {
messenger.publish('KeyringController:unlock');
await new Promise((resolve) => setTimeout(resolve, 250));

// The full 83-asset registry is untouched while the flag is off.
expect(Object.keys(controller.state.assetsInfo)).toHaveLength(83);
},
);
});
});
Loading