diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 9c7c1dfee12..bcc5f1699ba 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,6 +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 ([#9393](https://github.com/MetaMask/core/pull/9393)) ## [10.0.1] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 3222a8d2851..8fbd85f7412 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -22,6 +22,7 @@ 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, @@ -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..3a6612af095 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -107,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, @@ -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..98202bb31be --- /dev/null +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts @@ -0,0 +1,629 @@ +import type { AssetsControllerStateInternal, Caip19AssetId } from '../types'; +import type { CurrentAssetsState } from './healAssetsInfoMetadata'; +import { + healAssetsInfoMetadata, + tempHealAssetsInfoMetadata, +} from './healAssetsInfoMetadata'; + +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', () => { + /** + * 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), + }); + + expect(healedState.assetsInfo[FLARE_ASSET_ID]).toStrictEqual({ + type: 'erc20', + symbol: 'TST', + name: 'Test Token', + decimals: 18, + }); + expect(healedState.customAssets[ACCOUNT_ID]).toStrictEqual([ + FLARE_ASSET_ID, + ]); + expect(state).toStrictEqual(buildFullState()); + }); + + it('returns the original state when there is nothing to heal', () => { + const state = buildFullState(); + + const healedState = tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => ({ unrelated: true }), + }); + + 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; + const state = buildFullState({ + customAssets: { [ACCOUNT_ID]: [otherAssetId] }, + }); + const getMigrationState = (): unknown => buildLegacyState(VALID_TOKEN); + + const afterFirstRun = tempHealAssetsInfoMetadata({ + state, + getMigrationState, + }); + const afterSecondRun = tempHealAssetsInfoMetadata({ + state: afterFirstRun, + getMigrationState, + }); + + 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 = buildFullState(); + const captureException = jest.fn(); + + let healedState: AssetsControllerStateInternal | undefined; + expect(() => { + healedState = tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => { + throw new Error('legacy state unavailable'); + }, + captureException, + }); + }).not.toThrow(); + + expect(healedState).toBe(state); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('legacy state unavailable'), + }), + ); + }); + + it('swallows errors even when captureException is not provided', () => { + const state = buildFullState(); + + expect(() => { + tempHealAssetsInfoMetadata({ + state, + getMigrationState: () => { + throw new Error('legacy state unavailable'); + }, + }); + }).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..fc34bc32855 --- /dev/null +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts @@ -0,0 +1,586 @@ +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 { cloneDeep } from 'lodash'; + +import { createModuleLogger, projectLogger } from '../logger'; +import type { + AccountId, + Caip19AssetId, + FungibleAssetMetadata, + AssetsControllerStateInternal, +} 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: AssetsControllerStateInternal; + /** + * 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 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): AssetsControllerStateInternal { + 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); + } + + if (!patch) { + return state; + } + + try { + const nextState = cloneDeep(state); + 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; + } +} + +/** + * 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 copy. + * @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; +} 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; }; /**