diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index b7e630d9f4..e95d3bd446 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/transaction-controller` from `^67.1.0` to `^68.0.0` ([#9089](https://github.com/MetaMask/core/pull/9089)) +### Fixed + +- `AssetsController` reconciliate and self-heals stale assetInfo metadata types ([#9099](https://github.com/MetaMask/core/pull/9099)) + ## [9.0.1] ### Changed diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 9865f69dbc..98a34fe1c9 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1365,6 +1365,167 @@ describe('AssetsController', () => { }); }); + it('reconciles a stale native type stored as erc20 when assetsInfo includes the asset', async () => { + // Native (zero-address ERC-20) mis-stored as erc20 by an older version. + const imxAssetId = + 'eip155:13371/erc20:0x0000000000000000000000000000000000000000' as Caip19AssetId; + const initialState: Partial = { + assetsInfo: { + [imxAssetId]: { + type: 'erc20', + symbol: 'IMX', + name: 'Immutable X', + decimals: 18, + image: 'https://example.com/imx.png', + }, + }, + }; + + await withController( + { state: initialState, isBasicFunctionality: () => false }, + async ({ controller }) => { + // Reconciliation occurs when assetsInfo includes the asset. + await controller.handleAssetsUpdate( + { + assetsInfo: { + [imxAssetId]: { + type: 'erc20', + symbol: 'IMX', + name: 'Immutable X', + decimals: 18, + image: 'https://example.com/imx.png', + }, + }, + }, + 'TestSource', + ); + + expect(controller.state.assetsInfo[imxAssetId]).toStrictEqual({ + type: 'native', + symbol: 'IMX', + name: 'Immutable X', + decimals: 18, + image: 'https://example.com/imx.png', + }); + }, + ); + }); + + it('reconciles a stale native type stored as erc20 when assetsBalance includes the asset', async () => { + const imxAssetId = + 'eip155:13371/erc20:0x0000000000000000000000000000000000000000' as Caip19AssetId; + const initialState: Partial = { + assetsInfo: { + [imxAssetId]: { + type: 'erc20', + symbol: 'IMX', + name: 'Immutable X', + decimals: 18, + image: 'https://example.com/imx.png', + }, + }, + }; + + await withController( + { state: initialState, isBasicFunctionality: () => false }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [imxAssetId]: { amount: '1000000000000000000' }, + }, + }, + }, + 'TestSource', + ); + + expect(controller.state.assetsInfo[imxAssetId]).toStrictEqual({ + type: 'native', + symbol: 'IMX', + name: 'Immutable X', + decimals: 18, + image: 'https://example.com/imx.png', + }); + }, + ); + }); + + it('leaves a genuine erc20 type untouched when assetsInfo includes it', async () => { + const initialState: Partial = { + assetsInfo: { + [MOCK_ASSET_ID]: { + type: 'erc20', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }; + + await withController( + { state: initialState, isBasicFunctionality: () => false }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsInfo: { + [MOCK_ASSET_ID]: { + type: 'erc20', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }, + 'TestSource', + ); + + expect(controller.state.assetsInfo[MOCK_ASSET_ID]?.type).toBe( + 'erc20', + ); + }, + ); + }); + + it('reconciles type when assetsInfo response uses a non-checksummed asset ID', async () => { + const initialState: Partial = { + assetsInfo: { + [MOCK_ASSET_ID]: { + type: 'native', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }; + + await withController( + { state: initialState, isBasicFunctionality: () => false }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsInfo: { + [MOCK_ASSET_ID_LOWERCASE]: { + type: 'native', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }, + 'TestSource', + ); + + expect(controller.state.assetsInfo[MOCK_ASSET_ID]?.type).toBe( + 'erc20', + ); + expect( + controller.state.assetsInfo[MOCK_ASSET_ID_LOWERCASE], + ).toBeUndefined(); + }, + ); + }); + it('updates state with metadata', async () => { await withController(async ({ controller }) => { await controller.handleAssetsUpdate( diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 77f95782f4..35e716ec4b 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2188,6 +2188,28 @@ export class AssetsController extends BaseController< } } + // Reconcile & self-heal stale asset "types" (e.g. erc20 -> native) + // using IDs from new response assetInfo and assetBalance + const assetsInfoAssetIdsSet = new Set([ + ...Object.keys(normalizedResponse.assetsInfo ?? {}), + ...Object.values(normalizedResponse.assetsBalance ?? {}).flatMap( + (accountBalances) => Object.keys(accountBalances), + ), + ] as Caip19AssetId[]); + for (const assetId of assetsInfoAssetIdsSet) { + const entry = metadata[assetId] as FungibleAssetMetadata | undefined; + if (!entry) { + continue; + } + const correctType = this.#getAssetType(assetId); + if (entry.type !== correctType) { + metadata[assetId] = { ...entry, type: correctType }; + if (!changedMetadata.includes(assetId)) { + changedMetadata.push(assetId); + } + } + } + if (normalizedResponse.assetsBalance) { for (const [accountId, accountBalances] of Object.entries( normalizedResponse.assetsBalance,