diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cfcc1f3bae9..8acfa50fa2f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -279,19 +279,6 @@ "count": 2 } }, - "packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, - "packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 6 - }, - "@typescript-eslint/no-misused-promises": { - "count": 3 - } - }, "packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts": { "no-restricted-syntax": { "count": 3 diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 48b126b36fb..6112a24d979 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,12 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `MultichainAssetsController`: periodic Blockaid re-scan of stored SPL-style `token:` assets (default once per day) so tokens that become malicious after a prior scan are dropped; use constructor option `blockaidTokenRescanInterval` (ms), or `0` to disable. ([#8400](https://github.com/MetaMask/core/pull/8400)) + ### Changed - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) - Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +### Fixed + +- `MultichainAssetsController`: fungible `token:` assets from automatic detection are no longer added when Blockaid bulk scan fails, returns empty, or omits that address (previously fail open); an explicit non-malicious per-token result from `PhishingController:bulkScanTokens` is now required before add. ([#8400](https://github.com/MetaMask/core/pull/8400)) + ## [103.1.1] ### Changed diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts index 088a50f1aae..311dbe48292 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -242,18 +242,37 @@ function getRootMessenger(): RootMessenger { return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } +type SetupControllerResult = { + controller: MultichainAssetsController; + messenger: RootMessenger; + mockSnapHandleRequest: jest.Mock; + mockListMultichainAccounts: jest.Mock; + mockGetAllSnaps: jest.Mock; + mockGetPermissions: jest.Mock; + mockBulkScanTokens: jest.Mock; +}; + +/** Request shape for `PhishingController:bulkScanTokens` in tests. */ +type BulkTokenScanTestRequest = { + chainId: string; + tokens: string[]; +}; + const setupController = ({ state = getDefaultMultichainAssetsControllerState(), mocks, + /** `0` disables periodic Blockaid re-scan (default for tests). */ + blockaidTokenRescanInterval = 0, }: { state?: MultichainAssetsControllerState; + blockaidTokenRescanInterval?: number; mocks?: { listMultichainAccounts?: InternalAccount[]; handleRequestReturnValue?: CaipAssetTypeOrId[]; getAllReturnValue?: Snap[]; getPermissionsReturnValue?: SubjectPermissions; }; -} = {}) => { +} = {}): SetupControllerResult => { const messenger = getRootMessenger(); const multichainAssetsControllerMessenger: MultichainAssetsControllerMessenger = @@ -310,15 +329,30 @@ const setupController = ({ ), ); - const mockBulkScanTokens = jest.fn(); + const mockBulkScanTokens = jest + .fn() + .mockImplementation( + (request: BulkTokenScanTestRequest): Promise => { + const results: BulkTokenScanResponse = {}; + for (const addr of request.tokens) { + results[addr] = { + result_type: TokenScanResultType.Benign, + chain: request.chainId, + address: addr, + }; + } + return Promise.resolve(results); + }, + ); messenger.registerActionHandler( 'PhishingController:bulkScanTokens', - mockBulkScanTokens.mockResolvedValue({}), + mockBulkScanTokens, ); const controller = new MultichainAssetsController({ messenger: multichainAssetsControllerMessenger, state, + blockaidTokenRescanInterval, }); return { @@ -1265,8 +1299,8 @@ describe('MultichainAssetsController', () => { }, }); - // Wait for async processing - await jestAdvanceTime({ duration: 0 }); + // Wait for async processing (including Blockaid scan) + await jestAdvanceTime({ duration: 1 }); // Only the non-ignored asset should be added expect( @@ -1302,8 +1336,7 @@ describe('MultichainAssetsController', () => { }, }); - // Wait for async processing - await jestAdvanceTime({ duration: 0 }); + await jestAdvanceTime({ duration: 1 }); // Ignored asset should remain filtered out and stay in ignored list expect( @@ -1333,8 +1366,7 @@ describe('MultichainAssetsController', () => { // Simulate account being added messenger.publish('AccountsController:accountAdded', mockSolanaAccount); - // Wait for async processing - await jestAdvanceTime({ duration: 0 }); + await jestAdvanceTime({ duration: 1 }); // All assets should be added to active list (no ignored assets for new account) expect( @@ -1482,7 +1514,7 @@ describe('MultichainAssetsController', () => { ]); }); - it('keeps all tokens when bulkScanTokens throws (fail open)', async () => { + it('does not add tokens when bulkScanTokens throws (fail closed)', async () => { const mockAccountId = 'account1'; const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SomeAddr'; @@ -1504,13 +1536,10 @@ describe('MultichainAssetsController', () => { await jestAdvanceTime({ duration: 1 }); - // Token should be kept when scan throws - expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ - token, - ]); + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([]); }); - it('keeps all tokens when bulkScanTokens returns empty (API error handled internally)', async () => { + it('does not add tokens when bulkScanTokens returns empty (API error handled internally)', async () => { const mockAccountId = 'account1'; const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SomeAddr'; @@ -1533,10 +1562,7 @@ describe('MultichainAssetsController', () => { await jestAdvanceTime({ duration: 1 }); - // Token should be kept when scan returns empty (no result = fail open) - expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ - token, - ]); + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([]); }); it('does not scan native (slip44) assets', async () => { @@ -1566,7 +1592,7 @@ describe('MultichainAssetsController', () => { expect(mockBulkScanTokens).not.toHaveBeenCalled(); }); - it('keeps tokens with no result in the scan response (fail open)', async () => { + it('does not add tokens with no result in the scan response (fail closed)', async () => { const mockAccountId = 'account1'; const knownToken = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:KnownAddr'; @@ -1598,10 +1624,8 @@ describe('MultichainAssetsController', () => { await jestAdvanceTime({ duration: 1 }); - // Both tokens should be kept (unknown token has no result, fail open) expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ knownToken, - unknownToken, ]); }); @@ -1713,7 +1737,7 @@ describe('MultichainAssetsController', () => { messenger.publish('AccountsController:accountAssetListUpdated', { assets: { - [mockAccountId]: { added: tokens, removed: [] }, + [mockAccountId]: { added: tokens as CaipAssetType[], removed: [] }, }, }); @@ -1741,7 +1765,7 @@ describe('MultichainAssetsController', () => { ).toBeUndefined(); }); - it('keeps results from successful batches when one batch fails (partial fail open)', async () => { + it('drops tokens from batches that fail (partial fail closed)', async () => { const mockAccountId = 'account1'; // 120 tokens = batch 1 (100) + batch 2 (20) const tokens = Array.from( @@ -1776,13 +1800,13 @@ describe('MultichainAssetsController', () => { } return Promise.resolve(results); } - // Second batch fails + // Second batch fails — its tokens must not be added return Promise.reject(new Error('API timeout')); }); messenger.publish('AccountsController:accountAssetListUpdated', { assets: { - [mockAccountId]: { added: tokens, removed: [] }, + [mockAccountId]: { added: tokens as CaipAssetType[], removed: [] }, }, }); @@ -1798,14 +1822,154 @@ describe('MultichainAssetsController', () => { ), ).toBeUndefined(); - // Tokens from the failed second batch (100–119) should all be kept (fail open) for (let i = 100; i < 120; i++) { const tokenCaip = `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`; - expect(storedAssets).toContain(tokenCaip); + expect(storedAssets).not.toContain(tokenCaip); } - // Total: 99 benign from batch 1 + 20 kept from failed batch 2 = 119 - expect(storedAssets).toHaveLength(119); + expect(storedAssets).toHaveLength(99); + }); + + it('periodic rescan ignores SPL tokens that Blockaid later marks malicious', async () => { + const mockAccountId = 'account1'; + const token = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:TurnsMalicious'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [token] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + TurnsMalicious: { + result_type: TokenScanResultType.Malicious, + chain: 'solana', + address: 'TurnsMalicious', + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([]); + expect(controller.state.allIgnoredAssets[mockAccountId]).toStrictEqual([ + token, + ]); + + controller.stopAllPolling(); + }); + + it('periodic rescan leaves tokens unchanged when bulk scan batch rejects', async () => { + const mockAccountId = 'account1'; + const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SomeAddr'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [token] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockRejectedValue(new Error('network error')); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + token, + ]); + + controller.stopAllPolling(); + }); + + it('periodic rescan skips Blockaid when account only holds native slip44 assets', async () => { + const mockAccountId = 'account1'; + const native = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [native] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + native, + ]); + + controller.stopAllPolling(); + }); + + it('periodic rescan skips entries that are not CAIP asset type strings', async () => { + const mockAccountId = 'account1'; + const notCaip = 'clearly-not-caip' as CaipAssetType; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [notCaip] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + + controller.stopAllPolling(); + }); + + it('does not publish accountAssetListUpdated when periodic rescan finds no malicious tokens', async () => { + const mockAccountId = 'account1'; + const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:StillBenign'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [token] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + StillBenign: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'StillBenign', + }, + }); + + const publishSpy = jest.spyOn( + ( + controller as unknown as { + messenger: MultichainAssetsControllerMessenger; + } + ).messenger, + 'publish', + ); + + await jestAdvanceTime({ duration: 1 }); + + expect( + publishSpy.mock.calls.filter( + (call) => + call[0] === 'MultichainAssetsController:accountAssetListUpdated', + ), + ).toHaveLength(0); + + publishSpy.mockRestore(); + controller.stopAllPolling(); }); }); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index adef7b6939c..4030d6b3c57 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -4,7 +4,6 @@ import type { AccountsControllerAccountRemovedEvent, AccountsControllerListMultichainAccountsAction, } from '@metamask/accounts-controller'; -import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -29,6 +28,7 @@ import type { PhishingControllerBulkScanTokensAction, } from '@metamask/phishing-controller'; import { TokenScanResultType } from '@metamask/phishing-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { SnapControllerGetRunnableSnapsAction, SnapControllerHandleRequestAction, @@ -183,9 +183,26 @@ const MESSENGER_EXPOSED_METHODS = [ 'addAssets', ] as const; -// TODO: make this controller extends StaticIntervalPollingController and update all assetsMetadata once a day. +/** Phishing API allows at most this many token addresses per bulk scan request. */ +const BLOCKAID_BULK_TOKEN_SCAN_BATCH_SIZE = 100; -export class MultichainAssetsController extends BaseController< +/** + * Default interval for re-scanning stored SPL (`token:`) assets with Blockaid. + * Once per day limits API load while still catching tokens reclassified after add. + */ +const DEFAULT_BLOCKAID_TOKEN_RESCAN_INTERVAL_MS = 24 * 60 * 60 * 1000; + +type ChainTokenEntry = { asset: CaipAssetType; address: string }; + +type BulkTokenScanBatchOutcome = + | { + status: 'fulfilled'; + response: BulkTokenScanResponse; + entries: ChainTokenEntry[]; + } + | { status: 'rejected'; entries: ChainTokenEntry[] }; + +export class MultichainAssetsController extends StaticIntervalPollingController()< typeof controllerName, MultichainAssetsControllerState, MultichainAssetsControllerMessenger @@ -198,9 +215,12 @@ export class MultichainAssetsController extends BaseController< constructor({ messenger, state = {}, + blockaidTokenRescanInterval = DEFAULT_BLOCKAID_TOKEN_RESCAN_INTERVAL_MS, }: { messenger: MultichainAssetsControllerMessenger; state?: Partial; + /** Blockaid re-scan interval (ms); default daily. `0` disables. */ + blockaidTokenRescanInterval?: number; }) { super({ messenger, @@ -214,22 +234,74 @@ export class MultichainAssetsController extends BaseController< this.#snaps = {}; + if (blockaidTokenRescanInterval > 0) { + this.setIntervalLength(blockaidTokenRescanInterval); + this.startPolling(null); + } + this.messenger.subscribe( 'AccountsController:accountAdded', + // eslint-disable-next-line @typescript-eslint/no-misused-promises async (account) => await this.#handleOnAccountAddedEvent(account), ); this.messenger.subscribe( 'AccountsController:accountRemoved', + // eslint-disable-next-line @typescript-eslint/no-misused-promises async (account) => await this.#handleOnAccountRemovedEvent(account), ); this.messenger.subscribe( 'AccountsController:accountAssetListUpdated', + // eslint-disable-next-line @typescript-eslint/no-misused-promises async (event) => await this.#handleAccountAssetListUpdatedEvent(event), ); messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); } + async _executePoll(_input: null): Promise { + await this.#withControllerLock(async () => { + const assetsByAccount: Record< + string, + { added: CaipAssetType[]; removed: CaipAssetType[] } + > = {}; + + for (const [accountId, assets] of Object.entries( + this.state.accountsAssets, + )) { + const splTokens = assets.filter((asset) => { + if (!isCaipAssetType(asset)) { + return false; + } + try { + return parseCaipAssetType(asset).assetNamespace === 'token'; + } catch { + return false; + } + }); + + if (splTokens.length === 0) { + continue; + } + + const malicious = await this.#findMaliciousTokensAmong(splTokens); + if (malicious.length > 0) { + this.ignoreAssets(malicious, accountId); + assetsByAccount[accountId] = { + added: [], + removed: malicious, + }; + } + } + + if (Object.keys(assetsByAccount).length > 0) { + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { + assets: assetsByAccount, + }); + } + }); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #handleAccountAssetListUpdatedEvent( event: AccountAssetListUpdatedEventPayload, ) { @@ -238,6 +310,7 @@ export class MultichainAssetsController extends BaseController< ); } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #handleOnAccountAddedEvent(account: InternalAccount) { return this.#withControllerLock(async () => this.#handleOnAccountAdded(account), @@ -371,6 +444,7 @@ export class MultichainAssetsController extends BaseController< * * @param event - The list of assets to update */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #handleAccountAssetListUpdated( event: AccountAssetListUpdatedEventPayload, ) { @@ -394,10 +468,9 @@ export class MultichainAssetsController extends BaseController< !this.#isAssetIgnored(asset, accountId), ); - // Filter out tokens flagged by Blockaid as non-benign - const filteredToBeAddedAssets = await this.#filterBlockaidSpamTokens( - preFilteredToBeAddedAssets, - ); + // Filter out tokens that cannot be verified or are flagged malicious + const filteredToBeAddedAssets = + await this.#filterBlockaidSpamTokensOnAdd(preFilteredToBeAddedAssets); // In case accountsAndAssetsToUpdate event is fired with "removed" assets that don't exist, we don't want to remove them const filteredToBeRemovedAssets = removed.filter( @@ -482,7 +555,13 @@ export class MultichainAssetsController extends BaseController< account.id, account.metadata.snap.id, ); - const assets = await this.#filterBlockaidSpamTokens(allAssets); + const caipAssets = allAssets.filter(isCaipAssetType); + const filteredCaip = + await this.#filterBlockaidSpamTokensOnAdd(caipAssets); + const filteredCaipSet = new Set(filteredCaip); + const assets = allAssets.filter( + (asset) => !isCaipAssetType(asset) || filteredCaipSet.has(asset), + ); await this.#refreshAssetsMetadata(assets); this.update((state) => { state.accountsAssets[account.id] = assets; @@ -521,6 +600,7 @@ export class MultichainAssetsController extends BaseController< * * @param assets - The assets to refresh */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #refreshAssetsMetadata(assets: CaipAssetType[]) { this.#assertControllerMutexIsLocked(); @@ -548,6 +628,7 @@ export class MultichainAssetsController extends BaseController< * * @param assets - The assets to update */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #updateAssetsMetadata(assets: CaipAssetType[]) { // Creates a mapping of scope to their respective assets list. const assetsByScope: Record = {}; @@ -681,29 +762,20 @@ export class MultichainAssetsController extends BaseController< } /** - * Filters out tokens flagged as malicious by Blockaid via the - * `PhishingController:bulkScanTokens` messenger action. Only tokens with - * an `assetNamespace` of "token" are scanned (native assets like slip44 are - * passed through unfiltered). If the scan fails, all tokens are kept - * (fail open). + * Groups `token:` CAIP assets by chain namespace for bulk scan. * - * @param assets - The CAIP asset type list to filter. - * @returns The filtered list with malicious tokens removed. + * @param assets - CAIP assets to inspect. + * @returns Map of chain namespace to token entries. */ - async #filterBlockaidSpamTokens( + #groupTokenAssetsByChain( assets: CaipAssetType[], - ): Promise { - // Group scannable token assets by chain namespace - const tokensByChain: Record< - string, - { asset: CaipAssetType; address: string }[] - > = {}; + ): Record { + const tokensByChain: Record = {}; for (const asset of assets) { const { assetNamespace, assetReference, chain } = parseCaipAssetType(asset); - // Only scan fungible token assets (e.g. SPL tokens), skip native (slip44) if (assetNamespace === 'token') { const chainName = chain.namespace; if (!tokensByChain[chainName]) { @@ -713,58 +785,132 @@ export class MultichainAssetsController extends BaseController< } } - // If there are no token assets to scan, return as-is - if (Object.keys(tokensByChain).length === 0) { - return assets; + return tokensByChain; + } + + async #runBatchedBulkTokenScans( + chainName: string, + tokenEntries: ChainTokenEntry[], + ): Promise { + const batches: ChainTokenEntry[][] = []; + for ( + let i = 0; + i < tokenEntries.length; + i += BLOCKAID_BULK_TOKEN_SCAN_BATCH_SIZE + ) { + batches.push( + tokenEntries.slice(i, i + BLOCKAID_BULK_TOKEN_SCAN_BATCH_SIZE), + ); } - // Build a set of assets to reject (non-benign tokens) - const rejectedAssets = new Set(); + const batchResults = await Promise.allSettled( + batches.map((batch) => + this.messenger.call('PhishingController:bulkScanTokens', { + chainId: chainName, + tokens: batch.map((entry) => entry.address), + }), + ), + ); + + return batches.map((entries, index) => { + const result = batchResults[index]; + if (result.status === 'fulfilled') { + return { + status: 'fulfilled' as const, + response: result.value, + entries, + }; + } + return { status: 'rejected' as const, entries }; + }); + } - // PhishingController:bulkScanTokens rejects requests with more than - // 100 tokens (returning {}). Batch addresses into chunks to stay within - // the limit. - const BATCH_SIZE = 100; + /** + * Fail-closed Blockaid filter for newly detected `token:` assets (native/other namespaces unchanged). + * + * @param assets - CAIP assets to filter. + * @returns Filtered list, original order preserved. + */ + async #filterBlockaidSpamTokensOnAdd( + assets: CaipAssetType[], + ): Promise { + const tokensByChain = this.#groupTokenAssetsByChain(assets); - for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { - const addresses = tokenEntries.map((entry) => entry.address); + if (Object.keys(tokensByChain).length === 0) { + return [...assets]; + } - // Create batches of BATCH_SIZE - const batches: string[][] = []; - for (let i = 0; i < addresses.length; i += BATCH_SIZE) { - batches.push(addresses.slice(i, i + BATCH_SIZE)); - } + const keptTokenAssets = new Set(); - // Scan all batches in parallel. Using Promise.allSettled so that a - // single batch failure doesn't discard results from successful batches - // (fail open at the batch level, not the chain level). - const batchResults = await Promise.allSettled( - batches.map((batch) => - this.messenger.call('PhishingController:bulkScanTokens', { - chainId: chainName, - tokens: batch, - }), - ), + for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { + const batchOutcomes = await this.#runBatchedBulkTokenScans( + chainName, + tokenEntries, ); - // Merge results from fulfilled batches (rejected batches fail open) - const scanResponse: BulkTokenScanResponse = {}; - for (const result of batchResults) { - if (result.status === 'fulfilled') { - Object.assign(scanResponse, result.value); + for (const outcome of batchOutcomes) { + if (outcome.status === 'rejected') { + continue; + } + for (const entry of outcome.entries) { + const scanned = outcome.response[entry.address]; + if ( + scanned?.result_type && + scanned.result_type !== TokenScanResultType.Malicious + ) { + keptTokenAssets.add(entry.asset); + } + } + } + } + + return assets.filter((asset) => { + try { + if (parseCaipAssetType(asset).assetNamespace === 'token') { + return keptTokenAssets.has(asset); } + } catch { + return false; } + return true; + }); + } + + /** + * SPL `token:` assets in state that Blockaid marks malicious (failed batches skipped). + * + * @param assets - CAIP `token:` assets to scan. + * @returns Subset marked malicious. + */ + async #findMaliciousTokensAmong( + assets: CaipAssetType[], + ): Promise { + const tokensByChain = this.#groupTokenAssetsByChain(assets); + + const maliciousAssets: CaipAssetType[] = []; - for (const entry of tokenEntries) { - const result = scanResponse[entry.address]; - if (result?.result_type === TokenScanResultType.Malicious) { - rejectedAssets.add(entry.asset); + for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { + const batchOutcomes = await this.#runBatchedBulkTokenScans( + chainName, + tokenEntries, + ); + + for (const outcome of batchOutcomes) { + if (outcome.status === 'rejected') { + continue; + } + for (const entry of outcome.entries) { + if ( + outcome.response[entry.address]?.result_type === + TokenScanResultType.Malicious + ) { + maliciousAssets.push(entry.asset); + } } } } - // Filter while preserving original order - return assets.filter((asset) => !rejectedAssets.has(asset)); + return maliciousAssets; } /** @@ -804,6 +950,7 @@ export class MultichainAssetsController extends BaseController< * * @throws If the controller mutex is not locked. */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type #assertControllerMutexIsLocked() { if (!this.#controllerOperationMutex.isLocked()) { throw new Error(