Skip to content

Commit cf5334f

Browse files
authored
feat: support adding non-evm tokens (#7016)
## Explanation The feature to hide tokens is now available for NON-EVM assets but there is no way to bring the assets back. This task involves implementing a token import feature for non-EVM assets. This is crucial for improving user experience by allowing users to import. The implementation should be prioritized to align with upcoming Solana campaigns. <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References https://consensyssoftware.atlassian.net/browse/ASSETS-1425 <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces `addAssets` to `MultichainAssetsController` for adding non‑EVM assets and updates `MultichainBalancesController` to initialize balances for new assets, zeroing when snaps return none. > > - **MultichainAssetsController (`src/MultichainAssetsController/MultichainAssetsController.ts`)** > - Adds `addAssets` action/handler to append multiple non‑EVM `CaipAssetType` assets to an account. > - Validates all assets are from the same chain; rejects mixed chains. > - Refreshes metadata, updates `accountsAssets`, removes assets from `allIgnoredAssets`, and publishes `MultichainAssetsController:accountAssetListUpdated` with only newly added assets. > - Prevents duplicates; returns existing list on empty input. > - **MultichainBalancesController (`src/MultichainBalancesController/MultichainBalancesController.ts`)** > - On `MultichainAssetsController:accountAssetListUpdated`, updates balances for added assets and sets missing ones to `{ amount: '0', unit: '' }` when snap returns no balance. > - Minor internal refactor to map accounts for efficient updates. > - **Tests** > - Extensive unit tests for `addAssets` scenarios (single/multiple, duplicates, ignored, cross-chain error, empty input, event emission). > - Tests ensuring balances zero-fill for assets without returned balances. > - **Changelog** > - Documents new `addAssets` capability for non‑EVM chains in `packages/assets-controllers/CHANGELOG.md`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 28f9108. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dc2d67d commit cf5334f

File tree

8 files changed

+665
-4
lines changed

8 files changed

+665
-4
lines changed

packages/assets-controllers/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Add `AssetsByAccountGroup` to list of exported types ([#6983](https://github.com/MetaMask/core/pull/6983))
13+
- Added `addAssets` to allow adding multiple assets for non-EVM chains ([#7016](https://github.com/MetaMask/core/pull/7016))
1314

1415
### Changed
1516

@@ -22,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2223
- `AccountTreeController:selectedAccountGroupChange` updates DeFi positions for the selected address
2324
- `TransactionController:transactionConfirmed` only updates DeFi positions if the transaction is for the selected address
2425

26+
### Fixed
27+
28+
- Fixed token is not removed from ignored tokens list when added back due to case insensiteivity ([#7016](https://github.com/MetaMask/core/pull/7016))
29+
2530
## [86.0.0]
2631

2732
### Changed

packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { deriveStateFromMetadata } from '@metamask/base-controller';
22
import type {
33
AccountAssetListUpdatedEventPayload,
4+
CaipAssetType,
45
CaipAssetTypeOrId,
56
} from '@metamask/keyring-api';
67
import {
@@ -921,6 +922,317 @@ describe('MultichainAssetsController', () => {
921922
});
922923
});
923924

925+
describe('addAssets', () => {
926+
it('should add a single asset to account assets list', async () => {
927+
const { controller } = setupController({
928+
state: {
929+
accountsAssets: {
930+
[mockSolanaAccount.id]: [
931+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
932+
],
933+
},
934+
assetsMetadata: mockGetMetadataReturnValue.assets,
935+
allIgnoredAssets: {},
936+
} as MultichainAssetsControllerState,
937+
});
938+
939+
const assetToAdd =
940+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr';
941+
942+
const result = await controller.addAssets(
943+
[assetToAdd],
944+
mockSolanaAccount.id,
945+
);
946+
947+
expect(result).toStrictEqual([
948+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
949+
assetToAdd,
950+
]);
951+
expect(
952+
controller.state.accountsAssets[mockSolanaAccount.id],
953+
).toStrictEqual([
954+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
955+
assetToAdd,
956+
]);
957+
});
958+
959+
it('should not add duplicate assets', async () => {
960+
const existingAsset =
961+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
962+
const { controller } = setupController({
963+
state: {
964+
accountsAssets: {
965+
[mockSolanaAccount.id]: [existingAsset],
966+
},
967+
assetsMetadata: mockGetMetadataReturnValue.assets,
968+
allIgnoredAssets: {},
969+
} as MultichainAssetsControllerState,
970+
});
971+
972+
const result = await controller.addAssets(
973+
[existingAsset],
974+
mockSolanaAccount.id,
975+
);
976+
977+
expect(result).toStrictEqual([existingAsset]);
978+
expect(
979+
controller.state.accountsAssets[mockSolanaAccount.id],
980+
).toStrictEqual([existingAsset]);
981+
});
982+
983+
it('should remove asset from ignored list when added', async () => {
984+
const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
985+
const { controller } = setupController({
986+
state: {
987+
accountsAssets: {},
988+
assetsMetadata: mockGetMetadataReturnValue.assets,
989+
allIgnoredAssets: {
990+
[mockSolanaAccount.id]: [assetToAdd],
991+
},
992+
} as MultichainAssetsControllerState,
993+
});
994+
995+
const result = await controller.addAssets(
996+
[assetToAdd],
997+
mockSolanaAccount.id,
998+
);
999+
1000+
expect(result).toStrictEqual([assetToAdd]);
1001+
expect(
1002+
controller.state.accountsAssets[mockSolanaAccount.id],
1003+
).toStrictEqual([assetToAdd]);
1004+
expect(
1005+
controller.state.allIgnoredAssets[mockSolanaAccount.id],
1006+
).toBeUndefined();
1007+
});
1008+
1009+
it('should handle adding asset to account with no existing assets', async () => {
1010+
const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
1011+
const { controller } = setupController({
1012+
state: {
1013+
accountsAssets: {},
1014+
assetsMetadata: mockGetMetadataReturnValue.assets,
1015+
allIgnoredAssets: {},
1016+
} as MultichainAssetsControllerState,
1017+
});
1018+
1019+
const result = await controller.addAssets(
1020+
[assetToAdd],
1021+
mockSolanaAccount.id,
1022+
);
1023+
1024+
expect(result).toStrictEqual([assetToAdd]);
1025+
expect(
1026+
controller.state.accountsAssets[mockSolanaAccount.id],
1027+
).toStrictEqual([assetToAdd]);
1028+
});
1029+
1030+
it('should publish accountAssetListUpdated event when asset is added', async () => {
1031+
const { controller, messenger } = setupController({
1032+
state: {
1033+
accountsAssets: {},
1034+
assetsMetadata: mockGetMetadataReturnValue.assets,
1035+
allIgnoredAssets: {},
1036+
} as MultichainAssetsControllerState,
1037+
});
1038+
1039+
const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
1040+
1041+
// Set up event listener to capture the published event
1042+
const eventListener = jest.fn();
1043+
messenger.subscribe(
1044+
'MultichainAssetsController:accountAssetListUpdated',
1045+
eventListener,
1046+
);
1047+
1048+
await controller.addAssets([assetToAdd], mockSolanaAccount.id);
1049+
1050+
expect(eventListener).toHaveBeenCalledWith({
1051+
assets: {
1052+
[mockSolanaAccount.id]: {
1053+
added: [assetToAdd],
1054+
removed: [],
1055+
},
1056+
},
1057+
});
1058+
});
1059+
1060+
it('should add multiple assets from the same chain', async () => {
1061+
const { controller } = setupController({
1062+
state: {
1063+
accountsAssets: {
1064+
[mockSolanaAccount.id]: [
1065+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
1066+
],
1067+
},
1068+
assetsMetadata: mockGetMetadataReturnValue.assets,
1069+
allIgnoredAssets: {},
1070+
} as MultichainAssetsControllerState,
1071+
});
1072+
1073+
const assetsToAdd: CaipAssetType[] = [
1074+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr',
1075+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:AnotherTokenAddress',
1076+
];
1077+
1078+
const result = await controller.addAssets(
1079+
assetsToAdd,
1080+
mockSolanaAccount.id,
1081+
);
1082+
1083+
expect(result).toStrictEqual([
1084+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
1085+
...assetsToAdd,
1086+
]);
1087+
expect(
1088+
controller.state.accountsAssets[mockSolanaAccount.id],
1089+
).toStrictEqual([
1090+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
1091+
...assetsToAdd,
1092+
]);
1093+
});
1094+
1095+
it('should throw error when assets are from different chains', async () => {
1096+
const { controller } = setupController({
1097+
state: {
1098+
accountsAssets: {},
1099+
assetsMetadata: mockGetMetadataReturnValue.assets,
1100+
allIgnoredAssets: {},
1101+
} as MultichainAssetsControllerState,
1102+
});
1103+
1104+
const assetsFromDifferentChains: CaipAssetType[] = [
1105+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501',
1106+
'eip155:1/slip44:60', // Ethereum asset
1107+
];
1108+
1109+
await expect(
1110+
controller.addAssets(assetsFromDifferentChains, mockSolanaAccount.id),
1111+
).rejects.toThrow(
1112+
'All assets must belong to the same chain. Found assets from chains: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1, eip155:1',
1113+
);
1114+
});
1115+
1116+
it('should return existing assets when empty array is provided', async () => {
1117+
const existingAsset =
1118+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
1119+
const { controller } = setupController({
1120+
state: {
1121+
accountsAssets: {
1122+
[mockSolanaAccount.id]: [existingAsset],
1123+
},
1124+
assetsMetadata: mockGetMetadataReturnValue.assets,
1125+
allIgnoredAssets: {},
1126+
} as MultichainAssetsControllerState,
1127+
});
1128+
1129+
const result = await controller.addAssets([], mockSolanaAccount.id);
1130+
1131+
expect(result).toStrictEqual([existingAsset]);
1132+
});
1133+
1134+
it('should only publish event for newly added assets', async () => {
1135+
const existingAsset =
1136+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
1137+
const newAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken';
1138+
1139+
const { controller, messenger } = setupController({
1140+
state: {
1141+
accountsAssets: {
1142+
[mockSolanaAccount.id]: [existingAsset],
1143+
},
1144+
assetsMetadata: mockGetMetadataReturnValue.assets,
1145+
allIgnoredAssets: {},
1146+
} as MultichainAssetsControllerState,
1147+
});
1148+
1149+
const eventListener = jest.fn();
1150+
messenger.subscribe(
1151+
'MultichainAssetsController:accountAssetListUpdated',
1152+
eventListener,
1153+
);
1154+
1155+
await controller.addAssets(
1156+
[existingAsset, newAsset],
1157+
mockSolanaAccount.id,
1158+
);
1159+
1160+
expect(eventListener).toHaveBeenCalledWith({
1161+
assets: {
1162+
[mockSolanaAccount.id]: {
1163+
added: [newAsset], // Only the new asset should be in the event
1164+
removed: [],
1165+
},
1166+
},
1167+
});
1168+
});
1169+
1170+
it('should not publish event when no new assets are added', async () => {
1171+
const existingAsset =
1172+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
1173+
1174+
const { controller, messenger } = setupController({
1175+
state: {
1176+
accountsAssets: {
1177+
[mockSolanaAccount.id]: [existingAsset],
1178+
},
1179+
assetsMetadata: mockGetMetadataReturnValue.assets,
1180+
allIgnoredAssets: {},
1181+
} as MultichainAssetsControllerState,
1182+
});
1183+
1184+
const eventListener = jest.fn();
1185+
messenger.subscribe(
1186+
'MultichainAssetsController:accountAssetListUpdated',
1187+
eventListener,
1188+
);
1189+
1190+
await controller.addAssets([existingAsset], mockSolanaAccount.id);
1191+
1192+
// Event should not be published since no new assets were added
1193+
expect(eventListener).not.toHaveBeenCalled();
1194+
});
1195+
1196+
it('should partially remove assets from ignored list when only some are added', async () => {
1197+
const ignoredAsset1 =
1198+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';
1199+
const ignoredAsset2 =
1200+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token1';
1201+
const ignoredAsset3 =
1202+
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token2';
1203+
1204+
const { controller } = setupController({
1205+
state: {
1206+
accountsAssets: {},
1207+
assetsMetadata: mockGetMetadataReturnValue.assets,
1208+
allIgnoredAssets: {
1209+
[mockSolanaAccount.id]: [
1210+
ignoredAsset1,
1211+
ignoredAsset2,
1212+
ignoredAsset3,
1213+
],
1214+
},
1215+
} as MultichainAssetsControllerState,
1216+
});
1217+
1218+
// Only add two of the three ignored assets
1219+
await controller.addAssets(
1220+
[ignoredAsset1, ignoredAsset2],
1221+
mockSolanaAccount.id,
1222+
);
1223+
1224+
// Should have added the two assets
1225+
expect(
1226+
controller.state.accountsAssets[mockSolanaAccount.id],
1227+
).toStrictEqual([ignoredAsset1, ignoredAsset2]);
1228+
1229+
// Should have only the third asset remaining in ignored list
1230+
expect(
1231+
controller.state.allIgnoredAssets[mockSolanaAccount.id],
1232+
).toStrictEqual([ignoredAsset3]);
1233+
});
1234+
});
1235+
9241236
describe('asset detection with ignored assets', () => {
9251237
it('should filter out ignored assets when account assets are updated', async () => {
9261238
const ignoredAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501';

0 commit comments

Comments
 (0)