diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index c6fa58e7327..a4f9659de3b 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `AssetsByAccountGroup` to list of exported types ([#6983](https://github.com/MetaMask/core/pull/6983)) +- Added `addAssets` to allow adding multiple assets for non-EVM chains ([#7016](https://github.com/MetaMask/core/pull/7016)) ### Changed @@ -22,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AccountTreeController:selectedAccountGroupChange` updates DeFi positions for the selected address - `TransactionController:transactionConfirmed` only updates DeFi positions if the transaction is for the selected address +### Fixed + +- Fixed token is not removed from ignored tokens list when added back due to case insensiteivity ([#7016](https://github.com/MetaMask/core/pull/7016)) + ## [86.0.0] ### Changed diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts index b9e6af42bb8..15d3a24f159 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -1,6 +1,7 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import type { AccountAssetListUpdatedEventPayload, + CaipAssetType, CaipAssetTypeOrId, } from '@metamask/keyring-api'; import { @@ -921,6 +922,317 @@ describe('MultichainAssetsController', () => { }); }); + describe('addAssets', () => { + it('should add a single asset to account assets list', async () => { + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetToAdd = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + + const result = await controller.addAssets( + [assetToAdd], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + assetToAdd, + ]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + assetToAdd, + ]); + }); + + it('should not add duplicate assets', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [existingAsset], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([existingAsset]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([existingAsset]); + }); + + it('should remove asset from ignored list when added', async () => { + const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [assetToAdd], + }, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [assetToAdd], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([assetToAdd]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToAdd]); + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toBeUndefined(); + }); + + it('should handle adding asset to account with no existing assets', async () => { + const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [assetToAdd], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([assetToAdd]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToAdd]); + }); + + it('should publish accountAssetListUpdated event when asset is added', async () => { + const { controller, messenger } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + // Set up event listener to capture the published event + const eventListener = jest.fn(); + messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + eventListener, + ); + + await controller.addAssets([assetToAdd], mockSolanaAccount.id); + + expect(eventListener).toHaveBeenCalledWith({ + assets: { + [mockSolanaAccount.id]: { + added: [assetToAdd], + removed: [], + }, + }, + }); + }); + + it('should add multiple assets from the same chain', async () => { + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetsToAdd: CaipAssetType[] = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:AnotherTokenAddress', + ]; + + const result = await controller.addAssets( + assetsToAdd, + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ...assetsToAdd, + ]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ...assetsToAdd, + ]); + }); + + it('should throw error when assets are from different chains', async () => { + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetsFromDifferentChains: CaipAssetType[] = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'eip155:1/slip44:60', // Ethereum asset + ]; + + await expect( + controller.addAssets(assetsFromDifferentChains, mockSolanaAccount.id), + ).rejects.toThrow( + 'All assets must belong to the same chain. Found assets from chains: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1, eip155:1', + ); + }); + + it('should return existing assets when empty array is provided', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets([], mockSolanaAccount.id); + + expect(result).toStrictEqual([existingAsset]); + }); + + it('should only publish event for newly added assets', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const newAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const eventListener = jest.fn(); + messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + eventListener, + ); + + await controller.addAssets( + [existingAsset, newAsset], + mockSolanaAccount.id, + ); + + expect(eventListener).toHaveBeenCalledWith({ + assets: { + [mockSolanaAccount.id]: { + added: [newAsset], // Only the new asset should be in the event + removed: [], + }, + }, + }); + }); + + it('should not publish event when no new assets are added', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const eventListener = jest.fn(); + messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + eventListener, + ); + + await controller.addAssets([existingAsset], mockSolanaAccount.id); + + // Event should not be published since no new assets were added + expect(eventListener).not.toHaveBeenCalled(); + }); + + it('should partially remove assets from ignored list when only some are added', async () => { + const ignoredAsset1 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const ignoredAsset2 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token1'; + const ignoredAsset3 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token2'; + + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [ + ignoredAsset1, + ignoredAsset2, + ignoredAsset3, + ], + }, + } as MultichainAssetsControllerState, + }); + + // Only add two of the three ignored assets + await controller.addAssets( + [ignoredAsset1, ignoredAsset2], + mockSolanaAccount.id, + ); + + // Should have added the two assets + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ignoredAsset1, ignoredAsset2]); + + // Should have only the third asset remaining in ignored list + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([ignoredAsset3]); + }); + }); + describe('asset detection with ignored assets', () => { it('should filter out ignored assets when account assets are updated', async () => { const ignoredAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index 6f05f676f59..a6638ab2a5c 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -85,6 +85,11 @@ export type MultichainAssetsControllerIgnoreAssetsAction = { handler: MultichainAssetsController['ignoreAssets']; }; +export type MultichainAssetsControllerAddAssetsAction = { + type: `${typeof controllerName}:addAssets`; + handler: MultichainAssetsController['addAssets']; +}; + /** * Returns the state of the {@link MultichainAssetsController}. */ @@ -108,7 +113,8 @@ export type MultichainAssetsControllerStateChangeEvent = export type MultichainAssetsControllerActions = | MultichainAssetsControllerGetStateAction | MultichainAssetsControllerGetAssetMetadataAction - | MultichainAssetsControllerIgnoreAssetsAction; + | MultichainAssetsControllerIgnoreAssetsAction + | MultichainAssetsControllerAddAssetsAction; /** * Events emitted by {@link MultichainAssetsController}. @@ -259,6 +265,11 @@ export class MultichainAssetsController extends BaseController< 'MultichainAssetsController:ignoreAssets', this.ignoreAssets.bind(this), ); + + this.messenger.registerActionHandler( + 'MultichainAssetsController:addAssets', + this.addAssets.bind(this), + ); } /** @@ -296,6 +307,82 @@ export class MultichainAssetsController extends BaseController< }); } + /** + * Adds multiple assets to the stored asset list for a specific account. + * All assets must belong to the same chain. + * + * @param assetIds - Array of CAIP asset IDs to add (must be from same chain). + * @param accountId - The account ID to add the assets to. + * @returns The updated asset list for the account. + * @throws Error if assets are from different chains. + */ + async addAssets( + assetIds: CaipAssetType[], + accountId: string, + ): Promise { + if (assetIds.length === 0) { + return this.state.accountsAssets[accountId] || []; + } + + // Validate that all assets are from the same chain + const chainIds = new Set( + assetIds.map((assetId) => parseCaipAssetType(assetId).chainId), + ); + if (chainIds.size > 1) { + throw new Error( + `All assets must belong to the same chain. Found assets from chains: ${Array.from(chainIds).join(', ')}`, + ); + } + + return this.#withControllerLock(async () => { + // Refresh metadata for all assets + await this.#refreshAssetsMetadata(assetIds); + + const addedAssets: CaipAssetType[] = []; + + this.update((state) => { + // Initialize account assets if it doesn't exist + if (!state.accountsAssets[accountId]) { + state.accountsAssets[accountId] = []; + } + + // Add assets if they don't already exist + for (const assetId of assetIds) { + if (!state.accountsAssets[accountId].includes(assetId)) { + state.accountsAssets[accountId].push(assetId); + addedAssets.push(assetId); + } + } + + // Remove from ignored list if they exist there (inline logic like EVM) + if (state.allIgnoredAssets[accountId]) { + state.allIgnoredAssets[accountId] = state.allIgnoredAssets[ + accountId + ].filter((asset) => !assetIds.includes(asset)); + + // Clean up empty arrays + if (state.allIgnoredAssets[accountId].length === 0) { + delete state.allIgnoredAssets[accountId]; + } + } + }); + + // Publish event to notify other controllers (balances, rates) about the new assets + if (addedAssets.length > 0) { + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { + assets: { + [accountId]: { + added: addedAssets, + removed: [], + }, + }, + }); + } + + return this.state.accountsAssets[accountId] || []; + }); + } + /** * Checks if an asset is ignored for a specific account. * diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts index 9c3ec74912d..9315586c59f 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -643,6 +643,96 @@ describe('MultichainBalancesController', () => { }, }); }); + + it('sets balance to zero for assets that were added but have no balance from snap', async () => { + const mockSolanaAccountId1 = mockListSolanaAccounts[0].id; + + const existingBalancesState = { + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken': { + amount: '5.00000000', + unit: 'SOL', + }, + }, + }; + + const { + controller, + messenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + } = setupController({ + state: { + balances: existingBalancesState, + }, + mocks: { + handleMockGetAssetsState: { + accountsAssets: { + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken', + ], + }, + }, + handleRequestReturnValue: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken': { + amount: '5.00000000', + unit: 'SOL', + }, + }, + listMultichainAccounts: [mockListSolanaAccounts[0]], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockListMultichainAccounts.mockReset(); + + mockListMultichainAccounts.mockReturnValue(mockListSolanaAccounts); + + // Mock snap returning balance for only one asset, not the newly added ones + mockSnapHandleRequest.mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithBalance': { + amount: '1.00000000', + unit: 'SOL', + }, + // Note: newTokenWithoutBalance is not returned by snap, so it should get 0 balance + }); + + // Simulate adding assets where some have balance and some don't + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccountId1]: { + added: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithBalance', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithoutBalance', + ], + removed: [], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({ + [mockSolanaAccountId1]: { + // Existing balance should remain + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken': { + amount: '5.00000000', + unit: 'SOL', + }, + // New asset with balance from snap + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithBalance': { + amount: '1.00000000', + unit: 'SOL', + }, + // New asset without balance from snap should get zero balance + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithoutBalance': + { + amount: '0', + unit: '', + }, + }, + }); + }); }); it('resumes updating balances after unlocking KeyringController', async () => { diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts index 892f0b06079..32ee1934521 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts @@ -223,6 +223,8 @@ export class MultichainBalancesController extends BaseController< return; } + const accountsMap = new Map(accounts.map((acc) => [acc.accountId, acc])); + this.update((state: Draft) => { for (const [accountId, accountBalances] of Object.entries( balancesToUpdate, @@ -233,11 +235,21 @@ export class MultichainBalancesController extends BaseController< ) { state.balances[accountId] = accountBalances; } else { + const acc = accountsMap.get(accountId); + + const assetsWithoutBalance = new Set(acc?.assets || []); + for (const assetId in accountBalances) { if (!state.balances[accountId][assetId]) { state.balances[accountId][assetId] = accountBalances[assetId]; + assetsWithoutBalance.delete(assetId as CaipAssetType); } } + + // Triggered when an asset is added to the accountAssets list manually + for (const assetId of assetsWithoutBalance) { + state.balances[accountId][assetId] = { amount: '0', unit: '' }; + } } } }); diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts index f1e7d341a56..d35857522a2 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts @@ -330,4 +330,95 @@ describe('ERC1155Standard', () => { ); }); }); + + describe('getDetails optional parameters', () => { + it('should return details without tokenURI when no tokenId is provided', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + // No tokenId parameter to test the optional parameter behavior + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toBeUndefined(); // Should be undefined when no tokenId + expect(details.symbol).toBe('TEST'); + expect(details.name).toBe('Test Token'); + + // Restore original methods + jest.restoreAllMocks(); + }); + + it('should convert IPFS URIs to gateway URLs', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + // Mock getTokenURI to return IPFS URI + jest + .spyOn(erc1155Standard, 'getTokenURI') + .mockResolvedValue( + 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + ); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + SAMPLE_TOKEN_ID, + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toContain('ipfs.gateway.com'); // Should be converted from IPFS URI + + // Restore original methods + jest.restoreAllMocks(); + }); + + it('should handle metadata fetching with network errors', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + jest + .spyOn(erc1155Standard, 'getTokenURI') + .mockResolvedValue('https://example.com/metadata.json'); + + // Mock fetch to fail - this tests the catch block in getDetails + nock('https://example.com') + .get('/metadata.json') + .replyWithError('Network error'); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + SAMPLE_TOKEN_ID, + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toBe('https://example.com/metadata.json'); + expect(details.image).toBeUndefined(); // Should be undefined due to fetch error + + // Restore original methods + jest.restoreAllMocks(); + }); + }); }); diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index d5d8d114566..0078012e0d1 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -2925,6 +2925,68 @@ describe('TokensController', () => { ); }); + it('should clear nest allIgnoredTokens when re-adding tokens with different address case via addTokens', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + + const tokenAddressFromAPI = '0x7DA14988E4F390C2E34ED41DF1814467D3ADE0C3'; + const checksummedAddress = '0x7da14988E4f390C2E34ed41DF1814467D3aDe0c3'; + + const dummyTokens = [ + { + address: tokenAddressFromAPI, + symbol: 'PEPE', + decimals: 18, + aggregators: [], + image: undefined, + }, + ]; + + await withController( + { + options: { + chainId: ChainId.mainnet, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ controller }) => { + await controller.addTokens(dummyTokens, 'mainnet'); + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress][0] + .address, + ).toBe(checksummedAddress); + + controller.ignoreTokens([tokenAddressFromAPI], 'mainnet'); + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([checksummedAddress]); + + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([]); + + await controller.addTokens(dummyTokens, 'mainnet'); + + // Should remove ignored token despite case difference + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([]); + + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress], + ).toHaveLength(1); + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress][0] + .address, + ).toBe(checksummedAddress); + }, + ); + }); + it('should clear nest allDetectedTokens under chain ID and selected address when an detected token is added to tokens list', async () => { const selectedAddress = '0x1'; const selectedAccount = createMockInternalAccount({ diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index b5350c60979..62cac130a75 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -516,7 +516,7 @@ export class TokensController extends BaseController< ...tokensToImport, ].reduce( (output, token) => { - output[token.address] = token; + output[toChecksumHexAddress(token.address)] = token; return output; }, {} as { [address: string]: Token }, @@ -534,7 +534,7 @@ export class TokensController extends BaseController< aggregators, name, }; - newTokensMap[address] = formattedToken; + newTokensMap[checksumAddress] = formattedToken; importedTokensMap[address.toLowerCase()] = true; return formattedToken; }); @@ -542,7 +542,9 @@ export class TokensController extends BaseController< const newIgnoredTokens = allIgnoredTokens[interactingChainId]?.[ this.#getSelectedAddress() - ]?.filter((tokenAddress) => !newTokensMap[tokenAddress.toLowerCase()]); + ]?.filter( + (tokenAddress) => !newTokensMap[toChecksumHexAddress(tokenAddress)], + ); const detectedTokensForGivenChain = interactingChainId ? allDetectedTokens?.[interactingChainId]?.[this.#getSelectedAddress()]