diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 5c4942dea43..d9f8a68b3f6 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -18,6 +18,8 @@ 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 ([#9803](https://github.com/MetaMask/core/pull/9803)) +- Checksum the lower-case ERC-20 asset IDs delivered by `AccountActivityDataSource` before they enter the assets pipeline, so middleware comparisons against state (which keys assets by checksummed ID) no longer treat an existing holding as a brand-new asset — previously such balance updates could be re-detected, or dropped entirely by spam filtering, and never reach state ([#9803](https://github.com/MetaMask/core/pull/9803)) - 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] diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index cee1e2daa56..ed4cd54dc99 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -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()) { diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts index 2d522e7f681..d374539928b 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts @@ -31,6 +31,11 @@ const EVM_ADDRESS = '0x1234567890123456789012345678901234567890'; const SOLANA_ADDRESS = 'DjVE6JNiYqPL2QXyCUUh8rNjHrbz9hXHNYt99MQ59qw1'; const ETH_ASSET = 'eip155:1/slip44:60' as Caip19AssetId; +// The websocket delivers lower-case addresses; state keys them checksummed. +const USDC_ASSET_LOWERCASE = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId; +const USDC_ASSET_CHECKSUMMED = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; /** * Build an InternalAccount for tests. @@ -354,6 +359,60 @@ describe('AccountActivityDataSource', () => { cleanup(); }); + it('checksums the lower-case ERC-20 asset IDs the websocket delivers', async () => { + const account = createMockAccount(); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], + }); + + triggerBalanceUpdated({ + address: EVM_ADDRESS, + chain: CHAIN_MAINNET, + updates: [ + createBalanceUpdate({ + asset: { type: USDC_ASSET_LOWERCASE, unit: 'USDC', decimals: 6 }, + postBalance: { amount: '1000000' }, + }), + ], + }); + + await Promise.resolve(); + + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsBalance[account.id]).toHaveProperty( + USDC_ASSET_CHECKSUMMED, + ); + expect(response.assetsBalance[account.id]).not.toHaveProperty( + USDC_ASSET_LOWERCASE, + ); + expect(response.assetsInfo).toHaveProperty(USDC_ASSET_CHECKSUMMED); + + cleanup(); + }); + + it('keeps an unparseable asset ID as-is', async () => { + const account = createMockAccount(); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], + }); + + const malformedAssetId = 'eip155:1/erc20:not-an-address' as Caip19AssetId; + triggerBalanceUpdated({ + address: EVM_ADDRESS, + chain: CHAIN_MAINNET, + updates: [createBalanceUpdate({ asset: { type: malformedAssetId } })], + }); + + await Promise.resolve(); + + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsBalance[account.id]).toHaveProperty( + malformedAssetId, + ); + + cleanup(); + }); + it.each([ ['address is empty', { address: '' }], ['chain is empty', { chain: '' }], diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts index 13b701d80f5..196af953ac0 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts @@ -17,6 +17,7 @@ import type { DataRequest, DataResponse, } from '../types.js'; +import { safeNormalizeAssetId } from '../utils/index.js'; import { AbstractDataSource } from './AbstractDataSource.js'; import type { DataSourceState } from './AbstractDataSource.js'; @@ -66,7 +67,11 @@ function processAccountActivityBalanceUpdates( continue; } - const assetId = asset.type as Caip19AssetId; + // The websocket sends lower-case ERC-20 addresses, while state and the + // rest of the pipeline key assets by their checksummed ID. Normalizing + // here keeps middleware comparisons (detection, custom-asset graduation, + // occurrence filtering) from treating an existing holding as brand new. + const assetId = safeNormalizeAssetId(asset.type as Caip19AssetId); if (asset.decimals === undefined) { continue; diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts index 68f7ff02480..b7d01fe087b 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts @@ -19,6 +19,9 @@ const CHAIN_MAINNET = 'eip155:1' as ChainId; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; const MOCK_TOKEN_ASSET = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId; +// Same token as MOCK_TOKEN_ASSET, in the checksummed form state stores. +const MOCK_TOKEN_ASSET_CHECKSUMMED = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const MOCK_NATIVE_ASSET = 'eip155:1/slip44:60' as Caip19AssetId; const MOCK_BTC_ASSET = 'bip122:000000000019d6689c085ae165831e93/slip44:0' as Caip19AssetId; @@ -974,6 +977,287 @@ 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; + expect(accountBalances[MOCK_TOKEN_ASSET]).toBeDefined(); + expect(accountBalances[spamAsset]).toBeUndefined(); + expect(context.response.assetsInfo?.[spamAsset]).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + + it('occurrenceFilterMiddleware keeps holdings already in state balances when the response uses a different address case', async () => { + const { controller, apiClient } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + // AccountActivity (websocket) delivers lower-case ERC-20 IDs, while state + // stores them checksummed — the same holding, different case. + const context = createMiddlewareContext({ + request: createDataRequest({ dataTypes: ['balance'] }), + response: { + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET]: { amount: '100' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET_CHECKSUMMED]: { amount: '42' }, + }, + }, + assetsInfo: {}, + }), + }); + + await controller.occurrenceFilterMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).not.toHaveBeenCalled(); + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as Record< + string, + unknown + > + )[MOCK_TOKEN_ASSET], + ).toStrictEqual({ amount: '100' }); + }); + + it('occurrenceFilterMiddleware keeps holdings known only through state metadata when the response uses a different address case', async () => { + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + request: createDataRequest({ dataTypes: ['balance'] }), + response: { + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET]: { amount: '100' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsBalance: {}, + assetsInfo: { + [MOCK_TOKEN_ASSET_CHECKSUMMED]: { + type: 'erc20', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + }, + }, + }), + }); + + await controller.occurrenceFilterMiddleware(context, next); + + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as Record< + string, + unknown + > + )[MOCK_TOKEN_ASSET], + ).toStrictEqual({ amount: '100' }); + }); + + it('occurrenceFilterMiddleware keeps a custom asset that CustomAssetGraduationMiddleware just removed from customAssets', async () => { + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + // Graduation runs first and empties `customAssets`, so the exemption is + // gone by the time this middleware reads state; the zero balance seeded + // by `addCustomAsset` (checksummed) is what marks the asset as known. + const context = createMiddlewareContext({ + request: createDataRequest({ dataTypes: ['balance'] }), + response: { + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET]: { amount: '100' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET_CHECKSUMMED]: { amount: '0' }, + }, + }, + assetsInfo: {}, + customAssets: { 'mock-account-id': [] }, + }), + }); + + await controller.occurrenceFilterMiddleware(context, next); + + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as Record< + string, + unknown + > + )[MOCK_TOKEN_ASSET], + ).toStrictEqual({ amount: '100' }); + }); + + it('occurrenceFilterMiddleware exempts custom assets recorded in a different address case', async () => { + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + request: createDataRequest({ dataTypes: ['balance'] }), + response: { + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET]: { amount: '100' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsBalance: {}, + assetsInfo: {}, + customAssets: { 'mock-account-id': [MOCK_TOKEN_ASSET_CHECKSUMMED] }, + }), + }); + + await controller.occurrenceFilterMiddleware(context, next); + + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as Record< + string, + unknown + > + )[MOCK_TOKEN_ASSET], + ).toStrictEqual({ amount: '100' }); + }); + 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 = diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts index 5d602b9a8d3..fffed2b9461 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -348,6 +348,152 @@ 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()), + ); + + // State keys are checksummed, but AccountActivity delivers lower-case + // ERC-20 IDs, so every lookup below compares lower-cased IDs. Matching + // case-sensitively would classify existing holdings as new and delete + // the very balance update this pipeline pass is meant to persist. + const knownMetadataIds = new Set( + Object.keys(stateMetadata).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(); + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance ?? {}, + )) { + const knownBalanceIds = new Set( + Object.keys(stateBalances[accountId] ?? {}).map((id) => + id.toLowerCase(), + ), + ); + for (const assetId of Object.keys(accountBalances)) { + const caipAssetId = assetId as Caip19AssetId; + const lowerId = assetId.toLowerCase(); + if ( + knownBalanceIds.has(lowerId) || + knownMetadataIds.has(lowerId) || + 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({ + 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(); + 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)[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. * @@ -607,6 +753,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 }); diff --git a/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts b/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts index 17070e3cdde..9fc5771ac89 100644 --- a/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts +++ b/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts @@ -8,7 +8,7 @@ import type { Caip19AssetId, Middleware, } from '../types.js'; -import { normalizeAssetId } from '../utils/index.js'; +import { safeNormalizeAssetId } from '../utils/index.js'; const CONTROLLER_NAME = 'CustomAssetGraduationMiddleware'; @@ -94,7 +94,7 @@ export class CustomAssetGraduationMiddleware { if (!hasPositiveBalance(returnedBalances[rawAssetId])) { continue; } - const normalizedAssetId = safeNormalize(rawAssetId); + const normalizedAssetId = safeNormalizeAssetId(rawAssetId); if (!customSet.has(normalizedAssetId)) { continue; } @@ -123,23 +123,6 @@ function isEvmAssetId(assetId: Caip19AssetId): boolean { return namespace === KnownCaipNamespace.Eip155; } -/** - * Normalize a CAIP-19 asset ID, returning the original on failure. Some - * malformed IDs (e.g. an asset reference that fails address checksumming) - * make `normalizeAssetId` throw — in that case we fall back to the raw ID - * so the graduation pass can still proceed for other assets. - * - * @param assetId - The CAIP-19 asset ID to normalize. - * @returns The normalized ID, or the original on failure. - */ -function safeNormalize(assetId: Caip19AssetId): Caip19AssetId { - try { - return normalizeAssetId(assetId); - } catch { - return assetId; - } -} - /** * Whether a balance entry reports a strictly positive amount. AccountsAPI * may return zero for tokens it indexes but the user no longer holds; we diff --git a/packages/assets-controller/src/utils/index.ts b/packages/assets-controller/src/utils/index.ts index ee56396dc86..c49f5a496f2 100644 --- a/packages/assets-controller/src/utils/index.ts +++ b/packages/assets-controller/src/utils/index.ts @@ -2,6 +2,7 @@ export { fetchWithTimeout } from './fetchWithTimeout.js'; export { normalizeAmountString } from './normalizeAmountString.js'; export { normalizeAssetId, + safeNormalizeAssetId, clearNormalizeAssetIdCacheForTesting, } from './normalizeAssetId.js'; export { diff --git a/packages/assets-controller/src/utils/normalizeAssetId.ts b/packages/assets-controller/src/utils/normalizeAssetId.ts index aa6f20724f9..6a3563dc89e 100644 --- a/packages/assets-controller/src/utils/normalizeAssetId.ts +++ b/packages/assets-controller/src/utils/normalizeAssetId.ts @@ -36,6 +36,23 @@ export const normalizeAssetId: ((assetId: Caip19AssetId) => Caip19AssetId) & return assetId; }); +/** + * Normalize a CAIP-19 asset ID, returning the original on failure. Some + * malformed IDs (e.g. an asset reference that fails address checksumming) + * make {@link normalizeAssetId} throw; callers processing untrusted input + * use this so one bad ID cannot abort a whole batch. + * + * @param assetId - The CAIP-19 asset ID to normalize. + * @returns The normalized ID, or the original on failure. + */ +export function safeNormalizeAssetId(assetId: Caip19AssetId): Caip19AssetId { + try { + return normalizeAssetId(assetId); + } catch { + return assetId; + } +} + /** * Clears the {@link normalizeAssetId} memoize cache. Exported for unit tests. */