From 004d52cf496c8c294af74b823f4845bab828b5b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 17:54:29 +0000 Subject: [PATCH 01/14] Add cleanupUnusedMetadata to prune unreferenced assetsInfo/assetsPrice entries on startup Co-authored-by: Prithpal Sooriya --- .../src/AssetsController.test.ts | 62 ++++ .../assets-controller/src/AssetsController.ts | 7 + .../src/utils/cleanupUnusedMetadata.test.ts | 337 ++++++++++++++++++ .../src/utils/cleanupUnusedMetadata.ts | 107 ++++++ 4 files changed, 513 insertions(+) create mode 100644 packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts create mode 100644 packages/assets-controller/src/utils/cleanupUnusedMetadata.ts diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 075b220955f..a5f8fc8e635 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2917,6 +2917,68 @@ describe('AssetsController', () => { }); }); + it('cleans up unused assetsInfo and assetsPrice entries after the startup refresh', async () => { + const unreferencedAssetId = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + const zeroBalanceAssetId = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; + // Default tracked asset on a chain that is NOT enabled in this test + // (only eip155:1 is enabled): metadata is pre-seeded but no balance + // entry exists, and it must survive the cleanup. + const musdOnMonadAssetId = + 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA' as Caip19AssetId; + + await withController( + { + clientControllerState: { isUiOpen: true }, + state: { + assetsInfo: { + ...buildDefaultAssetsInfo(), + [unreferencedAssetId]: { + type: 'erc20', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + }, + [zeroBalanceAssetId]: { + type: 'erc20', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + }, + }, + assetsPrice: { + [unreferencedAssetId]: { + assetPriceType: 'fungible', + price: 1, + usdPrice: 1, + lastUpdated: 0, + }, + }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [zeroBalanceAssetId]: { amount: '0' } }, + }, + }, + }, + async ({ controller, messenger }) => { + expect( + controller.state.assetsInfo[unreferencedAssetId], + ).toBeDefined(); + + await activateTracking(messenger); + + expect( + controller.state.assetsInfo[unreferencedAssetId], + ).toBeUndefined(); + expect( + controller.state.assetsPrice[unreferencedAssetId], + ).toBeUndefined(); + expect(controller.state.assetsInfo[zeroBalanceAssetId]).toBeDefined(); + expect(controller.state.assetsInfo[musdOnMonadAssetId]).toBeDefined(); + }, + ); + }); + it('stops tracking on keyring lock', async () => { await withController(async ({ messenger }) => { messenger.publish('KeyringController:unlock'); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index cee1e2daa56..14b947f616c 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -139,6 +139,7 @@ import type { SubscriptionResponse, Asset, } from './types.js'; +import { cleanupUnusedMetadata } from './utils/cleanupUnusedMetadata.js'; import { ZERO_ADDRESS } from './utils/constants.js'; import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement.js'; import { @@ -1365,12 +1366,18 @@ export class AssetsController extends BaseController< this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); + this.update((state) => + cleanupUnusedMetadata(state as AssetsControllerState), + ); } catch (error) { log('Failed to fetch assets on startup', error); this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); + this.update((state) => + cleanupUnusedMetadata(state as AssetsControllerState), + ); } finally { releaseLock(); } diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts new file mode 100644 index 00000000000..9f59c547b78 --- /dev/null +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts @@ -0,0 +1,337 @@ +import type { AssetsControllerState } from '../AssetsController.js'; +import type { AssetMetadata, AssetPrice } from '../types.js'; +import { cleanupUnusedMetadata } from './cleanupUnusedMetadata.js'; + +const SELECTED_ACCOUNT = 'account-1'; +const OTHER_ACCOUNT = 'account-2'; + +/** USDC on mainnet — held (non-zero balance) in most tests. */ +const HELD_ASSET = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + +/** DAI on mainnet — never referenced by any state slice. */ +const UNREFERENCED_ASSET = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; + +/** USDT on mainnet — only referenced through a zero-amount balance entry. */ +const ZERO_BALANCE_ASSET = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7'; + +/** cbETH on Base — only referenced through `customAssets`. */ +const CUSTOM_ASSET = + 'eip155:8453/erc20:0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22'; + +/** ETH on mainnet — native via the `slip44` asset namespace. */ +const NATIVE_SLIP44_ASSET = 'eip155:1/slip44:60'; + +/** + * SOL on Solana — a `slip44` native on a chain that is not in the hardcoded + * native asset registry (which only covers EVM chains). + */ +const NATIVE_SOLANA_ASSET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'; + +/** + * Native asset on a made-up chain represented with the zero-address ERC-20 + * convention (like Gnosis xDAI); not present in any native asset registry. + */ +const NATIVE_ZERO_ADDRESS_ASSET = + 'eip155:424242/erc20:0x0000000000000000000000000000000000000000'; + +/** + * METIS on Metis Andromeda — native represented as a non-zero "dead" ERC-20 + * address; only recognizable through the native asset registry constant. + */ +const NATIVE_REGISTRY_ASSET = + 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000'; + +/** + * mUSD on Monad — a default tracked asset + * (`DEFAULT_TRACKED_ASSETS_BY_CHAIN`). Its metadata is pre-seeded into + * `assetsInfo` by `getDefaultAssetsControllerState`, but no balance entry + * exists until the user enables the Monad network. + */ +const MUSD_ON_MONAD_ASSET = + 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; + +/** + * Build metadata for a fungible test asset. + * + * @param symbol - Token symbol. + * @returns Minimal ERC-20 metadata. + */ +function buildMetadata(symbol: string): AssetMetadata { + return { type: 'erc20', symbol, name: symbol, decimals: 18 }; +} + +/** + * Build a price entry for a fungible test asset. + * + * @param value - Price in the selected currency and USD. + * @returns Minimal fungible price data. + */ +function buildPrice(value: number): AssetPrice { + return { + assetPriceType: 'fungible', + price: value, + usdPrice: value, + lastUpdated: 1700000000000, + }; +} + +/** + * Build a plain `AssetsControllerState` object for tests. + * + * @param overrides - State slices to override. + * @returns A complete state object. + */ +function buildState( + overrides: Partial = {}, +): AssetsControllerState { + return { + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + selectedCurrency: 'usd', + ...overrides, + }; +} + +describe('cleanupUnusedMetadata', () => { + it('removes assetsInfo and assetsPrice entries that nothing references', () => { + const state = buildState({ + assetsInfo: { + [HELD_ASSET]: buildMetadata('USDC'), + [UNREFERENCED_ASSET]: buildMetadata('DAI'), + }, + assetsPrice: { + [HELD_ASSET]: buildPrice(1), + [UNREFERENCED_ASSET]: buildPrice(1), + }, + assetsBalance: { + [SELECTED_ACCOUNT]: { [HELD_ASSET]: { amount: '5000000' } }, + }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({ + [HELD_ASSET]: buildMetadata('USDC'), + }); + expect(state.assetsPrice).toStrictEqual({ + [HELD_ASSET]: buildPrice(1), + }); + }); + + it('removes an unreferenced price entry even when the asset has no assetsInfo entry', () => { + const state = buildState({ + assetsPrice: { [UNREFERENCED_ASSET]: buildPrice(1) }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsPrice).toStrictEqual({}); + }); + + it('keeps an asset whose only balance entry is a zero amount', () => { + const state = buildState({ + assetsInfo: { [ZERO_BALANCE_ASSET]: buildMetadata('USDT') }, + assetsPrice: { [ZERO_BALANCE_ASSET]: buildPrice(1) }, + assetsBalance: { + [SELECTED_ACCOUNT]: { [ZERO_BALANCE_ASSET]: { amount: '0' } }, + }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({ + [ZERO_BALANCE_ASSET]: buildMetadata('USDT'), + }); + expect(state.assetsPrice).toStrictEqual({ + [ZERO_BALANCE_ASSET]: buildPrice(1), + }); + }); + + it('keeps an asset that is only referenced by customAssets', () => { + const state = buildState({ + assetsInfo: { [CUSTOM_ASSET]: buildMetadata('cbETH') }, + assetsPrice: { [CUSTOM_ASSET]: buildPrice(2000) }, + customAssets: { [SELECTED_ACCOUNT]: [CUSTOM_ASSET] }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({ + [CUSTOM_ASSET]: buildMetadata('cbETH'), + }); + expect(state.assetsPrice).toStrictEqual({ + [CUSTOM_ASSET]: buildPrice(2000), + }); + }); + + it('keeps native assets even when no account has a balance entry for them', () => { + const state = buildState({ + assetsInfo: { + [NATIVE_SLIP44_ASSET]: buildMetadata('ETH'), + [NATIVE_SOLANA_ASSET]: buildMetadata('SOL'), + [NATIVE_ZERO_ADDRESS_ASSET]: buildMetadata('XDAI'), + [NATIVE_REGISTRY_ASSET]: buildMetadata('METIS'), + }, + assetsPrice: { + [NATIVE_SLIP44_ASSET]: buildPrice(3000), + [NATIVE_SOLANA_ASSET]: buildPrice(150), + [NATIVE_ZERO_ADDRESS_ASSET]: buildPrice(1), + [NATIVE_REGISTRY_ASSET]: buildPrice(30), + }, + }); + + cleanupUnusedMetadata(state); + + expect(Object.keys(state.assetsInfo)).toStrictEqual([ + NATIVE_SLIP44_ASSET, + NATIVE_SOLANA_ASSET, + NATIVE_ZERO_ADDRESS_ASSET, + NATIVE_REGISTRY_ASSET, + ]); + expect(Object.keys(state.assetsPrice)).toStrictEqual([ + NATIVE_SLIP44_ASSET, + NATIVE_SOLANA_ASSET, + NATIVE_ZERO_ADDRESS_ASSET, + NATIVE_REGISTRY_ASSET, + ]); + }); + + it('keeps default tracked assets with no balance entry (mUSD on a chain that is not enabled)', () => { + // Mirrors real startup state: `getDefaultAssetsControllerState` + // pre-seeds mUSD metadata for Monad, but no balance is seeded because + // `#ensureDefaultTrackedAssetsSeeded` only covers enabled chains. + const state = buildState({ + assetsInfo: { [MUSD_ON_MONAD_ASSET]: buildMetadata('mUSD') }, + assetsPrice: { [MUSD_ON_MONAD_ASSET]: buildPrice(1) }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({ + [MUSD_ON_MONAD_ASSET]: buildMetadata('mUSD'), + }); + expect(state.assetsPrice).toStrictEqual({ + [MUSD_ON_MONAD_ASSET]: buildPrice(1), + }); + }); + + it('keeps an asset held only by a non-selected account', () => { + const state = buildState({ + assetsInfo: { [HELD_ASSET]: buildMetadata('USDC') }, + assetsPrice: { [HELD_ASSET]: buildPrice(1) }, + assetsBalance: { + [SELECTED_ACCOUNT]: {}, + [OTHER_ACCOUNT]: { [HELD_ASSET]: { amount: '42' } }, + }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({ + [HELD_ASSET]: buildMetadata('USDC'), + }); + expect(state.assetsPrice).toStrictEqual({ + [HELD_ASSET]: buildPrice(1), + }); + }); + + it('compares asset IDs case-insensitively', () => { + // Metadata/price keys are checksummed while the references are + // lowercase — entries must still be recognized as referenced. + const state = buildState({ + assetsInfo: { + [HELD_ASSET]: buildMetadata('USDC'), + [CUSTOM_ASSET]: buildMetadata('cbETH'), + }, + assetsPrice: { + [HELD_ASSET]: buildPrice(1), + [CUSTOM_ASSET]: buildPrice(2000), + }, + assetsBalance: { + [SELECTED_ACCOUNT]: { [HELD_ASSET.toLowerCase()]: { amount: '1' } }, + }, + customAssets: { + [SELECTED_ACCOUNT]: [ + CUSTOM_ASSET.toLowerCase() as `${string}:${string}/${string}:${string}`, + ], + }, + }); + + cleanupUnusedMetadata(state); + + expect(Object.keys(state.assetsInfo)).toStrictEqual([ + HELD_ASSET, + CUSTOM_ASSET, + ]); + expect(Object.keys(state.assetsPrice)).toStrictEqual([ + HELD_ASSET, + CUSTOM_ASSET, + ]); + }); + + it('leaves assetPreferences untouched, including entries for removed assets', () => { + const state = buildState({ + assetsInfo: { [UNREFERENCED_ASSET]: buildMetadata('DAI') }, + assetsPrice: { [UNREFERENCED_ASSET]: buildPrice(1) }, + assetPreferences: { + [UNREFERENCED_ASSET]: { hidden: true }, + [HELD_ASSET]: { hidden: false }, + }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({}); + expect(state.assetsPrice).toStrictEqual({}); + expect(state.assetPreferences).toStrictEqual({ + [UNREFERENCED_ASSET]: { hidden: true }, + [HELD_ASSET]: { hidden: false }, + }); + }); + + it('removes malformed and NFT asset IDs without throwing when unreferenced', () => { + const nftAssetId = + 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234'; + const state = buildState({ + assetsInfo: { + 'not-a-caip-id': buildMetadata('JUNK'), + [nftAssetId]: buildMetadata('BAYC'), + }, + assetsPrice: { 'not-a-caip-id': buildPrice(0) }, + }); + + expect(() => cleanupUnusedMetadata(state)).not.toThrow(); + + expect(state.assetsInfo).toStrictEqual({}); + expect(state.assetsPrice).toStrictEqual({}); + }); + + it('keeps a malformed asset ID that is still referenced by a balance entry', () => { + const state = buildState({ + assetsInfo: { 'not-a-caip-id': buildMetadata('JUNK') }, + assetsBalance: { + [SELECTED_ACCOUNT]: { 'not-a-caip-id': { amount: '1' } }, + }, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual({ + 'not-a-caip-id': buildMetadata('JUNK'), + }); + }); + + it('does nothing on empty state', () => { + const state = buildState(); + + cleanupUnusedMetadata(state); + + expect(state).toStrictEqual(buildState()); + }); +}); diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts new file mode 100644 index 00000000000..ee1f59ed325 --- /dev/null +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -0,0 +1,107 @@ +import { isCaipAssetType, parseCaipAssetType } from '@metamask/utils'; + +import type { AssetsControllerState } from '../AssetsController.js'; +import { DEFAULT_TRACKED_ASSETS_BY_CHAIN } from '../defaults.js'; +import { ZERO_ADDRESS } from './constants.js'; +import { buildNativeAssetsFromConstant } from './native-assets.js'; + +/** + * Lowercase CAIP-19 IDs of natives that are not expressible through the + * `slip44`/zero-address conventions (e.g. METIS and MNT, which use a "dead" + * ERC-20 address). Built once from the hardcoded native asset registry. + */ +const KNOWN_NATIVE_ASSET_IDS: ReadonlySet = new Set( + Object.values(buildNativeAssetsFromConstant()).map((assetId) => + assetId.toLowerCase(), + ), +); + +/** Lowercase CAIP-19 IDs of every default tracked asset, across all chains. */ +const DEFAULT_TRACKED_ASSET_IDS: ReadonlySet = new Set( + [...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values()].flatMap((assetIds) => + assetIds.map((assetId) => assetId.toLowerCase()), + ), +); + +/** + * Whether a CAIP-19 asset ID represents a chain's native asset. Pure + * counterpart of `AssetsController.#isNativeAsset`: recognizes the `slip44` + * namespace, the zero-address ERC-20 convention, and the hardcoded native + * asset registry (which covers non-standard representations like METIS). + * + * @param assetId - The asset ID to check (any casing). + * @returns True when the asset ID is a native asset. + */ +function isNativeAssetId(assetId: string): boolean { + if (KNOWN_NATIVE_ASSET_IDS.has(assetId.toLowerCase())) { + return true; + } + + if (!isCaipAssetType(assetId)) { + return false; + } + const parsed = parseCaipAssetType(assetId); + + if (parsed.assetNamespace === 'slip44') { + return true; + } + + return ( + parsed.assetNamespace === 'erc20' && + parsed.assetReference.toLowerCase() === ZERO_ADDRESS + ); +} + +/** + * Delete `assetsInfo` and `assetsPrice` entries for assets that nothing in + * state references anymore, so those slices do not grow unbounded (neither + * has any other delete path). + * + * An asset is considered referenced — and its entries kept — when its ID is + * any of the following (all comparisons are case-insensitive, since state + * keys are checksummed while some sources emit lowercase IDs): + * + * - a key in `assetsBalance[accountId]` for ANY account in state (key + * presence is what counts: `addCustomAsset` and the native/default-asset + * seeders write `{ amount: '0' }` entries for held assets); + * - listed in `customAssets` for any account; + * - a native asset ID; + * - a default tracked asset ({@link DEFAULT_TRACKED_ASSETS_BY_CHAIN}) — + * these often have metadata but no balance (e.g. mUSD on Monad is + * pre-seeded into `assetsInfo` while its zero balance is only written + * once the chain is enabled), and deleting them would be permanent. + * + * `assetPreferences` is deliberately left untouched: a user who hid an + * asset should find it still hidden if it ever comes back. + * + * @param state - The controller state to clean up (mutated in place). + */ +export function cleanupUnusedMetadata(state: AssetsControllerState): void { + const referencedAssetIds = new Set(); + for (const accountBalances of Object.values(state.assetsBalance)) { + for (const assetId of Object.keys(accountBalances)) { + referencedAssetIds.add(assetId.toLowerCase()); + } + } + for (const accountCustomAssets of Object.values(state.customAssets)) { + for (const assetId of accountCustomAssets) { + referencedAssetIds.add(assetId.toLowerCase()); + } + } + + const isUnused = (assetId: string): boolean => + !referencedAssetIds.has(assetId.toLowerCase()) && + !DEFAULT_TRACKED_ASSET_IDS.has(assetId.toLowerCase()) && + !isNativeAssetId(assetId); + + for (const assetId of Object.keys(state.assetsInfo)) { + if (isUnused(assetId)) { + delete state.assetsInfo[assetId]; + } + } + for (const assetId of Object.keys(state.assetsPrice)) { + if (isUnused(assetId)) { + delete state.assetsPrice[assetId]; + } + } +} From 68e24b501be3b2306a794c2c8509586f654a9c45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 17:56:04 +0000 Subject: [PATCH 02/14] Add changelog entry for unused metadata cleanup Co-authored-by: Prithpal Sooriya --- packages/assets-controller/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 5c4942dea43..2e73f157be2 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -19,6 +19,8 @@ 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)) +- Clean up unused `assetsInfo` and `assetsPrice` entries on startup so those persisted state slices no longer grow unbounded (neither had a delete path before) ([#9806](https://github.com/MetaMask/core/pull/9806)) + - At the end of the startup refresh, entries are deleted for assets that are not held by any account in state (a balance key for any account counts, including `{ amount: '0' }`), not in any account's `customAssets`, not native assets, and not default tracked assets (`DEFAULT_TRACKED_ASSETS_BY_CHAIN`). Asset IDs are compared case-insensitively, and `assetPreferences` is left untouched. ## [13.1.1] From 640aba23a336defd31d86de69d57cc74f2efae8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 18:12:27 +0000 Subject: [PATCH 03/14] Extract isNativeAssetId into native-assets and remove type-level import cycle Co-authored-by: Prithpal Sooriya --- .../assets-controller/src/AssetsController.ts | 4 +- .../src/utils/cleanupUnusedMetadata.ts | 57 +++--------------- .../src/utils/native-assets.test.ts | 48 +++++++++++++++ .../src/utils/native-assets.ts | 59 ++++++++++++++++++- 4 files changed, 116 insertions(+), 52 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 14b947f616c..217543f370b 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1367,7 +1367,7 @@ export class AssetsController extends BaseController< this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); this.update((state) => - cleanupUnusedMetadata(state as AssetsControllerState), + cleanupUnusedMetadata(state as AssetsControllerStateInternal), ); } catch (error) { log('Failed to fetch assets on startup', error); @@ -1376,7 +1376,7 @@ export class AssetsController extends BaseController< this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); this.update((state) => - cleanupUnusedMetadata(state as AssetsControllerState), + cleanupUnusedMetadata(state as AssetsControllerStateInternal), ); } finally { releaseLock(); diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts index ee1f59ed325..8b40c07dd72 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -1,20 +1,6 @@ -import { isCaipAssetType, parseCaipAssetType } from '@metamask/utils'; - -import type { AssetsControllerState } from '../AssetsController.js'; import { DEFAULT_TRACKED_ASSETS_BY_CHAIN } from '../defaults.js'; -import { ZERO_ADDRESS } from './constants.js'; -import { buildNativeAssetsFromConstant } from './native-assets.js'; - -/** - * Lowercase CAIP-19 IDs of natives that are not expressible through the - * `slip44`/zero-address conventions (e.g. METIS and MNT, which use a "dead" - * ERC-20 address). Built once from the hardcoded native asset registry. - */ -const KNOWN_NATIVE_ASSET_IDS: ReadonlySet = new Set( - Object.values(buildNativeAssetsFromConstant()).map((assetId) => - assetId.toLowerCase(), - ), -); +import type { AssetsControllerStateInternal, Caip19AssetId } from '../types.js'; +import { isNativeAssetId } from './native-assets.js'; /** Lowercase CAIP-19 IDs of every default tracked asset, across all chains. */ const DEFAULT_TRACKED_ASSET_IDS: ReadonlySet = new Set( @@ -23,35 +9,6 @@ const DEFAULT_TRACKED_ASSET_IDS: ReadonlySet = new Set( ), ); -/** - * Whether a CAIP-19 asset ID represents a chain's native asset. Pure - * counterpart of `AssetsController.#isNativeAsset`: recognizes the `slip44` - * namespace, the zero-address ERC-20 convention, and the hardcoded native - * asset registry (which covers non-standard representations like METIS). - * - * @param assetId - The asset ID to check (any casing). - * @returns True when the asset ID is a native asset. - */ -function isNativeAssetId(assetId: string): boolean { - if (KNOWN_NATIVE_ASSET_IDS.has(assetId.toLowerCase())) { - return true; - } - - if (!isCaipAssetType(assetId)) { - return false; - } - const parsed = parseCaipAssetType(assetId); - - if (parsed.assetNamespace === 'slip44') { - return true; - } - - return ( - parsed.assetNamespace === 'erc20' && - parsed.assetReference.toLowerCase() === ZERO_ADDRESS - ); -} - /** * Delete `assetsInfo` and `assetsPrice` entries for assets that nothing in * state references anymore, so those slices do not grow unbounded (neither @@ -65,7 +22,7 @@ function isNativeAssetId(assetId: string): boolean { * presence is what counts: `addCustomAsset` and the native/default-asset * seeders write `{ amount: '0' }` entries for held assets); * - listed in `customAssets` for any account; - * - a native asset ID; + * - a native asset ID (see {@link isNativeAssetId}); * - a default tracked asset ({@link DEFAULT_TRACKED_ASSETS_BY_CHAIN}) — * these often have metadata but no balance (e.g. mUSD on Monad is * pre-seeded into `assetsInfo` while its zero balance is only written @@ -76,7 +33,9 @@ function isNativeAssetId(assetId: string): boolean { * * @param state - The controller state to clean up (mutated in place). */ -export function cleanupUnusedMetadata(state: AssetsControllerState): void { +export function cleanupUnusedMetadata( + state: AssetsControllerStateInternal, +): void { const referencedAssetIds = new Set(); for (const accountBalances of Object.values(state.assetsBalance)) { for (const assetId of Object.keys(accountBalances)) { @@ -96,12 +55,12 @@ export function cleanupUnusedMetadata(state: AssetsControllerState): void { for (const assetId of Object.keys(state.assetsInfo)) { if (isUnused(assetId)) { - delete state.assetsInfo[assetId]; + delete state.assetsInfo[assetId as Caip19AssetId]; } } for (const assetId of Object.keys(state.assetsPrice)) { if (isUnused(assetId)) { - delete state.assetsPrice[assetId]; + delete state.assetsPrice[assetId as Caip19AssetId]; } } } diff --git a/packages/assets-controller/src/utils/native-assets.test.ts b/packages/assets-controller/src/utils/native-assets.test.ts index b82cedfdeb9..38a4f0af124 100644 --- a/packages/assets-controller/src/utils/native-assets.test.ts +++ b/packages/assets-controller/src/utils/native-assets.test.ts @@ -4,6 +4,7 @@ import { fetchWithErrorHandling } from '@metamask/controller-utils'; import { buildNativeAssetsFromConstant, buildNativeAssetsFromApi, + isNativeAssetId, } from './native-assets.js'; import { normalizeAssetId } from './normalizeAssetId.js'; @@ -25,6 +26,53 @@ describe('buildNativeAssetsFromConstant', () => { }); }); +describe('isNativeAssetId', () => { + it('recognizes slip44 natives, including chains outside the hardcoded registry', () => { + expect(isNativeAssetId('eip155:1/slip44:60')).toBe(true); + expect( + isNativeAssetId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'), + ).toBe(true); + }); + + it('recognizes the zero-address ERC-20 convention on any chain', () => { + expect( + isNativeAssetId( + 'eip155:424242/erc20:0x0000000000000000000000000000000000000000', + ), + ).toBe(true); + }); + + it('recognizes registry-only natives case-insensitively (e.g. METIS dead address)', () => { + expect( + isNativeAssetId( + 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', + ), + ).toBe(true); + expect( + isNativeAssetId( + 'eip155:1088/erc20:0xDEADDEADDEADDEADDEADDEADDEADDEADDEAD0000', + ), + ).toBe(true); + }); + + it('reports regular ERC-20 tokens as non-native', () => { + expect( + isNativeAssetId( + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ), + ).toBe(false); + }); + + it('reports malformed and NFT asset IDs as non-native without throwing', () => { + expect(isNativeAssetId('not-a-caip-id')).toBe(false); + expect( + isNativeAssetId( + 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234', + ), + ).toBe(false); + }); +}); + describe('buildNativeAssetsFromApi', () => { beforeEach(() => { fetchWithErrorHandlingMock.mockReset(); diff --git a/packages/assets-controller/src/utils/native-assets.ts b/packages/assets-controller/src/utils/native-assets.ts index 5cf251974aa..1565ce4cfa3 100644 --- a/packages/assets-controller/src/utils/native-assets.ts +++ b/packages/assets-controller/src/utils/native-assets.ts @@ -1,8 +1,9 @@ import { SPOT_PRICES_SUPPORT_INFO } from '@metamask/assets-controllers'; import { fetchWithErrorHandling } from '@metamask/controller-utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import { isCaipAssetType, parseCaipAssetType } from '@metamask/utils'; import type { Caip19AssetId, ChainId } from '../types.js'; +import { ZERO_ADDRESS } from './constants.js'; import { normalizeAssetId } from './normalizeAssetId.js'; const CHAINID_NETWORK_URL = 'https://chainid.network/chains.json'; @@ -29,6 +30,62 @@ export function buildNativeAssetsFromConstant(): Record< return nativeAssetsMap; } +/** + * Lowercase CAIP-19 IDs of every native asset in the hardcoded registry, + * built lazily on first use. Needed for natives that are not expressible + * through the `slip44`/zero-address conventions (e.g. METIS and MNT, which + * use a "dead" ERC-20 address). + */ +let knownNativeAssetIds: ReadonlySet | undefined; + +/** + * Get the lazily-built set of lowercase native asset IDs from the + * hardcoded registry. + * + * @returns The set of known native asset IDs. + */ +function getKnownNativeAssetIds(): ReadonlySet { + knownNativeAssetIds ??= new Set( + Object.values(buildNativeAssetsFromConstant()).map((assetId) => + assetId.toLowerCase(), + ), + ); + return knownNativeAssetIds; +} + +/** + * Whether a CAIP-19 asset ID represents a chain's native asset. Pure (no + * network, no controller state) counterpart of + * `AssetsController.#isNativeAsset`: recognizes the `slip44` asset + * namespace, the zero-address ERC-20 convention, and the hardcoded native + * asset registry. It does not consult the runtime native asset map fetched + * from chainid.network — but every entry that map adds beyond the hardcoded + * registry is `slip44`-shaped, so the first rule already covers them. + * + * @param assetId - The asset ID to check (any casing). Malformed IDs are + * reported as non-native rather than throwing. + * @returns True when the asset ID is a native asset. + */ +export function isNativeAssetId(assetId: string): boolean { + if (getKnownNativeAssetIds().has(assetId.toLowerCase())) { + return true; + } + + if (!isCaipAssetType(assetId)) { + return false; + } + const parsed = parseCaipAssetType(assetId); + + if (parsed.assetNamespace === 'slip44') { + return true; + } + + return ( + parsed.assetNamespace === 'erc20' && + parsed.assetReference.toLowerCase() === ZERO_ADDRESS + ); +} + /** * Fetches chain data from chainid.network and merges it with the seed * native asset map built from {@link buildNativeAssetsFromConstant}. From d05dff6b10a441865be943e94bc3a7d02305d1b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 18:16:48 +0000 Subject: [PATCH 04/14] Remove type assertions at cleanupUnusedMetadata call sites Co-authored-by: Prithpal Sooriya --- .../assets-controller/src/AssetsController.ts | 8 ++--- .../src/utils/cleanupUnusedMetadata.ts | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 217543f370b..22d683d5578 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1366,18 +1366,14 @@ export class AssetsController extends BaseController< this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); - this.update((state) => - cleanupUnusedMetadata(state as AssetsControllerStateInternal), - ); + this.update((state) => cleanupUnusedMetadata(state)); } catch (error) { log('Failed to fetch assets on startup', error); this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); - this.update((state) => - cleanupUnusedMetadata(state as AssetsControllerStateInternal), - ); + this.update((state) => cleanupUnusedMetadata(state)); } finally { releaseLock(); } diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts index 8b40c07dd72..73592caa9ea 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -1,7 +1,27 @@ import { DEFAULT_TRACKED_ASSETS_BY_CHAIN } from '../defaults.js'; -import type { AssetsControllerStateInternal, Caip19AssetId } from '../types.js'; import { isNativeAssetId } from './native-assets.js'; +/** + * The state slices {@link cleanupUnusedMetadata} operates on. The cleanup + * only ever inspects keys (asset IDs) — values are never read — so value + * types are `unknown`. This makes the parameter satisfiable both by + * `AssetsControllerState` and by the immer draft passed to + * `BaseController.update` (whose `WritableDraft`-wrapped values are not + * assignable to their base types), so no type assertion is needed at call + * sites. Omitting `assetPreferences` also guarantees at the type level that + * the cleanup cannot touch it. + */ +export type CleanupUnusedMetadataState = { + /** Shared metadata for all assets, keyed by CAIP-19 asset ID. */ + assetsInfo: Record; + /** Per-account balances, keyed by account ID then CAIP-19 asset ID. */ + assetsBalance: Record>; + /** Price data for assets, keyed by CAIP-19 asset ID. */ + assetsPrice: Record; + /** Custom assets added by users per account (CAIP-19 asset IDs). */ + customAssets: Record; +}; + /** Lowercase CAIP-19 IDs of every default tracked asset, across all chains. */ const DEFAULT_TRACKED_ASSET_IDS: ReadonlySet = new Set( [...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values()].flatMap((assetIds) => @@ -33,9 +53,7 @@ const DEFAULT_TRACKED_ASSET_IDS: ReadonlySet = new Set( * * @param state - The controller state to clean up (mutated in place). */ -export function cleanupUnusedMetadata( - state: AssetsControllerStateInternal, -): void { +export function cleanupUnusedMetadata(state: CleanupUnusedMetadataState): void { const referencedAssetIds = new Set(); for (const accountBalances of Object.values(state.assetsBalance)) { for (const assetId of Object.keys(accountBalances)) { @@ -55,12 +73,12 @@ export function cleanupUnusedMetadata( for (const assetId of Object.keys(state.assetsInfo)) { if (isUnused(assetId)) { - delete state.assetsInfo[assetId as Caip19AssetId]; + delete state.assetsInfo[assetId]; } } for (const assetId of Object.keys(state.assetsPrice)) { if (isUnused(assetId)) { - delete state.assetsPrice[assetId as Caip19AssetId]; + delete state.assetsPrice[assetId]; } } } From 2dbf92639d8fa344c483e383775c4f1bdcb70398 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 18:18:38 +0000 Subject: [PATCH 05/14] Only run unused-metadata cleanup after a successful startup refresh Co-authored-by: Prithpal Sooriya --- packages/assets-controller/src/AssetsController.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 22d683d5578..09d8d00b3c9 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1366,6 +1366,9 @@ export class AssetsController extends BaseController< this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); + // Only prune after a successful refresh: on failure the balance + // picture may be incomplete, so pruning against it could delete + // metadata for assets that are still held. this.update((state) => cleanupUnusedMetadata(state)); } catch (error) { log('Failed to fetch assets on startup', error); @@ -1373,7 +1376,6 @@ export class AssetsController extends BaseController< this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); - this.update((state) => cleanupUnusedMetadata(state)); } finally { releaseLock(); } From 5029578be6f8765ff4bce38b91045f25bedf3112 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 19:49:54 +0000 Subject: [PATCH 06/14] Condense cleanupUnusedMetadata: trim comments, dedupe types, use flat/flatMap Co-authored-by: Prithpal Sooriya --- .../src/utils/cleanupUnusedMetadata.ts | 94 ++++++------------- 1 file changed, 28 insertions(+), 66 deletions(-) diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts index 73592caa9ea..7fa43b7cad0 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -1,84 +1,46 @@ import { DEFAULT_TRACKED_ASSETS_BY_CHAIN } from '../defaults.js'; +import type { AssetsControllerStateInternal } from '../types.js'; import { isNativeAssetId } from './native-assets.js'; +type AssetIdKeyedRecord = Record; + /** - * The state slices {@link cleanupUnusedMetadata} operates on. The cleanup - * only ever inspects keys (asset IDs) — values are never read — so value - * types are `unknown`. This makes the parameter satisfiable both by - * `AssetsControllerState` and by the immer draft passed to - * `BaseController.update` (whose `WritableDraft`-wrapped values are not - * assignable to their base types), so no type assertion is needed at call - * sites. Omitting `assetPreferences` also guarantees at the type level that - * the cleanup cannot touch it. + * The state slices {@link cleanupUnusedMetadata} operates on. Only keys are + * inspected, so slice values are `unknown` — this keeps both + * `AssetsControllerState` and the immer draft passed to + * `BaseController.update` assignable without type assertions. */ export type CleanupUnusedMetadataState = { - /** Shared metadata for all assets, keyed by CAIP-19 asset ID. */ - assetsInfo: Record; - /** Per-account balances, keyed by account ID then CAIP-19 asset ID. */ - assetsBalance: Record>; - /** Price data for assets, keyed by CAIP-19 asset ID. */ - assetsPrice: Record; - /** Custom assets added by users per account (CAIP-19 asset IDs). */ - customAssets: Record; + assetsInfo: AssetIdKeyedRecord; + assetsBalance: Record; + assetsPrice: AssetIdKeyedRecord; + customAssets: AssetsControllerStateInternal['customAssets']; }; -/** Lowercase CAIP-19 IDs of every default tracked asset, across all chains. */ -const DEFAULT_TRACKED_ASSET_IDS: ReadonlySet = new Set( - [...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values()].flatMap((assetIds) => - assetIds.map((assetId) => assetId.toLowerCase()), - ), -); - /** - * Delete `assetsInfo` and `assetsPrice` entries for assets that nothing in - * state references anymore, so those slices do not grow unbounded (neither - * has any other delete path). - * - * An asset is considered referenced — and its entries kept — when its ID is - * any of the following (all comparisons are case-insensitive, since state - * keys are checksummed while some sources emit lowercase IDs): - * - * - a key in `assetsBalance[accountId]` for ANY account in state (key - * presence is what counts: `addCustomAsset` and the native/default-asset - * seeders write `{ amount: '0' }` entries for held assets); - * - listed in `customAssets` for any account; - * - a native asset ID (see {@link isNativeAssetId}); - * - a default tracked asset ({@link DEFAULT_TRACKED_ASSETS_BY_CHAIN}) — - * these often have metadata but no balance (e.g. mUSD on Monad is - * pre-seeded into `assetsInfo` while its zero balance is only written - * once the chain is enabled), and deleting them would be permanent. - * - * `assetPreferences` is deliberately left untouched: a user who hid an - * asset should find it still hidden if it ever comes back. + * Delete `assetsInfo` / `assetsPrice` entries whose asset ID (compared + * case-insensitively) is not held by any account, not a custom asset, not a + * default tracked asset, and not a native asset. `assetPreferences` is + * deliberately left untouched. * * @param state - The controller state to clean up (mutated in place). */ export function cleanupUnusedMetadata(state: CleanupUnusedMetadataState): void { - const referencedAssetIds = new Set(); - for (const accountBalances of Object.values(state.assetsBalance)) { - for (const assetId of Object.keys(accountBalances)) { - referencedAssetIds.add(assetId.toLowerCase()); - } - } - for (const accountCustomAssets of Object.values(state.customAssets)) { - for (const assetId of accountCustomAssets) { - referencedAssetIds.add(assetId.toLowerCase()); - } - } - + const keptAssetIds = new Set( + [ + ...[...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values()].flat(), + ...Object.values(state.assetsBalance).flatMap((accountBalances) => + Object.keys(accountBalances), + ), + ...Object.values(state.customAssets).flat(), + ].map((assetId) => assetId.toLowerCase()), + ); const isUnused = (assetId: string): boolean => - !referencedAssetIds.has(assetId.toLowerCase()) && - !DEFAULT_TRACKED_ASSET_IDS.has(assetId.toLowerCase()) && - !isNativeAssetId(assetId); + !keptAssetIds.has(assetId.toLowerCase()) && !isNativeAssetId(assetId); - for (const assetId of Object.keys(state.assetsInfo)) { - if (isUnused(assetId)) { - delete state.assetsInfo[assetId]; - } - } - for (const assetId of Object.keys(state.assetsPrice)) { - if (isUnused(assetId)) { - delete state.assetsPrice[assetId]; + for (const slice of [state.assetsInfo, state.assetsPrice]) { + for (const assetId of Object.keys(slice).filter(isUnused)) { + delete slice[assetId]; } } } From 684370c96cc0a7c1bf4683724f8a5f89a68b7066 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 19:51:41 +0000 Subject: [PATCH 07/14] Convert isNativeAssetId tests to a parameterized table Co-authored-by: Prithpal Sooriya --- .../src/utils/native-assets.test.ts | 85 ++++++++++--------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/packages/assets-controller/src/utils/native-assets.test.ts b/packages/assets-controller/src/utils/native-assets.test.ts index 38a4f0af124..44eb3a8011d 100644 --- a/packages/assets-controller/src/utils/native-assets.test.ts +++ b/packages/assets-controller/src/utils/native-assets.test.ts @@ -27,49 +27,50 @@ describe('buildNativeAssetsFromConstant', () => { }); describe('isNativeAssetId', () => { - it('recognizes slip44 natives, including chains outside the hardcoded registry', () => { - expect(isNativeAssetId('eip155:1/slip44:60')).toBe(true); - expect( - isNativeAssetId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'), - ).toBe(true); - }); - - it('recognizes the zero-address ERC-20 convention on any chain', () => { - expect( - isNativeAssetId( - 'eip155:424242/erc20:0x0000000000000000000000000000000000000000', - ), - ).toBe(true); - }); - - it('recognizes registry-only natives case-insensitively (e.g. METIS dead address)', () => { - expect( - isNativeAssetId( - 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', - ), - ).toBe(true); - expect( - isNativeAssetId( - 'eip155:1088/erc20:0xDEADDEADDEADDEADDEADDEADDEADDEADDEAD0000', - ), - ).toBe(true); - }); - - it('reports regular ERC-20 tokens as non-native', () => { - expect( - isNativeAssetId( - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - ), - ).toBe(false); - }); - - it('reports malformed and NFT asset IDs as non-native without throwing', () => { - expect(isNativeAssetId('not-a-caip-id')).toBe(false); - expect( - isNativeAssetId( + it.each([ + { + description: 'a slip44 native on a chain in the hardcoded registry', + assetId: 'eip155:1/slip44:60', + expected: true, + }, + { + description: 'a slip44 native on a chain outside the hardcoded registry', + assetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + expected: true, + }, + { + description: 'a zero-address ERC-20 native on an unregistered chain', + assetId: 'eip155:424242/erc20:0x0000000000000000000000000000000000000000', + expected: true, + }, + { + description: 'a registry-only native (METIS dead address)', + assetId: 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', + expected: true, + }, + { + description: 'a registry-only native in a different casing', + assetId: 'eip155:1088/erc20:0xDEADDEADDEADDEADDEADDEADDEADDEADDEAD0000', + expected: true, + }, + { + description: 'a regular ERC-20 token', + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + expected: false, + }, + { + description: 'a malformed asset ID', + assetId: 'not-a-caip-id', + expected: false, + }, + { + description: 'an NFT asset ID with a token ID', + assetId: 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234', - ), - ).toBe(false); + expected: false, + }, + ])('returns $expected for $description', ({ assetId, expected }) => { + expect(isNativeAssetId(assetId)).toBe(expected); }); }); From 06154e585ec958de4e8c5ca00c7f5b5192e102c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 19:53:26 +0000 Subject: [PATCH 08/14] Shorten changelog entry for unused metadata cleanup Co-authored-by: Prithpal Sooriya --- packages/assets-controller/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 2e73f157be2..e8c3ed9ee8d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -19,8 +19,7 @@ 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)) -- Clean up unused `assetsInfo` and `assetsPrice` entries on startup so those persisted state slices no longer grow unbounded (neither had a delete path before) ([#9806](https://github.com/MetaMask/core/pull/9806)) - - At the end of the startup refresh, entries are deleted for assets that are not held by any account in state (a balance key for any account counts, including `{ amount: '0' }`), not in any account's `customAssets`, not native assets, and not default tracked assets (`DEFAULT_TRACKED_ASSETS_BY_CHAIN`). Asset IDs are compared case-insensitively, and `assetPreferences` is left untouched. +- Clean up unused `assetsInfo` and `assetsPrice` entries after a successful startup refresh so those persisted state slices no longer grow unbounded ([#9806](https://github.com/MetaMask/core/pull/9806)) ## [13.1.1] From f6967a477535c6fb5b703183c9091593375a67c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 19:56:34 +0000 Subject: [PATCH 09/14] Trim isNativeAssetId comments and drop lazy-init helper Co-authored-by: Prithpal Sooriya --- .../src/utils/native-assets.ts | 55 +++++-------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/packages/assets-controller/src/utils/native-assets.ts b/packages/assets-controller/src/utils/native-assets.ts index 1565ce4cfa3..47347e78a4f 100644 --- a/packages/assets-controller/src/utils/native-assets.ts +++ b/packages/assets-controller/src/utils/native-assets.ts @@ -30,59 +30,34 @@ export function buildNativeAssetsFromConstant(): Record< return nativeAssetsMap; } -/** - * Lowercase CAIP-19 IDs of every native asset in the hardcoded registry, - * built lazily on first use. Needed for natives that are not expressible - * through the `slip44`/zero-address conventions (e.g. METIS and MNT, which - * use a "dead" ERC-20 address). - */ -let knownNativeAssetIds: ReadonlySet | undefined; - -/** - * Get the lazily-built set of lowercase native asset IDs from the - * hardcoded registry. - * - * @returns The set of known native asset IDs. - */ -function getKnownNativeAssetIds(): ReadonlySet { - knownNativeAssetIds ??= new Set( - Object.values(buildNativeAssetsFromConstant()).map((assetId) => - assetId.toLowerCase(), - ), - ); - return knownNativeAssetIds; -} +const KNOWN_NATIVE_ASSET_IDS: ReadonlySet = new Set( + Object.values(buildNativeAssetsFromConstant()).map((assetId) => + assetId.toLowerCase(), + ), +); /** - * Whether a CAIP-19 asset ID represents a chain's native asset. Pure (no - * network, no controller state) counterpart of - * `AssetsController.#isNativeAsset`: recognizes the `slip44` asset - * namespace, the zero-address ERC-20 convention, and the hardcoded native - * asset registry. It does not consult the runtime native asset map fetched - * from chainid.network — but every entry that map adds beyond the hardcoded - * registry is `slip44`-shaped, so the first rule already covers them. + * Whether a CAIP-19 asset ID represents a chain's native asset — via the + * `slip44` namespace, the zero-address ERC-20 convention, or the hardcoded + * native asset registry (non-standard natives like METIS/MNT). * - * @param assetId - The asset ID to check (any casing). Malformed IDs are - * reported as non-native rather than throwing. + * @param assetId - The asset ID to check (any casing); malformed IDs are + * reported as non-native. * @returns True when the asset ID is a native asset. */ export function isNativeAssetId(assetId: string): boolean { - if (getKnownNativeAssetIds().has(assetId.toLowerCase())) { + if (KNOWN_NATIVE_ASSET_IDS.has(assetId.toLowerCase())) { return true; } - if (!isCaipAssetType(assetId)) { return false; } - const parsed = parseCaipAssetType(assetId); - - if (parsed.assetNamespace === 'slip44') { - return true; - } + const { assetNamespace, assetReference } = parseCaipAssetType(assetId); return ( - parsed.assetNamespace === 'erc20' && - parsed.assetReference.toLowerCase() === ZERO_ADDRESS + assetNamespace === 'slip44' || + (assetNamespace === 'erc20' && + assetReference.toLowerCase() === ZERO_ADDRESS) ); } From 06bda7fc4f7fc96d15bce63713760430dfed18fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 20:00:36 +0000 Subject: [PATCH 10/14] Convert cleanupUnusedMetadata tests to a parameterized table Co-authored-by: Prithpal Sooriya --- .../src/utils/cleanupUnusedMetadata.test.ts | 379 +++++++----------- 1 file changed, 152 insertions(+), 227 deletions(-) diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts index 9f59c547b78..46185dfd264 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.test.ts @@ -1,74 +1,43 @@ import type { AssetsControllerState } from '../AssetsController.js'; -import type { AssetMetadata, AssetPrice } from '../types.js'; +import type { AssetMetadata, AssetPrice, Caip19AssetId } from '../types.js'; import { cleanupUnusedMetadata } from './cleanupUnusedMetadata.js'; const SELECTED_ACCOUNT = 'account-1'; const OTHER_ACCOUNT = 'account-2'; -/** USDC on mainnet — held (non-zero balance) in most tests. */ +/** USDC on mainnet (checksummed). */ const HELD_ASSET = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; - -/** DAI on mainnet — never referenced by any state slice. */ +/** DAI on mainnet. */ const UNREFERENCED_ASSET = 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; - -/** USDT on mainnet — only referenced through a zero-amount balance entry. */ +/** USDT on mainnet. */ const ZERO_BALANCE_ASSET = 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7'; - -/** cbETH on Base — only referenced through `customAssets`. */ +/** cbETH on Base. */ const CUSTOM_ASSET = 'eip155:8453/erc20:0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22'; - -/** ETH on mainnet — native via the `slip44` asset namespace. */ +/** ETH on mainnet. */ const NATIVE_SLIP44_ASSET = 'eip155:1/slip44:60'; - -/** - * SOL on Solana — a `slip44` native on a chain that is not in the hardcoded - * native asset registry (which only covers EVM chains). - */ +/** SOL — slip44 native on a chain outside the hardcoded (EVM-only) registry. */ const NATIVE_SOLANA_ASSET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'; - -/** - * Native asset on a made-up chain represented with the zero-address ERC-20 - * convention (like Gnosis xDAI); not present in any native asset registry. - */ +/** Zero-address ERC-20 native convention on a chain with no registry entry. */ const NATIVE_ZERO_ADDRESS_ASSET = 'eip155:424242/erc20:0x0000000000000000000000000000000000000000'; - -/** - * METIS on Metis Andromeda — native represented as a non-zero "dead" ERC-20 - * address; only recognizable through the native asset registry constant. - */ +/** METIS — native only recognizable through the hardcoded registry. */ const NATIVE_REGISTRY_ASSET = 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000'; - -/** - * mUSD on Monad — a default tracked asset - * (`DEFAULT_TRACKED_ASSETS_BY_CHAIN`). Its metadata is pre-seeded into - * `assetsInfo` by `getDefaultAssetsControllerState`, but no balance entry - * exists until the user enables the Monad network. - */ +/** mUSD on Monad — default tracked, has metadata but no balance until the chain is enabled. */ const MUSD_ON_MONAD_ASSET = 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; +/** BAYC #1234 — NFT asset IDs carry a tokenId suffix. */ +const NFT_ASSET = + 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234'; -/** - * Build metadata for a fungible test asset. - * - * @param symbol - Token symbol. - * @returns Minimal ERC-20 metadata. - */ function buildMetadata(symbol: string): AssetMetadata { return { type: 'erc20', symbol, name: symbol, decimals: 18 }; } -/** - * Build a price entry for a fungible test asset. - * - * @param value - Price in the selected currency and USD. - * @returns Minimal fungible price data. - */ function buildPrice(value: number): AssetPrice { return { assetPriceType: 'fungible', @@ -78,12 +47,6 @@ function buildPrice(value: number): AssetPrice { }; } -/** - * Build a plain `AssetsControllerState` object for tests. - * - * @param overrides - State slices to override. - * @returns A complete state object. - */ function buildState( overrides: Partial = {}, ): AssetsControllerState { @@ -98,8 +61,145 @@ function buildState( }; } +type CleanupCase = { + description: string; + assetId: string; + /** State slices referencing the asset (none means unreferenced). */ + references?: Partial; + expectKept: boolean; +}; + +const cleanupCases: CleanupCase[] = [ + { + description: 'removes an asset that nothing references', + assetId: UNREFERENCED_ASSET, + expectKept: false, + }, + { + description: 'removes a malformed asset ID that nothing references', + assetId: 'not-a-caip-id', + expectKept: false, + }, + { + description: 'removes an unreferenced NFT asset ID', + assetId: NFT_ASSET, + expectKept: false, + }, + { + description: 'keeps an asset with a non-zero balance', + assetId: HELD_ASSET, + references: { + assetsBalance: { [SELECTED_ACCOUNT]: { [HELD_ASSET]: { amount: '5' } } }, + }, + expectKept: true, + }, + { + description: 'keeps an asset whose only balance entry is a zero amount', + assetId: ZERO_BALANCE_ASSET, + references: { + assetsBalance: { + [SELECTED_ACCOUNT]: { [ZERO_BALANCE_ASSET]: { amount: '0' } }, + }, + }, + expectKept: true, + }, + { + description: 'keeps an asset held only by a non-selected account', + assetId: HELD_ASSET, + references: { + assetsBalance: { + [SELECTED_ACCOUNT]: {}, + [OTHER_ACCOUNT]: { [HELD_ASSET]: { amount: '42' } }, + }, + }, + expectKept: true, + }, + { + description: 'keeps an asset that is only referenced by customAssets', + assetId: CUSTOM_ASSET, + references: { customAssets: { [SELECTED_ACCOUNT]: [CUSTOM_ASSET] } }, + expectKept: true, + }, + { + description: 'keeps an asset whose balance key differs in casing', + assetId: HELD_ASSET, + references: { + assetsBalance: { + [SELECTED_ACCOUNT]: { [HELD_ASSET.toLowerCase()]: { amount: '1' } }, + }, + }, + expectKept: true, + }, + { + description: 'keeps an asset whose customAssets entry differs in casing', + assetId: CUSTOM_ASSET, + references: { + customAssets: { + [SELECTED_ACCOUNT]: [CUSTOM_ASSET.toLowerCase() as Caip19AssetId], + }, + }, + expectKept: true, + }, + { + description: 'keeps a malformed asset ID that a balance entry references', + assetId: 'not-a-caip-id', + references: { + assetsBalance: { + [SELECTED_ACCOUNT]: { 'not-a-caip-id': { amount: '1' } }, + }, + }, + expectKept: true, + }, + { + description: 'keeps a slip44 native asset', + assetId: NATIVE_SLIP44_ASSET, + expectKept: true, + }, + { + description: 'keeps a slip44 native on a chain outside the native registry', + assetId: NATIVE_SOLANA_ASSET, + expectKept: true, + }, + { + description: 'keeps a zero-address ERC-20 native', + assetId: NATIVE_ZERO_ADDRESS_ASSET, + expectKept: true, + }, + { + description: 'keeps a registry-only native (METIS dead address)', + assetId: NATIVE_REGISTRY_ASSET, + expectKept: true, + }, + { + description: + 'keeps a default tracked asset with no balance (mUSD on a disabled chain)', + assetId: MUSD_ON_MONAD_ASSET, + expectKept: true, + }, +]; + describe('cleanupUnusedMetadata', () => { - it('removes assetsInfo and assetsPrice entries that nothing references', () => { + it.each(cleanupCases)( + '$description', + ({ assetId, references = {}, expectKept }) => { + const state = buildState({ + assetsInfo: { [assetId]: buildMetadata('TEST') }, + assetsPrice: { [assetId]: buildPrice(1) }, + ...references, + }); + + cleanupUnusedMetadata(state); + + expect(state.assetsInfo).toStrictEqual( + expectKept ? { [assetId]: buildMetadata('TEST') } : {}, + ); + expect(state.assetsPrice).toStrictEqual( + expectKept ? { [assetId]: buildPrice(1) } : {}, + ); + }, + ); + + it('removes only unreferenced entries, leaving referenced ones in place', () => { const state = buildState({ assetsInfo: { [HELD_ASSET]: buildMetadata('USDC'), @@ -119,9 +219,7 @@ describe('cleanupUnusedMetadata', () => { expect(state.assetsInfo).toStrictEqual({ [HELD_ASSET]: buildMetadata('USDC'), }); - expect(state.assetsPrice).toStrictEqual({ - [HELD_ASSET]: buildPrice(1), - }); + expect(state.assetsPrice).toStrictEqual({ [HELD_ASSET]: buildPrice(1) }); }); it('removes an unreferenced price entry even when the asset has no assetsInfo entry', () => { @@ -134,147 +232,6 @@ describe('cleanupUnusedMetadata', () => { expect(state.assetsPrice).toStrictEqual({}); }); - it('keeps an asset whose only balance entry is a zero amount', () => { - const state = buildState({ - assetsInfo: { [ZERO_BALANCE_ASSET]: buildMetadata('USDT') }, - assetsPrice: { [ZERO_BALANCE_ASSET]: buildPrice(1) }, - assetsBalance: { - [SELECTED_ACCOUNT]: { [ZERO_BALANCE_ASSET]: { amount: '0' } }, - }, - }); - - cleanupUnusedMetadata(state); - - expect(state.assetsInfo).toStrictEqual({ - [ZERO_BALANCE_ASSET]: buildMetadata('USDT'), - }); - expect(state.assetsPrice).toStrictEqual({ - [ZERO_BALANCE_ASSET]: buildPrice(1), - }); - }); - - it('keeps an asset that is only referenced by customAssets', () => { - const state = buildState({ - assetsInfo: { [CUSTOM_ASSET]: buildMetadata('cbETH') }, - assetsPrice: { [CUSTOM_ASSET]: buildPrice(2000) }, - customAssets: { [SELECTED_ACCOUNT]: [CUSTOM_ASSET] }, - }); - - cleanupUnusedMetadata(state); - - expect(state.assetsInfo).toStrictEqual({ - [CUSTOM_ASSET]: buildMetadata('cbETH'), - }); - expect(state.assetsPrice).toStrictEqual({ - [CUSTOM_ASSET]: buildPrice(2000), - }); - }); - - it('keeps native assets even when no account has a balance entry for them', () => { - const state = buildState({ - assetsInfo: { - [NATIVE_SLIP44_ASSET]: buildMetadata('ETH'), - [NATIVE_SOLANA_ASSET]: buildMetadata('SOL'), - [NATIVE_ZERO_ADDRESS_ASSET]: buildMetadata('XDAI'), - [NATIVE_REGISTRY_ASSET]: buildMetadata('METIS'), - }, - assetsPrice: { - [NATIVE_SLIP44_ASSET]: buildPrice(3000), - [NATIVE_SOLANA_ASSET]: buildPrice(150), - [NATIVE_ZERO_ADDRESS_ASSET]: buildPrice(1), - [NATIVE_REGISTRY_ASSET]: buildPrice(30), - }, - }); - - cleanupUnusedMetadata(state); - - expect(Object.keys(state.assetsInfo)).toStrictEqual([ - NATIVE_SLIP44_ASSET, - NATIVE_SOLANA_ASSET, - NATIVE_ZERO_ADDRESS_ASSET, - NATIVE_REGISTRY_ASSET, - ]); - expect(Object.keys(state.assetsPrice)).toStrictEqual([ - NATIVE_SLIP44_ASSET, - NATIVE_SOLANA_ASSET, - NATIVE_ZERO_ADDRESS_ASSET, - NATIVE_REGISTRY_ASSET, - ]); - }); - - it('keeps default tracked assets with no balance entry (mUSD on a chain that is not enabled)', () => { - // Mirrors real startup state: `getDefaultAssetsControllerState` - // pre-seeds mUSD metadata for Monad, but no balance is seeded because - // `#ensureDefaultTrackedAssetsSeeded` only covers enabled chains. - const state = buildState({ - assetsInfo: { [MUSD_ON_MONAD_ASSET]: buildMetadata('mUSD') }, - assetsPrice: { [MUSD_ON_MONAD_ASSET]: buildPrice(1) }, - }); - - cleanupUnusedMetadata(state); - - expect(state.assetsInfo).toStrictEqual({ - [MUSD_ON_MONAD_ASSET]: buildMetadata('mUSD'), - }); - expect(state.assetsPrice).toStrictEqual({ - [MUSD_ON_MONAD_ASSET]: buildPrice(1), - }); - }); - - it('keeps an asset held only by a non-selected account', () => { - const state = buildState({ - assetsInfo: { [HELD_ASSET]: buildMetadata('USDC') }, - assetsPrice: { [HELD_ASSET]: buildPrice(1) }, - assetsBalance: { - [SELECTED_ACCOUNT]: {}, - [OTHER_ACCOUNT]: { [HELD_ASSET]: { amount: '42' } }, - }, - }); - - cleanupUnusedMetadata(state); - - expect(state.assetsInfo).toStrictEqual({ - [HELD_ASSET]: buildMetadata('USDC'), - }); - expect(state.assetsPrice).toStrictEqual({ - [HELD_ASSET]: buildPrice(1), - }); - }); - - it('compares asset IDs case-insensitively', () => { - // Metadata/price keys are checksummed while the references are - // lowercase — entries must still be recognized as referenced. - const state = buildState({ - assetsInfo: { - [HELD_ASSET]: buildMetadata('USDC'), - [CUSTOM_ASSET]: buildMetadata('cbETH'), - }, - assetsPrice: { - [HELD_ASSET]: buildPrice(1), - [CUSTOM_ASSET]: buildPrice(2000), - }, - assetsBalance: { - [SELECTED_ACCOUNT]: { [HELD_ASSET.toLowerCase()]: { amount: '1' } }, - }, - customAssets: { - [SELECTED_ACCOUNT]: [ - CUSTOM_ASSET.toLowerCase() as `${string}:${string}/${string}:${string}`, - ], - }, - }); - - cleanupUnusedMetadata(state); - - expect(Object.keys(state.assetsInfo)).toStrictEqual([ - HELD_ASSET, - CUSTOM_ASSET, - ]); - expect(Object.keys(state.assetsPrice)).toStrictEqual([ - HELD_ASSET, - CUSTOM_ASSET, - ]); - }); - it('leaves assetPreferences untouched, including entries for removed assets', () => { const state = buildState({ assetsInfo: { [UNREFERENCED_ASSET]: buildMetadata('DAI') }, @@ -295,38 +252,6 @@ describe('cleanupUnusedMetadata', () => { }); }); - it('removes malformed and NFT asset IDs without throwing when unreferenced', () => { - const nftAssetId = - 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234'; - const state = buildState({ - assetsInfo: { - 'not-a-caip-id': buildMetadata('JUNK'), - [nftAssetId]: buildMetadata('BAYC'), - }, - assetsPrice: { 'not-a-caip-id': buildPrice(0) }, - }); - - expect(() => cleanupUnusedMetadata(state)).not.toThrow(); - - expect(state.assetsInfo).toStrictEqual({}); - expect(state.assetsPrice).toStrictEqual({}); - }); - - it('keeps a malformed asset ID that is still referenced by a balance entry', () => { - const state = buildState({ - assetsInfo: { 'not-a-caip-id': buildMetadata('JUNK') }, - assetsBalance: { - [SELECTED_ACCOUNT]: { 'not-a-caip-id': { amount: '1' } }, - }, - }); - - cleanupUnusedMetadata(state); - - expect(state.assetsInfo).toStrictEqual({ - 'not-a-caip-id': buildMetadata('JUNK'), - }); - }); - it('does nothing on empty state', () => { const state = buildState(); From 26d01e4d45c24e11228a197ea5f8ee8045436dc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 20:01:43 +0000 Subject: [PATCH 11/14] Remove comment above startup cleanup call Co-authored-by: Prithpal Sooriya --- packages/assets-controller/src/AssetsController.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 09d8d00b3c9..d89f678e5b9 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1366,9 +1366,6 @@ export class AssetsController extends BaseController< this.#ensureDefaultTrackedAssetsSeeded(); this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); - // Only prune after a successful refresh: on failure the balance - // picture may be incomplete, so pruning against it could delete - // metadata for assets that are still held. this.update((state) => cleanupUnusedMetadata(state)); } catch (error) { log('Failed to fetch assets on startup', error); From e883fffaf5772bf5d6506d20bd74d91fe396a28a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 09:58:09 +0000 Subject: [PATCH 12/14] Remove CleanupUnusedMetadataState doc comment Co-authored-by: Prithpal Sooriya --- .../assets-controller/src/utils/cleanupUnusedMetadata.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts index 7fa43b7cad0..fa40506a297 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -4,12 +4,6 @@ import { isNativeAssetId } from './native-assets.js'; type AssetIdKeyedRecord = Record; -/** - * The state slices {@link cleanupUnusedMetadata} operates on. Only keys are - * inspected, so slice values are `unknown` — this keeps both - * `AssetsControllerState` and the immer draft passed to - * `BaseController.update` assignable without type assertions. - */ export type CleanupUnusedMetadataState = { assetsInfo: AssetIdKeyedRecord; assetsBalance: Record; From 7976fd08c5f3d6c1fb298fc9229920f72b0f77de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 09:59:12 +0000 Subject: [PATCH 13/14] Shorten cleanupUnusedMetadata JSDoc Co-authored-by: Prithpal Sooriya --- .../assets-controller/src/utils/cleanupUnusedMetadata.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts index fa40506a297..98567c364af 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -12,10 +12,8 @@ export type CleanupUnusedMetadataState = { }; /** - * Delete `assetsInfo` / `assetsPrice` entries whose asset ID (compared - * case-insensitively) is not held by any account, not a custom asset, not a - * default tracked asset, and not a native asset. `assetPreferences` is - * deliberately left untouched. + * Delete `assetsInfo` / `assetsPrice` entries for assets that are not held, + * custom, default tracked, or native. * * @param state - The controller state to clean up (mutated in place). */ From ff39d6b96aa2fb12435a5e812c5310c7e90f3318 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 10:00:19 +0000 Subject: [PATCH 14/14] Extract kept-asset ID sources into named variables Co-authored-by: Prithpal Sooriya --- .../src/utils/cleanupUnusedMetadata.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts index 98567c364af..85fb2c9187e 100644 --- a/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts +++ b/packages/assets-controller/src/utils/cleanupUnusedMetadata.ts @@ -18,14 +18,18 @@ export type CleanupUnusedMetadataState = { * @param state - The controller state to clean up (mutated in place). */ export function cleanupUnusedMetadata(state: CleanupUnusedMetadataState): void { + const defaultTrackedAssetIds = [ + ...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values(), + ].flat(); + const heldAssetIds = Object.values(state.assetsBalance).flatMap( + (accountBalances) => Object.keys(accountBalances), + ); + const customAssetIds = Object.values(state.customAssets).flat(); + const keptAssetIds = new Set( - [ - ...[...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values()].flat(), - ...Object.values(state.assetsBalance).flatMap((accountBalances) => - Object.keys(accountBalances), - ), - ...Object.values(state.customAssets).flat(), - ].map((assetId) => assetId.toLowerCase()), + [...defaultTrackedAssetIds, ...heldAssetIds, ...customAssetIds].map( + (assetId) => assetId.toLowerCase(), + ), ); const isUnused = (assetId: string): boolean => !keptAssetIds.has(assetId.toLowerCase()) && !isNativeAssetId(assetId);