From c80a725cfa6836dd7136b22aa5e8f47f3eeb2f7e Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 6 Jul 2026 14:05:00 +0100 Subject: [PATCH 1/5] fix: add assets-controller temp state migration healing --- packages/assets-controller/CHANGELOG.md | 2 + .../src/AssetsController.test.ts | 120 ++++ .../assets-controller/src/AssetsController.ts | 21 + .../migrations/healAssetsInfoMetadata.test.ts | 582 ++++++++++++++++++ .../src/migrations/healAssetsInfoMetadata.ts | 582 ++++++++++++++++++ 5 files changed, 1307 insertions(+) create mode 100644 packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts create mode 100644 packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 9c7c1dfee12..787ab99439e 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fetch balances when switching account groups, enabling RPC-only networks, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) +- Add temporary `tempMigrateAssetsInfoMetadataAssets3346` constructor option that heals `assetsInfo` metadata (and custom-asset tracking) wiped by a prior defect for tokens on niche EVM chains, using legacy `TokensController` state provided by the host + ## [10.0.1] ### Changed diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 3222a8d2851..cdf3820c0f4 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -18,6 +18,7 @@ import type { AssetsControllerMessenger, AssetsControllerState, } from './AssetsController'; +import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; import { TokenDataSource } from './data-sources/TokenDataSource'; @@ -125,6 +126,8 @@ type WithControllerOptions = { trace: TraceCallback; priceDataSourceConfig: PriceDataSourceConfig; isEnabled: () => boolean; + captureException: (error: Error) => void; + tempMigrateAssetsInfoMetadataAssets3346: () => Assets3346MigrationState; }>; }; @@ -320,6 +323,123 @@ describe('AssetsController', () => { }); }); + describe('temporary assetsInfo metadata healing (tempMigrateAssetsInfoMetadataAssets3346)', () => { + const HEALED_ASSET_ID = + 'eip155:14/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const LEGACY_ACCOUNT_ADDRESS = + '0x1234567890123456789012345678901234567890'; + const legacyState: Assets3346MigrationState = { + TokensController: { + allTokens: { + // Flare (0xe / 14) is not covered by the Accounts API. + '0xe': { + [LEGACY_ACCOUNT_ADDRESS]: [ + { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + }, + ], + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [MOCK_ACCOUNT_ID]: { address: LEGACY_ACCOUNT_ADDRESS }, + }, + }, + }, + }; + + it('heals wiped niche-chain token metadata from legacy state on construction', async () => { + await withController( + { + controllerOptions: { + tempMigrateAssetsInfoMetadataAssets3346: () => legacyState, + }, + }, + ({ controller }) => { + expect(controller.state.assetsInfo[HEALED_ASSET_ID]).toStrictEqual({ + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + }); + expect( + controller.state.customAssets[MOCK_ACCOUNT_ID], + ).toStrictEqual([HEALED_ASSET_ID]); + }, + ); + }); + + it('does not overwrite existing assetsInfo metadata', async () => { + const existingMetadata: FungibleAssetMetadata = { + type: 'erc20', + symbol: 'EXISTING', + name: 'Existing Token', + decimals: 6, + }; + + await withController( + { + state: { + assetsInfo: { [HEALED_ASSET_ID]: existingMetadata }, + }, + controllerOptions: { + tempMigrateAssetsInfoMetadataAssets3346: () => legacyState, + }, + }, + ({ controller }) => { + expect(controller.state.assetsInfo[HEALED_ASSET_ID]).toStrictEqual( + existingMetadata, + ); + }, + ); + }); + + it('leaves state untouched when the legacy state has nothing restorable', async () => { + await withController( + { + controllerOptions: { + tempMigrateAssetsInfoMetadataAssets3346: () => ({}), + }, + }, + ({ controller }) => { + expect(controller.state).toStrictEqual( + getDefaultAssetsControllerState(), + ); + }, + ); + }); + + it('reports getter errors via captureException without breaking construction', async () => { + const captureException = jest.fn(); + + await withController( + { + controllerOptions: { + captureException, + tempMigrateAssetsInfoMetadataAssets3346: () => { + throw new Error('legacy state unavailable'); + }, + }, + }, + ({ controller }) => { + expect(controller.state).toStrictEqual( + getDefaultAssetsControllerState(), + ); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('legacy state unavailable'), + }), + ); + }, + ); + }); + }); + it('initializes normally when isEnabled returns true', async () => { await withController(({ controller, messenger }) => { // Controller should have default state diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 9e99e94d7c1..6408a238fa9 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -100,6 +100,8 @@ import { } from './defaults'; import { AssetsDataSourceError } from './errors'; import { projectLogger, createModuleLogger } from './logger'; +import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; +import { tempHealAssetsInfoMetadata } from './migrations/healAssetsInfoMetadata'; import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware'; import { DetectionMiddleware } from './middlewares/DetectionMiddleware'; import { @@ -435,6 +437,12 @@ export type AssetsControllerOptions = { * Defaults to () => true. */ isOnboarded?: () => boolean; + + /** + * TEMPORARY — will be removed in a future release. + * Issue: https://consensyssoftware.atlassian.net/browse/ASSETS-3346 + */ + tempMigrateAssetsInfoMetadataAssets3346?: () => Assets3346MigrationState; }; // ============================================================================ @@ -849,6 +857,7 @@ export class AssetsController extends BaseController< priceDataSourceConfig, stakedBalanceDataSourceConfig, isOnboarded, + tempMigrateAssetsInfoMetadataAssets3346, }: AssetsControllerOptions) { super({ name: CONTROLLER_NAME, @@ -868,6 +877,18 @@ export class AssetsController extends BaseController< this.#queryApiClient = queryApiClient; const rpcConfig = rpcDataSourceConfig ?? {}; + // TEMPORARY: heal assetsInfo metadata wiped by a prior defect + // (see extension migration #215 / ASSETS-3346). Remove in a future release. + if (tempMigrateAssetsInfoMetadataAssets3346) { + this.update( + tempHealAssetsInfoMetadata({ + state: this.state, + getMigrationState: tempMigrateAssetsInfoMetadataAssets3346, + captureException, + }), + ); + } + this.#initializeNativeAssetsMap(queryApiClient); this.#onActiveChainsUpdated = ( diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts new file mode 100644 index 00000000000..3e3d3adf1d4 --- /dev/null +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts @@ -0,0 +1,582 @@ +import type { CurrentAssetsState } from './healAssetsInfoMetadata'; +import { + healAssetsInfoMetadata, + tempHealAssetsInfoMetadata, +} from './healAssetsInfoMetadata'; +import type { Caip19AssetId } from '../types'; + +const ACCOUNT_ID = 'account-uuid-1'; +const ACCOUNT_ADDRESS = '0x1111111111111111111111111111111111111111'; + +// Flare (chainId 14 / 0xe) — a niche chain not covered by the Accounts API. +const FLARE_HEX_CHAIN_ID = '0xe'; +const TOKEN_ADDRESS_LOWER = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; +const TOKEN_ADDRESS_CHECKSUMMED = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +const FLARE_ASSET_ID = + `eip155:14/erc20:${TOKEN_ADDRESS_CHECKSUMMED}` as Caip19AssetId; + +/** + * Build an empty current AssetsController state, with optional overrides. + * + * @param overrides - Partial state slices to merge over the empty defaults. + * @returns The current state input for healAssetsInfoMetadata. + */ +function buildCurrentState( + overrides: Partial = {}, +): CurrentAssetsState { + return { + assetsInfo: {}, + assetsBalance: {}, + customAssets: {}, + assetPreferences: {}, + ...overrides, + }; +} + +/** + * Build a legacy state root containing a single token on the Flare chain + * owned by ACCOUNT_ADDRESS, with the AccountsController address-to-ID + * mapping in place. + * + * @param token - The raw token entry to place in allTokens. + * @returns The legacy state root. + */ +function buildLegacyState(token: Record): unknown { + return { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { + [ACCOUNT_ADDRESS]: [token], + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [ACCOUNT_ID]: { address: ACCOUNT_ADDRESS }, + }, + }, + }, + }; +} + +const VALID_TOKEN = { + address: TOKEN_ADDRESS_LOWER, + symbol: 'TST', + name: 'Test Token', + decimals: 18, +}; + +describe('healAssetsInfoMetadata', () => { + describe('guarding against invalid legacy state', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['a string', 'not-an-object'], + ['a number', 42], + ['an array', []], + ['an empty object', {}], + ['a non-object TokensController', { TokensController: 'nope' }], + ['a missing allTokens', { TokensController: {} }], + ['a non-object allTokens', { TokensController: { allTokens: [] } }], + ])('returns null when legacy state is %s', (_description, legacyState) => { + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it('skips chain entries that are not objects', () => { + const legacyState = { + TokensController: { + allTokens: { [FLARE_HEX_CHAIN_ID]: ['not-an-object'] }, + }, + }; + + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it('skips account entries that are not arrays', () => { + const legacyState = { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { [ACCOUNT_ADDRESS]: 'not-an-array' }, + }, + }, + }; + + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it.each([ + ['not an object', 'not-a-token'], + ['missing an address', { symbol: 'TST', decimals: 18 }], + ['a non-string address', { ...VALID_TOKEN, address: 123 }], + ['an invalid address', { ...VALID_TOKEN, address: '0x123' }], + ['a non-hex address', { ...VALID_TOKEN, address: `0x${'g'.repeat(40)}` }], + ['missing a symbol', { address: TOKEN_ADDRESS_LOWER, decimals: 18 }], + ['an empty-string symbol', { ...VALID_TOKEN, symbol: '' }], + ['a non-string symbol', { ...VALID_TOKEN, symbol: 7 }], + ])('skips tokens that are %s', (_description, token) => { + expect( + healAssetsInfoMetadata( + buildLegacyState(token as Record), + buildCurrentState(), + ), + ).toBeNull(); + }); + + it('skips chain keys that are not hex chain IDs', () => { + const legacyState = { + TokensController: { + allTokens: { + 'not-a-chain': { [ACCOUNT_ADDRESS]: [VALID_TOKEN] }, + }, + }, + }; + + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it('skips hex chain keys too large to represent as a number', () => { + const legacyState = { + TokensController: { + allTokens: { + [`0x${'f'.repeat(1000)}`]: { [ACCOUNT_ADDRESS]: [VALID_TOKEN] }, + }, + }, + }; + + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it('tolerates a malformed AccountsController and still heals assetsInfo', () => { + const legacyState = { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { [ACCOUNT_ADDRESS]: [VALID_TOKEN] }, + }, + }, + AccountsController: 'garbage', + }; + + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toStrictEqual({ + assetsInfo: { + [FLARE_ASSET_ID]: { + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + }, + }, + customAssets: {}, + }); + }); + }); + + describe('healing eligible tokens', () => { + it('restores metadata and custom-asset tracking for a niche-chain token', () => { + const patch = healAssetsInfoMetadata( + buildLegacyState({ + ...VALID_TOKEN, + image: 'https://example.com/tst.png', + aggregators: ['CoinGecko'], + }), + buildCurrentState(), + ); + + expect(patch).toStrictEqual({ + assetsInfo: { + [FLARE_ASSET_ID]: { + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + image: 'https://example.com/tst.png', + aggregators: ['CoinGecko'], + }, + }, + customAssets: { + [ACCOUNT_ID]: [FLARE_ASSET_ID], + }, + }); + }); + + it('checksums lowercase token addresses in the healed asset ID', () => { + const patch = healAssetsInfoMetadata( + buildLegacyState(VALID_TOKEN), + buildCurrentState(), + ); + + expect(Object.keys(patch?.assetsInfo ?? {})).toStrictEqual([ + FLARE_ASSET_ID, + ]); + }); + + it('falls back to the symbol when the name is missing, and 0 when decimals are invalid', () => { + const patch = healAssetsInfoMetadata( + buildLegacyState({ + address: TOKEN_ADDRESS_LOWER, + symbol: 'TST', + decimals: 'eighteen', + }), + buildCurrentState(), + ); + + expect(patch?.assetsInfo[FLARE_ASSET_ID]).toStrictEqual({ + type: 'erc20', + symbol: 'TST', + name: 'TST', + decimals: 0, + }); + }); + + it('omits empty images and filters non-string aggregators', () => { + const patch = healAssetsInfoMetadata( + buildLegacyState({ + ...VALID_TOKEN, + image: '', + aggregators: ['CoinGecko', 42, null], + }), + buildCurrentState(), + ); + + expect(patch?.assetsInfo[FLARE_ASSET_ID]).toStrictEqual({ + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + aggregators: ['CoinGecko'], + }); + }); + + it('matches the legacy account address case-insensitively', () => { + const legacyState = { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { + [ACCOUNT_ADDRESS.toUpperCase().replace('0X', '0x')]: [ + VALID_TOKEN, + ], + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { [ACCOUNT_ID]: { address: ACCOUNT_ADDRESS } }, + }, + }, + }; + + const patch = healAssetsInfoMetadata(legacyState, buildCurrentState()); + + expect(patch?.customAssets).toStrictEqual({ + [ACCOUNT_ID]: [FLARE_ASSET_ID], + }); + }); + + it('heals assetsInfo without custom assets when no account mapping exists', () => { + const legacyState = { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { [ACCOUNT_ADDRESS]: [VALID_TOKEN] }, + }, + }, + }; + + const patch = healAssetsInfoMetadata(legacyState, buildCurrentState()); + + expect(patch?.assetsInfo[FLARE_ASSET_ID]).toBeDefined(); + expect(patch?.customAssets).toStrictEqual({}); + }); + + it('dedupes duplicate token entries within the same account list', () => { + const patch = healAssetsInfoMetadata( + buildLegacyState(VALID_TOKEN), + buildCurrentState(), + ); + const patchWithDuplicates = healAssetsInfoMetadata( + { + ...(buildLegacyState(VALID_TOKEN) as Record), + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { + [ACCOUNT_ADDRESS]: [VALID_TOKEN, { ...VALID_TOKEN }], + }, + }, + }, + }, + buildCurrentState(), + ); + + expect(patchWithDuplicates).toStrictEqual(patch); + }); + + it('dedupes the same token across accounts (metadata once, tracking per account)', () => { + const otherAccountAddress = + '0x2222222222222222222222222222222222222222'; + const otherAccountId = 'account-uuid-2'; + const legacyState = { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { + [ACCOUNT_ADDRESS]: [VALID_TOKEN], + [otherAccountAddress]: [VALID_TOKEN], + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [ACCOUNT_ID]: { address: ACCOUNT_ADDRESS }, + [otherAccountId]: { address: otherAccountAddress }, + }, + }, + }, + }; + + const patch = healAssetsInfoMetadata(legacyState, buildCurrentState()); + + expect(Object.keys(patch?.assetsInfo ?? {})).toStrictEqual([ + FLARE_ASSET_ID, + ]); + expect(patch?.customAssets).toStrictEqual({ + [ACCOUNT_ID]: [FLARE_ASSET_ID], + [otherAccountId]: [FLARE_ASSET_ID], + }); + }); + }); + + describe('skipping tokens that must not be healed', () => { + it('skips chains supported by the Accounts API', () => { + const legacyState = { + TokensController: { + allTokens: { + '0x1': { [ACCOUNT_ADDRESS]: [VALID_TOKEN] }, + }, + }, + }; + + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it('skips ERC-721 tokens', () => { + expect( + healAssetsInfoMetadata( + buildLegacyState({ ...VALID_TOKEN, isERC721: true }), + buildCurrentState(), + ), + ).toBeNull(); + }); + + it('skips tokens ignored in the legacy allIgnoredTokens (case-insensitive)', () => { + const legacyState = { + TokensController: { + allTokens: { + [FLARE_HEX_CHAIN_ID]: { [ACCOUNT_ADDRESS]: [VALID_TOKEN] }, + }, + allIgnoredTokens: { + [FLARE_HEX_CHAIN_ID]: { + [ACCOUNT_ADDRESS.toUpperCase().replace('0X', '0x')]: [ + TOKEN_ADDRESS_CHECKSUMMED, + ], + }, + }, + }, + }; + + expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( + ); + }); + + it('skips tokens hidden via current assetPreferences (case-insensitive)', () => { + const currentState = buildCurrentState({ + assetPreferences: { + [FLARE_ASSET_ID.toLowerCase() as Caip19AssetId]: { hidden: true }, + }, + }); + + expect( + healAssetsInfoMetadata(buildLegacyState(VALID_TOKEN), currentState), + ).toBeNull(); + }); + + it('does not skip tokens whose preference exists but is not hidden', () => { + const currentState = buildCurrentState({ + assetPreferences: { [FLARE_ASSET_ID]: { hidden: false } }, + }); + + const patch = healAssetsInfoMetadata( + buildLegacyState(VALID_TOKEN), + currentState, + ); + + expect(patch?.assetsInfo[FLARE_ASSET_ID]).toBeDefined(); + }); + }); + + describe('idempotency against current state', () => { + it('never overwrites existing assetsInfo entries but still tracks the custom asset', () => { + const currentState = buildCurrentState({ + assetsInfo: { + [FLARE_ASSET_ID]: { + type: 'erc20', + symbol: 'EXISTING', + name: 'Existing Token', + decimals: 6, + }, + }, + }); + + const patch = healAssetsInfoMetadata( + buildLegacyState(VALID_TOKEN), + currentState, + ); + + expect(patch).toStrictEqual({ + assetsInfo: {}, + customAssets: { [ACCOUNT_ID]: [FLARE_ASSET_ID] }, + }); + }); + + it('does not track the custom asset when a balance entry already exists', () => { + const currentState = buildCurrentState({ + assetsBalance: { + [ACCOUNT_ID]: { [FLARE_ASSET_ID]: { amount: '1' } }, + }, + }); + + const patch = healAssetsInfoMetadata( + buildLegacyState(VALID_TOKEN), + currentState, + ); + + expect(patch?.customAssets).toStrictEqual({}); + }); + + it('does not track the custom asset when it is already in customAssets', () => { + const currentState = buildCurrentState({ + customAssets: { [ACCOUNT_ID]: [FLARE_ASSET_ID] }, + }); + + const patch = healAssetsInfoMetadata( + buildLegacyState(VALID_TOKEN), + currentState, + ); + + expect(patch?.customAssets).toStrictEqual({}); + }); + + it('returns null when everything is already healed', () => { + const currentState = buildCurrentState({ + assetsInfo: { + [FLARE_ASSET_ID]: { + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + }, + }, + customAssets: { [ACCOUNT_ID]: [FLARE_ASSET_ID] }, + }); + + expect( + healAssetsInfoMetadata(buildLegacyState(VALID_TOKEN), currentState), + ).toBeNull(); + }); + }); +}); + +describe('tempHealAssetsInfoMetadata', () => { + it('returns a state callback that applies the healing patch to the draft', () => { + const state = buildCurrentState(); + + const applyHealing = tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => buildLegacyState(VALID_TOKEN), + }); + applyHealing(state); + + expect(state.assetsInfo[FLARE_ASSET_ID]).toStrictEqual({ + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + }); + expect(state.customAssets[ACCOUNT_ID]).toStrictEqual([FLARE_ASSET_ID]); + }); + + it('leaves the draft untouched when there is nothing to heal', () => { + const state = buildCurrentState(); + + const applyHealing = tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => ({ unrelated: true }), + }); + applyHealing(state); + + expect(state).toStrictEqual(buildCurrentState()); + }); + + it('is idempotent: re-running never duplicates or overwrites entries', () => { + const otherAssetId = + 'eip155:14/erc20:0x0000000000000000000000000000000000000001' as Caip19AssetId; + const state = buildCurrentState({ + customAssets: { [ACCOUNT_ID]: [otherAssetId] }, + }); + const getMigrationState = (): unknown => buildLegacyState(VALID_TOKEN); + + tempHealAssetsInfoMetadata({ state, getMigrationState })(state); + const afterFirstRun = JSON.parse(JSON.stringify(state)); + tempHealAssetsInfoMetadata({ state, getMigrationState })(state); + + expect(state).toStrictEqual(afterFirstRun); + expect(state.customAssets[ACCOUNT_ID]).toStrictEqual([ + otherAssetId, + FLARE_ASSET_ID, + ]); + }); + + it('reports errors thrown by getMigrationState via captureException without throwing', () => { + const state = buildCurrentState(); + const captureException = jest.fn(); + + let applyHealing: ((state: unknown) => void) | undefined; + expect(() => { + applyHealing = tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => { + throw new Error('legacy state unavailable'); + }, + captureException, + }); + }).not.toThrow(); + + expect(() => applyHealing?.(state)).not.toThrow(); + expect(state).toStrictEqual(buildCurrentState()); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('legacy state unavailable'), + }), + ); + }); + + it('swallows errors even when captureException is not provided', () => { + const state = buildCurrentState(); + + expect(() => { + const applyHealing = tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => { + throw new Error('legacy state unavailable'); + }, + }); + applyHealing(state); + }).not.toThrow(); + }); +}); diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts new file mode 100644 index 00000000000..d93cf408632 --- /dev/null +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts @@ -0,0 +1,582 @@ +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { TokensControllerState } from '@metamask/assets-controllers'; +import type { Hex } from '@metamask/utils'; +import { + getChecksumAddress, + getErrorMessage, + hasProperty, + isObject, +} from '@metamask/utils'; + +import { createModuleLogger, projectLogger } from '../logger'; +import type { + AccountId, + AssetsControllerStateInternal, + Caip19AssetId, + FungibleAssetMetadata, +} from '../types'; + +/** + * TEMPORARY MODULE — remove in a future release. + * + * Port of extension migration #215 (Assets Controller Metadata Healing). + * Issue: https://consensyssoftware.atlassian.net/browse/ASSETS-3346 + * Incident: #incident-metamask-1731 + * + * Background: after a prior defect in AssetsController, metadata for custom + * tokens was wiped. Most popular chains support auto-detection and can + * self-heal, however not all. + * + * This module computes the state additions needed to restore metadata for + * custom tokens on niche EVM chains (chains that cannot auto-detect and + * self-heal), using the legacy `TokensController.allTokens` state as the + * source of truth. It is a pure function so it can be tested in isolation; + * the controller applies the returned patch in its constructor. + * + * The legacy state is treated as fully untrusted (`unknown`): every shape is + * validated before use, and any unexpected input results in that entry being + * skipped (or `null` being returned when nothing is restorable). + */ + +/** + * The slices of current `AssetsController` state the healing computation + * reads. Never mutated. + */ +export type CurrentAssetsState = Pick< + AssetsControllerStateInternal, + 'assetsInfo' | 'assetsBalance' | 'customAssets' | 'assetPreferences' +>; + +/** + * TEMPORARY — will be removed in a future release. + * + * Shape of the legacy persisted state the ASSETS-3346 healing reads, derived + * from the real `TokensControllerState` and `AccountsControllerState` types. + * Use it to type the `tempMigrateAssetsInfoMetadataAssets3346` getter when + * integrating in a client. + * + * Everything is optional and only the listed slices are read: + * + * - `TokensController.allTokens` — required for any healing to happen. + * - `TokensController.allIgnoredTokens` — tokens the user hid/removed + * (skipped during healing). + * - `AccountsController.internalAccounts.accounts` — maps addresses to + * account IDs so healed tokens are also tracked in `customAssets` + * (without it only `assetsInfo` is healed). + * + * At runtime the value still crosses the boundary as `unknown`: every shape + * is re-validated by the migration, so a partial or malformed object is safe + * and simply skipped. + */ +export type Assets3346MigrationState = { + // eslint-disable-next-line @typescript-eslint/naming-convention -- must match the persisted controller state key + TokensController?: Partial< + Pick + >; + // eslint-disable-next-line @typescript-eslint/naming-convention -- must match the persisted controller state key + AccountsController?: { + internalAccounts?: { + accounts?: Record< + string, + Pick< + AccountsControllerState['internalAccounts']['accounts'][string], + 'address' + > + >; + }; + }; +}; + +export type AssetsInfoHealingPatch = { + assetsInfo: Record; + customAssets: Record; +}; + +const log = createModuleLogger(projectLogger, 'tempHealAssetsInfoMetadata'); + +export type TempHealAssetsInfoMetadataOptions = { + /** Current `AssetsController` state the healing patch is computed against. */ + state: CurrentAssetsState; + /** + * Host-provided getter for the untrusted legacy state root (see + * `AssetsControllerOptions.tempMigrateAssetsInfoMetadataAssets3346`). + */ + getMigrationState: () => unknown; + /** Optional Sentry-compatible reporter for healing failures. */ + captureException?: (error: Error) => void; +}; + +/** + * TEMPORARY — will be removed in a future release. + * + * @param options - The options bag. + * @param options.state - Current controller state to compute the patch against. + * @param options.getMigrationState - Getter for the untrusted legacy state root. + * @param options.captureException - Optional reporter for healing failures. + * @returns The state callback to pass to the controller's `update` method. + */ +export function tempHealAssetsInfoMetadata({ + state, + getMigrationState, + captureException, +}: TempHealAssetsInfoMetadataOptions): (state: unknown) => void { + const reportError = (error: unknown): void => { + log('Failed to heal assetsInfo metadata', error); + captureException?.( + new Error( + `AssetsController: temporary assetsInfo metadata healing failed: ${getErrorMessage( + error, + )}`, + ), + ); + }; + + let patch: AssetsInfoHealingPatch | null = null; + try { + patch = healAssetsInfoMetadata(getMigrationState(), state); + } catch (error) { + reportError(error); + } + + return (draft) => { + if (!patch) { + return; + } + + try { + applyHealingPatch(draft as CurrentAssetsState, patch); + + log('Healed wiped assetsInfo metadata for niche-chain tokens', { + healedAssetsInfoCount: Object.keys(patch.assetsInfo).length, + healedCustomAssetsAccounts: Object.keys(patch.customAssets).length, + }); + } catch (error) { + reportError(error); + } + }; +} + +/** + * Apply a healing patch to (draft) controller state. Defensive against + * concurrent writes: fills `assetsInfo` gaps only and dedupes `customAssets` + * against the current draft rather than trusting the patch blindly. + * + * @param state - Mutable controller state (an Immer draft). + * @param patch - The additions computed by {@link healAssetsInfoMetadata}. + */ +function applyHealingPatch( + state: CurrentAssetsState, + patch: AssetsInfoHealingPatch, +): void { + for (const [assetId, metadata] of Object.entries(patch.assetsInfo)) { + state.assetsInfo[assetId as Caip19AssetId] ??= metadata; + } + + for (const [accountId, assetIds] of Object.entries(patch.customAssets)) { + const existing = state.customAssets[accountId] ?? []; + state.customAssets[accountId] = [ + ...existing, + ...assetIds.filter((assetId) => !existing.includes(assetId)), + ]; + } +} + +const EVM_CHAIN_NAMESPACE = 'eip155'; + +const EVM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/u; + +/** + * Popular networks (covered by the Accounts API) can self-heal through + * auto-detection, so their tokens must not be touched. Frozen snapshot from + * extension migration #215. + */ +const ACCOUNT_API_SUPPORTED_CHAIN_IDS: ReadonlySet = new Set([ + 'eip155:1', // Ethereum Mainnet + 'eip155:10', // Optimism + 'eip155:56', // BNB Smart Chain + 'eip155:137', // Polygon + 'eip155:143', // Monad + 'eip155:999', // HyperEVM + 'eip155:1329', // Sei + 'eip155:5042', // Arc + 'eip155:8453', // Base + 'eip155:42161', // Arbitrum One + 'eip155:43114', // Avalanche + 'eip155:59144', // Linea +]); + +/** + * Compute the additions required to heal `assetsInfo` metadata (and custom + * asset tracking) for tokens on niche EVM chains, based on the legacy + * `TokensController` state. + * + * What it deliberately skips: + * + * - Accounts-API-supported chains — see `ACCOUNT_API_SUPPORTED_CHAIN_IDS`. + * - Tokens the user hid/removed, detected via either the legacy + * `TokensController.allIgnoredTokens` or the current + * `assetPreferences` (`hidden: true`). + * - Non-EVM assets, ERC-721s, and tokens with invalid addresses or metadata. + * + * @param legacyState - Untrusted legacy state root. Expected (but not + * required) to contain `TokensController.allTokens`, + * `TokensController.allIgnoredTokens`, and + * `AccountsController.internalAccounts.accounts`. + * @param currentState - Current `AssetsController` state slices (read-only). + * @returns The additions to apply, or `null` when there is nothing to heal. + */ +export function healAssetsInfoMetadata( + legacyState: unknown, + currentState: CurrentAssetsState, +): AssetsInfoHealingPatch | null { + const allTokens = readPath(legacyState, ['TokensController', 'allTokens']); + if (!isObject(allTokens)) { + return null; + } + + const addressToAccountId = buildAddressToAccountIdMap(legacyState); + const hiddenAssetIds = collectHiddenAssetIds(currentState); + const allIgnoredTokens = readPath(legacyState, [ + 'TokensController', + 'allIgnoredTokens', + ]); + + const assetsInfoAdditions: Record = {}; + const customAssetAdditions: Record = {}; + + for (const [hexChainId, accountTokens] of Object.entries(allTokens)) { + if (!isObject(accountTokens)) { + continue; + } + + const caip2 = hexChainIdToCaip2(hexChainId); + // Skip chains we can't parse or that self-heal via the accounts API. + if (!caip2 || ACCOUNT_API_SUPPORTED_CHAIN_IDS.has(caip2)) { + continue; + } + + for (const [rawAddress, tokens] of Object.entries(accountTokens)) { + if (!Array.isArray(tokens) || tokens.length === 0) { + continue; + } + + const accountId = addressToAccountId[rawAddress.toLowerCase()]; + const ignoredAddresses = collectIgnoredAddresses( + allIgnoredTokens, + hexChainId, + rawAddress, + ); + + for (const token of tokens) { + const restorable = getRestorableAsset( + token, + caip2, + ignoredAddresses, + hiddenAssetIds, + ); + if (!restorable) { + continue; + } + const { assetId, info } = restorable; + + // `assetsInfo` is a global registry; fill gaps only, never overwrite. + if ( + currentState.assetsInfo[assetId] === undefined && + assetsInfoAdditions[assetId] === undefined + ) { + assetsInfoAdditions[assetId] = info; + } + + // Ensure the asset is tracked for its account. + if (accountId) { + addCustomAssetAddition( + currentState, + customAssetAdditions, + accountId, + assetId, + ); + } + } + } + } + + if ( + Object.keys(assetsInfoAdditions).length === 0 && + Object.keys(customAssetAdditions).length === 0 + ) { + return null; + } + + return { + assetsInfo: assetsInfoAdditions, + customAssets: customAssetAdditions, + }; +} + +/** + * Resolve a raw `TokensController` token entry to the CAIP-19 asset ID and + * metadata that should be healed, or `null` when the token must be skipped + * (not an object, missing/invalid address or symbol, an ERC-721, or hidden + * in either the legacy or the current controller). + * + * @param token - Raw token entry from `allTokens`. + * @param caip2 - The CAIP-2 chain ID of the token's chain (e.g. 'eip155:14'). + * @param ignoredAddresses - Lowercase addresses hidden via legacy `allIgnoredTokens`. + * @param hiddenAssetIds - Lowercase asset IDs hidden via current `assetPreferences`. + * @returns The restorable asset, or `null` to skip. + */ +function getRestorableAsset( + token: unknown, + caip2: string, + ignoredAddresses: Set, + hiddenAssetIds: Set, +): { assetId: Caip19AssetId; info: FungibleAssetMetadata } | null { + if (!isObject(token)) { + return null; + } + // Skip NFTs — AssetsController tracks them separately. + if (token.isERC721 === true) { + return null; + } + if ( + typeof token.address !== 'string' || + !EVM_ADDRESS_REGEX.test(token.address) + ) { + return null; + } + // Healed metadata with no symbol would be unusable in the UI; skip it. + if (typeof token.symbol !== 'string' || token.symbol.length === 0) { + return null; + } + + const assetId = buildErc20AssetId(caip2, token.address as Hex); + if (!assetId) { + return null; + } + + // Skip tokens the user hid/removed — in the legacy controller… + if (ignoredAddresses.has(token.address.toLowerCase())) { + return null; + } + // …or in the current controller. + if (hiddenAssetIds.has(assetId.toLowerCase())) { + return null; + } + + return { assetId, info: buildEvmAssetInfo(token) }; +} + +/** + * Build a `FungibleAssetMetadata` from a raw `TokensController` token entry. + * The caller has already validated `symbol` as a non-empty string. + * + * @param token - Raw token object. + * @returns The metadata to write into `assetsInfo`. + */ +function buildEvmAssetInfo( + token: Record, +): FungibleAssetMetadata { + const symbol = token.symbol as string; + const name = + typeof token.name === 'string' && token.name.length > 0 + ? token.name + : symbol; + const decimals = + typeof token.decimals === 'number' && Number.isFinite(token.decimals) + ? token.decimals + : 0; + + const image = + typeof token.image === 'string' && token.image.length > 0 + ? token.image + : undefined; + const aggregators = Array.isArray(token.aggregators) + ? token.aggregators.filter( + (aggregator): aggregator is string => typeof aggregator === 'string', + ) + : undefined; + + return { + type: 'erc20', + symbol, + name, + decimals, + ...(image ? { image } : {}), + ...(aggregators ? { aggregators } : {}), + }; +} + +/** + * Build a map from lowercase account address to account UUID using the + * legacy `AccountsController.internalAccounts.accounts`. + * + * @param legacyState - Untrusted legacy state root. + * @returns Map of lowercase address to account ID (empty on invalid input). + */ +function buildAddressToAccountIdMap( + legacyState: unknown, +): Record { + const accounts = readPath(legacyState, [ + 'AccountsController', + 'internalAccounts', + 'accounts', + ]); + if (!isObject(accounts)) { + return {}; + } + + const map: Record = {}; + for (const [id, account] of Object.entries(accounts)) { + if ( + isObject(account) && + typeof account.address === 'string' && + account.address.length > 0 + ) { + map[account.address.toLowerCase()] = id; + } + } + return map; +} + +/** + * Collect the set of CAIP-19 asset IDs (lowercased) marked `hidden: true` in + * the current `assetPreferences`. Lowercasing lets callers compare against + * checksummed asset IDs case-insensitively. + * + * @param currentState - Current `AssetsController` state slices. + * @returns Set of lowercase hidden asset IDs. + */ +function collectHiddenAssetIds(currentState: CurrentAssetsState): Set { + const hidden = new Set(); + for (const [assetId, preference] of Object.entries( + currentState.assetPreferences, + )) { + if (isObject(preference) && preference.hidden === true) { + hidden.add(assetId.toLowerCase()); + } + } + return hidden; +} + +/** + * Collect the lowercase token addresses ignored (hidden) in the legacy + * `TokensController.allIgnoredTokens` for a given chain and account. The + * account key is matched case-insensitively. + * + * @param allIgnoredTokens - The legacy `allIgnoredTokens` map (possibly missing). + * @param hexChainId - The hex chain ID being processed. + * @param accountAddress - The account address whose ignored list to read. + * @returns Set of lowercase ignored token addresses. + */ +function collectIgnoredAddresses( + allIgnoredTokens: unknown, + hexChainId: string, + accountAddress: string, +): Set { + const result = new Set(); + if (!isObject(allIgnoredTokens)) { + return result; + } + + const chainEntry = allIgnoredTokens[hexChainId]; + if (!isObject(chainEntry)) { + return result; + } + + const lowerAccount = accountAddress.toLowerCase(); + for (const [address, list] of Object.entries(chainEntry)) { + if (address.toLowerCase() !== lowerAccount || !Array.isArray(list)) { + continue; + } + for (const ignored of list) { + if (typeof ignored === 'string') { + result.add(ignored.toLowerCase()); + } + } + } + return result; +} + +/** + * Convert a hex chain ID (e.g. '0x1') to a CAIP-2 chain ID (e.g. 'eip155:1'). + * + * @param hexChainId - The hex-encoded EVM chain ID. + * @returns The CAIP-2 chain ID, or `null` when the input cannot be parsed. + */ +function hexChainIdToCaip2(hexChainId: string): string | null { + if (!/^0x[0-9a-fA-F]+$/u.test(hexChainId)) { + return null; + } + const decimal = Number.parseInt(hexChainId, 16); + if (!Number.isFinite(decimal)) { + return null; + } + return `${EVM_CHAIN_NAMESPACE}:${decimal}`; +} + +/** + * Build the checksummed CAIP-19 asset ID for an ERC-20 token. + * + * @param caip2 - The CAIP-2 chain ID (e.g. 'eip155:14'). + * @param tokenAddress - The ERC-20 contract address. + * @returns The asset ID, or `null` when the address cannot be checksummed. + */ +function buildErc20AssetId( + caip2: string, + tokenAddress: Hex, +): Caip19AssetId | null { + let checksummed: string; + try { + checksummed = getChecksumAddress(tokenAddress); + } catch { + return null; + } + return `${caip2}/erc20:${checksummed}` as Caip19AssetId; +} + +/** + * Read a nested path on an unknown value, returning `undefined` if any + * intermediate key is missing or not an object. + * + * @param root - The value to read from. + * @param path - Sequence of keys to traverse. + * @returns The value at the path, or `undefined`. + */ +function readPath(root: unknown, path: string[]): unknown { + return path.reduce((cursor, key) => { + if (!isObject(cursor) || !hasProperty(cursor, key)) { + return undefined; + } + return cursor[key]; + }, root); +} + +/** + * Record `assetId` as a custom-asset addition for `accountId` unless it is + * already tracked in the current `assetsBalance` (mutual exclusion), already + * present in the current `customAssets`, or already queued as an addition. + * + * @param currentState - Current `AssetsController` state slices. + * @param additions - The custom-asset additions accumulated so far (mutated). + * @param accountId - The account UUID. + * @param assetId - CAIP-19 asset identifier. + */ +function addCustomAssetAddition( + currentState: CurrentAssetsState, + additions: Record, + accountId: AccountId, + assetId: Caip19AssetId, +): void { + if (currentState.assetsBalance[accountId]?.[assetId]) { + return; + } + if (currentState.customAssets[accountId]?.includes(assetId)) { + return; + } + + const queued = additions[accountId] ?? []; + if (!queued.includes(assetId)) { + queued.push(assetId); + } + additions[accountId] = queued; +} From 96e2576e53b57ac8789efdbed87ef8b4834d293b Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 6 Jul 2026 15:08:19 +0100 Subject: [PATCH 2/5] docs: update changelog --- 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 787ab99439e..bcc5f1699ba 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,8 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fetch balances when switching account groups, enabling RPC-only networks, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) - -- Add temporary `tempMigrateAssetsInfoMetadataAssets3346` constructor option that heals `assetsInfo` metadata (and custom-asset tracking) wiped by a prior defect for tokens on niche EVM chains, using legacy `TokensController` state provided by the host +- Add temporary `tempMigrateAssetsInfoMetadataAssets3346` constructor option that heals `assetsInfo` metadata (and custom-asset tracking) wiped by a prior defect for tokens on niche EVM chains, using legacy `TokensController` state provided by the host ([#9393](https://github.com/MetaMask/core/pull/9393)) ## [10.0.1] From 97b409cf97b314489f000926caa7f65f4292d57c Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 6 Jul 2026 15:10:02 +0100 Subject: [PATCH 3/5] refactor: linting --- .../src/AssetsController.test.ts | 2 +- .../assets-controller/src/AssetsController.ts | 4 +- .../migrations/healAssetsInfoMetadata.test.ts | 40 +++++++++++-------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index cdf3820c0f4..8fbd85f7412 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -18,11 +18,11 @@ import type { AssetsControllerMessenger, AssetsControllerState, } from './AssetsController'; -import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; import { TokenDataSource } from './data-sources/TokenDataSource'; import { buildDefaultAssetsInfo } from './defaults'; +import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; import type { Caip19AssetId, AccountId, diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 6408a238fa9..42b3bf35cf6 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -100,8 +100,6 @@ import { } from './defaults'; import { AssetsDataSourceError } from './errors'; import { projectLogger, createModuleLogger } from './logger'; -import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; -import { tempHealAssetsInfoMetadata } from './migrations/healAssetsInfoMetadata'; import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware'; import { DetectionMiddleware } from './middlewares/DetectionMiddleware'; import { @@ -109,6 +107,8 @@ import { createParallelMiddleware, } from './middlewares/ParallelMiddleware'; import { RpcFallbackMiddleware } from './middlewares/RpcFallbackMiddleware'; +import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; +import { tempHealAssetsInfoMetadata } from './migrations/healAssetsInfoMetadata'; import type { AccountId, AssetPreferences, diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts index 3e3d3adf1d4..3410ff6e839 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts @@ -1,9 +1,9 @@ +import type { Caip19AssetId } from '../types'; import type { CurrentAssetsState } from './healAssetsInfoMetadata'; import { healAssetsInfoMetadata, tempHealAssetsInfoMetadata, } from './healAssetsInfoMetadata'; -import type { Caip19AssetId } from '../types'; const ACCOUNT_ID = 'account-uuid-1'; const ACCOUNT_ADDRESS = '0x1111111111111111111111111111111111111111'; @@ -80,8 +80,9 @@ describe('healAssetsInfoMetadata', () => { ['a missing allTokens', { TokensController: {} }], ['a non-object allTokens', { TokensController: { allTokens: [] } }], ])('returns null when legacy state is %s', (_description, legacyState) => { - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it('skips chain entries that are not objects', () => { @@ -91,8 +92,9 @@ describe('healAssetsInfoMetadata', () => { }, }; - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it('skips account entries that are not arrays', () => { @@ -104,8 +106,9 @@ describe('healAssetsInfoMetadata', () => { }, }; - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it.each([ @@ -135,8 +138,9 @@ describe('healAssetsInfoMetadata', () => { }, }; - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it('skips hex chain keys too large to represent as a number', () => { @@ -148,8 +152,9 @@ describe('healAssetsInfoMetadata', () => { }, }; - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it('tolerates a malformed AccountsController and still heals assetsInfo', () => { @@ -317,8 +322,7 @@ describe('healAssetsInfoMetadata', () => { }); it('dedupes the same token across accounts (metadata once, tracking per account)', () => { - const otherAccountAddress = - '0x2222222222222222222222222222222222222222'; + const otherAccountAddress = '0x2222222222222222222222222222222222222222'; const otherAccountId = 'account-uuid-2'; const legacyState = { TokensController: { @@ -361,8 +365,9 @@ describe('healAssetsInfoMetadata', () => { }, }; - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it('skips ERC-721 tokens', () => { @@ -390,8 +395,9 @@ describe('healAssetsInfoMetadata', () => { }, }; - expect(healAssetsInfoMetadata(legacyState, buildCurrentState())).toBeNull( - ); + expect( + healAssetsInfoMetadata(legacyState, buildCurrentState()), + ).toBeNull(); }); it('skips tokens hidden via current assetPreferences (case-insensitive)', () => { From ce0bf76fd00c7dee83e9b6f1d4e5987d282685ca Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 6 Jul 2026 15:28:31 +0100 Subject: [PATCH 4/5] fix(assets-controller): enhance tempHealAssetsInfoMetadata functionality - Updated the `tempHealAssetsInfoMetadata` function to return the updated controller state directly instead of a callback for the `update` method. - Introduced a new `selectedCurrency` field in `AssetsControllerStateInternal` to track the currently active ISO 4217 currency code. - Improved test coverage for `tempHealAssetsInfoMetadata`, including scenarios for healing state and error handling. This change aims to streamline the healing process for asset metadata and ensure better state management during migrations. --- .../assets-controller/src/AssetsController.ts | 2 +- .../migrations/healAssetsInfoMetadata.test.ts | 78 ++++++++++++------- .../src/migrations/healAssetsInfoMetadata.ts | 44 ++++++----- packages/assets-controller/src/types.ts | 3 + 4 files changed, 81 insertions(+), 46 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 42b3bf35cf6..3a6612af095 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -880,7 +880,7 @@ export class AssetsController extends BaseController< // TEMPORARY: heal assetsInfo metadata wiped by a prior defect // (see extension migration #215 / ASSETS-3346). Remove in a future release. if (tempMigrateAssetsInfoMetadataAssets3346) { - this.update( + this.update(() => tempHealAssetsInfoMetadata({ state: this.state, getMigrationState: tempMigrateAssetsInfoMetadataAssets3346, diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts index 3410ff6e839..512d1f87054 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts @@ -1,4 +1,4 @@ -import type { Caip19AssetId } from '../types'; +import type { AssetsControllerStateInternal, Caip19AssetId } from '../types'; import type { CurrentAssetsState } from './healAssetsInfoMetadata'; import { healAssetsInfoMetadata, @@ -499,62 +499,88 @@ describe('healAssetsInfoMetadata', () => { }); describe('tempHealAssetsInfoMetadata', () => { - it('returns a state callback that applies the healing patch to the draft', () => { - const state = buildCurrentState(); - - const applyHealing = tempHealAssetsInfoMetadata({ + /** + * Build a full controller state for tempHealAssetsInfoMetadata tests. + * + * @param overrides - Partial state slices to merge over the defaults. + * @returns Full controller state. + */ + function buildFullState( + overrides: Partial = {}, + ): AssetsControllerStateInternal { + return { + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + selectedCurrency: 'usd', + ...overrides, + }; + } + + it('returns healed state with the healing patch applied', () => { + const state = buildFullState(); + + const healedState = tempHealAssetsInfoMetadata({ state, getMigrationState: () => buildLegacyState(VALID_TOKEN), }); - applyHealing(state); - expect(state.assetsInfo[FLARE_ASSET_ID]).toStrictEqual({ + expect(healedState.assetsInfo[FLARE_ASSET_ID]).toStrictEqual({ type: 'erc20', symbol: 'TST', name: 'Test Token', decimals: 18, }); - expect(state.customAssets[ACCOUNT_ID]).toStrictEqual([FLARE_ASSET_ID]); + expect(healedState.customAssets[ACCOUNT_ID]).toStrictEqual([ + FLARE_ASSET_ID, + ]); + expect(state).toStrictEqual(buildFullState()); }); - it('leaves the draft untouched when there is nothing to heal', () => { - const state = buildCurrentState(); + it('returns the original state when there is nothing to heal', () => { + const state = buildFullState(); - const applyHealing = tempHealAssetsInfoMetadata({ + const healedState = tempHealAssetsInfoMetadata({ state, getMigrationState: () => ({ unrelated: true }), }); - applyHealing(state); - expect(state).toStrictEqual(buildCurrentState()); + expect(healedState).toBe(state); }); it('is idempotent: re-running never duplicates or overwrites entries', () => { const otherAssetId = 'eip155:14/erc20:0x0000000000000000000000000000000000000001' as Caip19AssetId; - const state = buildCurrentState({ + const state = buildFullState({ customAssets: { [ACCOUNT_ID]: [otherAssetId] }, }); const getMigrationState = (): unknown => buildLegacyState(VALID_TOKEN); - tempHealAssetsInfoMetadata({ state, getMigrationState })(state); - const afterFirstRun = JSON.parse(JSON.stringify(state)); - tempHealAssetsInfoMetadata({ state, getMigrationState })(state); + const afterFirstRun = tempHealAssetsInfoMetadata({ + state, + getMigrationState, + }); + const afterSecondRun = tempHealAssetsInfoMetadata({ + state: afterFirstRun, + getMigrationState, + }); - expect(state).toStrictEqual(afterFirstRun); - expect(state.customAssets[ACCOUNT_ID]).toStrictEqual([ + expect(afterSecondRun).toStrictEqual(afterFirstRun); + expect(afterSecondRun.customAssets[ACCOUNT_ID]).toStrictEqual([ otherAssetId, FLARE_ASSET_ID, ]); }); it('reports errors thrown by getMigrationState via captureException without throwing', () => { - const state = buildCurrentState(); + const state = buildFullState(); const captureException = jest.fn(); - let applyHealing: ((state: unknown) => void) | undefined; + let healedState: AssetsControllerStateInternal | undefined; expect(() => { - applyHealing = tempHealAssetsInfoMetadata({ + healedState = tempHealAssetsInfoMetadata({ state, getMigrationState: () => { throw new Error('legacy state unavailable'); @@ -563,8 +589,7 @@ describe('tempHealAssetsInfoMetadata', () => { }); }).not.toThrow(); - expect(() => applyHealing?.(state)).not.toThrow(); - expect(state).toStrictEqual(buildCurrentState()); + expect(healedState).toBe(state); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('legacy state unavailable'), @@ -573,16 +598,15 @@ describe('tempHealAssetsInfoMetadata', () => { }); it('swallows errors even when captureException is not provided', () => { - const state = buildCurrentState(); + const state = buildFullState(); expect(() => { - const applyHealing = tempHealAssetsInfoMetadata({ + tempHealAssetsInfoMetadata({ state, getMigrationState: () => { throw new Error('legacy state unavailable'); }, }); - applyHealing(state); }).not.toThrow(); }); }); diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts index d93cf408632..e43af7b4068 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts @@ -11,9 +11,9 @@ import { import { createModuleLogger, projectLogger } from '../logger'; import type { AccountId, - AssetsControllerStateInternal, Caip19AssetId, FungibleAssetMetadata, + AssetsControllerStateInternal, } from '../types'; /** @@ -96,7 +96,7 @@ const log = createModuleLogger(projectLogger, 'tempHealAssetsInfoMetadata'); export type TempHealAssetsInfoMetadataOptions = { /** Current `AssetsController` state the healing patch is computed against. */ - state: CurrentAssetsState; + state: AssetsControllerStateInternal; /** * Host-provided getter for the untrusted legacy state root (see * `AssetsControllerOptions.tempMigrateAssetsInfoMetadataAssets3346`). @@ -113,13 +113,14 @@ export type TempHealAssetsInfoMetadataOptions = { * @param options.state - Current controller state to compute the patch against. * @param options.getMigrationState - Getter for the untrusted legacy state root. * @param options.captureException - Optional reporter for healing failures. - * @returns The state callback to pass to the controller's `update` method. + * @returns Updated controller state with the healing patch applied, or the + * original state when there is nothing to heal or healing fails. */ export function tempHealAssetsInfoMetadata({ state, getMigrationState, captureException, -}: TempHealAssetsInfoMetadataOptions): (state: unknown) => void { +}: TempHealAssetsInfoMetadataOptions): AssetsControllerStateInternal { const reportError = (error: unknown): void => { log('Failed to heal assetsInfo metadata', error); captureException?.( @@ -138,22 +139,29 @@ export function tempHealAssetsInfoMetadata({ reportError(error); } - return (draft) => { - if (!patch) { - return; - } + if (!patch) { + return state; + } - try { - applyHealingPatch(draft as CurrentAssetsState, patch); + try { + const nextState: AssetsControllerStateInternal = { + ...state, + assetsInfo: { ...state.assetsInfo }, + customAssets: { ...state.customAssets }, + }; - log('Healed wiped assetsInfo metadata for niche-chain tokens', { - healedAssetsInfoCount: Object.keys(patch.assetsInfo).length, - healedCustomAssetsAccounts: Object.keys(patch.customAssets).length, - }); - } catch (error) { - reportError(error); - } - }; + applyHealingPatch(nextState, patch); + + log('Healed wiped assetsInfo metadata for niche-chain tokens', { + healedAssetsInfoCount: Object.keys(patch.assetsInfo).length, + healedCustomAssetsAccounts: Object.keys(patch.customAssets).length, + }); + + return nextState; + } catch (error) { + reportError(error); + return state; + } } /** diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 0fb743e8532..2b2b940a713 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -1,3 +1,4 @@ +import type { SupportedCurrency } from '@metamask/core-backend'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; @@ -475,6 +476,8 @@ export type AssetsControllerStateInternal = { customAssets: Record; /** UI preferences per asset (e.g. hidden) - separate from metadata */ assetPreferences: Record; + /** Currently-active ISO 4217 currency code */ + selectedCurrency: SupportedCurrency; }; /** From 5d54234a6742af85caedaaf5a829ffe8a8a37210 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 6 Jul 2026 15:33:54 +0100 Subject: [PATCH 5/5] fix(assets-controller): ensure immutability of customAssets during state healing - Added a test to verify that the `tempHealAssetsInfoMetadata` function does not mutate existing `customAssets` arrays in the input state. - Refactored the `tempHealAssetsInfoMetadata` function to use `cloneDeep` for state management, ensuring that the original state remains unchanged during the healing process. This change enhances the reliability of state migrations by preventing unintended side effects on the input state. --- .../migrations/healAssetsInfoMetadata.test.ts | 17 +++++++++++++++++ .../src/migrations/healAssetsInfoMetadata.ts | 10 +++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts index 512d1f87054..98202bb31be 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts @@ -550,6 +550,23 @@ describe('tempHealAssetsInfoMetadata', () => { expect(healedState).toBe(state); }); + it('does not mutate existing customAssets arrays on the input state', () => { + const otherAssetId = + 'eip155:14/erc20:0x0000000000000000000000000000000000000001' as Caip19AssetId; + const existingCustomAssets = [otherAssetId]; + const state = buildFullState({ + customAssets: { [ACCOUNT_ID]: existingCustomAssets }, + }); + + tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => buildLegacyState(VALID_TOKEN), + }); + + expect(existingCustomAssets).toStrictEqual([otherAssetId]); + expect(state.customAssets[ACCOUNT_ID]).toBe(existingCustomAssets); + }); + it('is idempotent: re-running never duplicates or overwrites entries', () => { const otherAssetId = 'eip155:14/erc20:0x0000000000000000000000000000000000000001' as Caip19AssetId; diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts index e43af7b4068..fc34bc32855 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts @@ -7,6 +7,7 @@ import { hasProperty, isObject, } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; import { createModuleLogger, projectLogger } from '../logger'; import type { @@ -144,12 +145,7 @@ export function tempHealAssetsInfoMetadata({ } try { - const nextState: AssetsControllerStateInternal = { - ...state, - assetsInfo: { ...state.assetsInfo }, - customAssets: { ...state.customAssets }, - }; - + const nextState = cloneDeep(state); applyHealingPatch(nextState, patch); log('Healed wiped assetsInfo metadata for niche-chain tokens', { @@ -169,7 +165,7 @@ export function tempHealAssetsInfoMetadata({ * concurrent writes: fills `assetsInfo` gaps only and dedupes `customAssets` * against the current draft rather than trusting the patch blindly. * - * @param state - Mutable controller state (an Immer draft). + * @param state - Mutable controller state copy. * @param patch - The additions computed by {@link healAssetsInfoMetadata}. */ function applyHealingPatch(