Skip to content

Commit 372d21e

Browse files
authored
feat(AssetController): add hiddenAssets state (#7777)
## Explanation This PR adds support for hidden assets in the `AssetsController`, similar to how `allIgnoredAssets` works in `MultichainAssetsController`. **Current State:** Users currently have no way to hide assets they don't want to see or track in the new AssetsController architecture. The `MultichainAssetsController` already has this capability via `allIgnoredAssets`, but the new unified `AssetsController` was missing this feature. **Solution:** Added a `hiddenAssets` field to the `AssetsController` state that stores hidden assets per account (CAIP-19 asset IDs). This allows users to hide assets they don't want to track and/or see. **Changes:** 1. **State**: Added `hiddenAssets: { [accountId: string]: string[] }` to `AssetsControllerState` 2. **Methods**: Added `hideAsset()`, `unhideAsset()`, `getHiddenAssets()`, and `isAssetHidden()` methods 3. **Actions**: Added corresponding action types (`AssetsControllerHideAssetAction`, `AssetsControllerUnhideAssetAction`, `AssetsControllerGetHiddenAssetsAction`) and registered handlers 4. **Custom Assets Integration**: When a custom asset is added via `addCustomAsset()`, it's automatically unhidden if it was previously hidden (matching the behavior of `MultichainAssetsController.addAssets()`) 5. **RPC Tracking**: Updated `RpcDataSource` and `BalanceFetcher` to filter out hidden assets from balance polling, so hidden assets won't be tracked or updated **Type Updates:** - Added `hiddenAssets` to `AssetsControllerState` - Added `hiddenAssets` to `AssetsControllerStateInternal` - Added `hiddenAssets` to `AssetsBalanceState` in evm-rpc-services types ## References - Related to the Hide Token feature for the new unified AssetsController architecture - Pattern follows `allIgnoredAssets` in `MultichainAssetsController` ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] 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/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new persisted `assetPreferences` state slice and changes `getAssets` to filter hidden assets, which can affect UI output and downstream consumers relying on prior state shape/types. > > **Overview** > Adds a new persisted `assetPreferences` state (with `AssetPreferences`) to store per-asset UI flags like `hidden`, and introduces `hideAsset`/`unhideAsset` actions to manage this globally. > > Updates `getAssets` to exclude hidden assets from returned results (while leaving balance/price tracking intact), and ensures `addCustomAsset` automatically unhides an asset if it was previously hidden. > > Tightens `AssetsControllerState` typing from generic `Json`/`string[]` to semantic types (`AssetMetadata`, `AssetBalance`, `AssetPrice`, `Caip19AssetId[]`), and adjusts `RpcDataSource`/tests to match the narrowed `AssetsController:getState` expectations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a661f7e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent df7c506 commit 372d21e

File tree

7 files changed

+152
-40
lines changed

7 files changed

+152
-40
lines changed

packages/assets-controller/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `assetPreferences` state and `AssetPreferences` type for per-asset UI preferences (e.g. `hidden`), separate from `assetsMetadata` ([#7777](https://github.com/MetaMask/core/pull/7777))
13+
- Add `hideAsset(assetId)`, `unhideAsset(assetId)` for managing hidden assets globally; hidden assets are excluded from `getAssets` but balance updates continue to be tracked ([#7777](https://github.com/MetaMask/core/pull/7777))
14+
15+
### Changed
16+
17+
- Narrow `AssetsControllerState` types from `Json` to semantic types: `assetsMetadata``AssetMetadata`, `assetsBalance``AssetBalance`, `assetsPrice``AssetPrice`, `assetPreferences``AssetPreferences`, `customAssets``Caip19AssetId[]` ([#7777](https://github.com/MetaMask/core/pull/7777))
18+
1019
## [0.1.0]
1120

1221
### Added

packages/assets-controller/src/AssetsController.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ describe('AssetsController', () => {
201201
assetsBalance: {},
202202
assetsPrice: {},
203203
customAssets: {},
204+
assetPreferences: {},
204205
});
205206
});
206207
});
@@ -213,6 +214,7 @@ describe('AssetsController', () => {
213214
assetsBalance: {},
214215
assetsPrice: {},
215216
customAssets: {},
217+
assetPreferences: {},
216218
});
217219
});
218220
});
@@ -291,6 +293,7 @@ describe('AssetsController', () => {
291293

292294
// Controller should still have default state (from super() call)
293295
expect(controller.state).toStrictEqual({
296+
assetPreferences: {},
294297
assetsMetadata: {},
295298
assetsBalance: {},
296299
assetsPrice: {},
@@ -311,6 +314,7 @@ describe('AssetsController', () => {
311314
await withController(({ controller, messenger }) => {
312315
// Controller should have default state
313316
expect(controller.state).toStrictEqual({
317+
assetPreferences: {},
314318
assetsMetadata: {},
315319
assetsBalance: {},
316320
assetsPrice: {},

packages/assets-controller/src/AssetsController.ts

Lines changed: 122 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import type {
1919
NetworkEnablementControllerEvents,
2020
NetworkEnablementControllerState,
2121
} from '@metamask/network-enablement-controller';
22-
import type { Json } from '@metamask/utils';
2322
import { parseCaipAssetType } from '@metamask/utils';
2423
import { Mutex } from 'async-mutex';
2524
import BigNumberJS from 'bignumber.js';
@@ -39,6 +38,7 @@ import { projectLogger, createModuleLogger } from './logger';
3938
import type { DetectionMiddlewareGetAssetsMiddlewareAction } from './middlewares/DetectionMiddleware';
4039
import type {
4140
AccountId,
41+
AssetPreferences,
4242
ChainId,
4343
Caip19AssetId,
4444
AssetMetadata,
@@ -75,34 +75,36 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
7575
/**
7676
* State structure for AssetsController.
7777
*
78-
* All values are JSON-serializable. The type is widened to satisfy
79-
* StateConstraint from BaseController, but the actual runtime values
80-
* conform to AssetMetadata, AssetPrice, and AssetBalance interfaces.
78+
* All values are JSON-serializable. UI preferences (e.g. hidden) are in
79+
* assetPreferences, not in metadata.
8180
*
8281
* @see AssetsControllerStateInternal for the semantic type structure
8382
*/
8483
export type AssetsControllerState = {
8584
/** Shared metadata for all assets (stored once per asset) */
86-
assetsMetadata: { [assetId: string]: Json };
85+
assetsMetadata: { [assetId: string]: AssetMetadata };
8786
/** Per-account balance data */
88-
assetsBalance: { [accountId: string]: { [assetId: string]: Json } };
87+
assetsBalance: { [accountId: string]: { [assetId: string]: AssetBalance } };
8988
/** Price data for assets */
90-
assetsPrice: { [assetId: string]: Json };
89+
assetsPrice: { [assetId: string]: AssetPrice };
9190
/** Custom assets added by users per account (CAIP-19 asset IDs) */
92-
customAssets: { [accountId: string]: string[] };
91+
customAssets: { [accountId: string]: Caip19AssetId[] };
92+
/** UI preferences per asset (e.g. hidden) */
93+
assetPreferences: { [assetId: string]: AssetPreferences };
9394
};
9495

9596
/**
9697
* Returns the default state for AssetsController.
9798
*
98-
* @returns The default AssetsController state with empty metadata, balance, price, and customAssets maps.
99+
* @returns The default AssetsController state with empty maps.
99100
*/
100101
export function getDefaultAssetsControllerState(): AssetsControllerState {
101102
return {
102103
assetsMetadata: {},
103104
assetsBalance: {},
104105
assetsPrice: {},
105106
customAssets: {},
107+
assetPreferences: {},
106108
};
107109
}
108110

@@ -160,6 +162,16 @@ export type AssetsControllerGetCustomAssetsAction = {
160162
handler: AssetsController['getCustomAssets'];
161163
};
162164

165+
export type AssetsControllerHideAssetAction = {
166+
type: `${typeof CONTROLLER_NAME}:hideAsset`;
167+
handler: AssetsController['hideAsset'];
168+
};
169+
170+
export type AssetsControllerUnhideAssetAction = {
171+
type: `${typeof CONTROLLER_NAME}:unhideAsset`;
172+
handler: AssetsController['unhideAsset'];
173+
};
174+
163175
export type AssetsControllerActions =
164176
| AssetsControllerGetStateAction
165177
| AssetsControllerGetAssetsAction
@@ -170,7 +182,9 @@ export type AssetsControllerActions =
170182
| AssetsControllerAssetsUpdateAction
171183
| AssetsControllerAddCustomAssetAction
172184
| AssetsControllerRemoveCustomAssetAction
173-
| AssetsControllerGetCustomAssetsAction;
185+
| AssetsControllerGetCustomAssetsAction
186+
| AssetsControllerHideAssetAction
187+
| AssetsControllerUnhideAssetAction;
174188

175189
export type AssetsControllerStateChangeEvent = ControllerStateChangeEvent<
176190
typeof CONTROLLER_NAME,
@@ -292,6 +306,12 @@ const stateMetadata: StateMetadata<AssetsControllerState> = {
292306
includeInDebugSnapshot: false,
293307
usedInUi: true,
294308
},
309+
assetPreferences: {
310+
persist: true,
311+
includeInStateLogs: false,
312+
includeInDebugSnapshot: false,
313+
usedInUi: true,
314+
},
295315
};
296316

297317
// ============================================================================
@@ -627,6 +647,16 @@ export class AssetsController extends BaseController<
627647
'AssetsController:getCustomAssets',
628648
this.getCustomAssets.bind(this),
629649
);
650+
651+
this.messenger.registerActionHandler(
652+
'AssetsController:hideAsset',
653+
this.hideAsset.bind(this),
654+
);
655+
656+
this.messenger.registerActionHandler(
657+
'AssetsController:unhideAsset',
658+
this.unhideAsset.bind(this),
659+
);
630660
}
631661

632662
// ============================================================================
@@ -872,6 +902,7 @@ export class AssetsController extends BaseController<
872902
/**
873903
* Add a custom asset for an account.
874904
* Custom assets are included in subscription and fetch operations.
905+
* Adding a custom asset also unhides it if it was previously hidden.
875906
*
876907
* @param accountId - The account ID to add the custom asset for.
877908
* @param assetId - The CAIP-19 asset ID to add.
@@ -894,6 +925,15 @@ export class AssetsController extends BaseController<
894925
if (!customAssets[accountId].includes(normalizedAssetId)) {
895926
customAssets[accountId].push(normalizedAssetId);
896927
}
928+
929+
// Unhide the asset if it was hidden (via assetPreferences)
930+
const prefs = state.assetPreferences[normalizedAssetId];
931+
if (prefs?.hidden) {
932+
delete prefs.hidden;
933+
if (Object.keys(prefs).length === 0) {
934+
delete state.assetPreferences[normalizedAssetId];
935+
}
936+
}
897937
});
898938

899939
// Fetch data for the newly added custom asset
@@ -919,15 +959,14 @@ export class AssetsController extends BaseController<
919959
log('Removing custom asset', { accountId, assetId: normalizedAssetId });
920960

921961
this.update((state) => {
922-
const customAssets = state.customAssets as Record<string, string[]>;
923-
if (customAssets[accountId]) {
924-
customAssets[accountId] = customAssets[accountId].filter(
962+
if (state.customAssets[accountId]) {
963+
state.customAssets[accountId] = state.customAssets[accountId].filter(
925964
(id) => id !== normalizedAssetId,
926965
);
927966

928967
// Clean up empty arrays
929-
if (customAssets[accountId].length === 0) {
930-
delete customAssets[accountId];
968+
if (state.customAssets[accountId].length === 0) {
969+
delete state.customAssets[accountId];
931970
}
932971
}
933972
});
@@ -940,7 +979,52 @@ export class AssetsController extends BaseController<
940979
* @returns Array of CAIP-19 asset IDs for the account's custom assets.
941980
*/
942981
getCustomAssets(accountId: AccountId): Caip19AssetId[] {
943-
return (this.state.customAssets[accountId] ?? []) as Caip19AssetId[];
982+
return this.state.customAssets[accountId] ?? [];
983+
}
984+
985+
// ============================================================================
986+
// HIDDEN ASSETS MANAGEMENT
987+
// ============================================================================
988+
989+
/**
990+
* Hide an asset globally.
991+
* Hidden assets are excluded from the asset list returned by getAssets.
992+
* The hidden state is stored in assetPreferences.
993+
*
994+
* @param assetId - The CAIP-19 asset ID to hide.
995+
*/
996+
hideAsset(assetId: Caip19AssetId): void {
997+
const normalizedAssetId = normalizeAssetId(assetId);
998+
999+
log('Hiding asset', { assetId: normalizedAssetId });
1000+
1001+
this.update((state) => {
1002+
if (!state.assetPreferences[normalizedAssetId]) {
1003+
state.assetPreferences[normalizedAssetId] = {};
1004+
}
1005+
state.assetPreferences[normalizedAssetId].hidden = true;
1006+
});
1007+
}
1008+
1009+
/**
1010+
* Unhide an asset globally.
1011+
*
1012+
* @param assetId - The CAIP-19 asset ID to unhide.
1013+
*/
1014+
unhideAsset(assetId: Caip19AssetId): void {
1015+
const normalizedAssetId = normalizeAssetId(assetId);
1016+
1017+
log('Unhiding asset', { assetId: normalizedAssetId });
1018+
1019+
this.update((state) => {
1020+
const prefs = state.assetPreferences[normalizedAssetId];
1021+
if (prefs) {
1022+
delete prefs.hidden;
1023+
if (Object.keys(prefs).length === 0) {
1024+
delete state.assetPreferences[normalizedAssetId];
1025+
}
1026+
}
1027+
});
9441028
}
9451029

9461030
// ============================================================================
@@ -1083,16 +1167,13 @@ export class AssetsController extends BaseController<
10831167
const changedMetadata: string[] = [];
10841168

10851169
this.update((state) => {
1086-
// Use type assertions to avoid deep type instantiation issues with Draft<Json>
1087-
const metadata = state.assetsMetadata as unknown as Record<
1088-
string,
1089-
unknown
1090-
>;
1091-
const balances = state.assetsBalance as unknown as Record<
1170+
// Use type assertions to avoid deep type instantiation issues with Immer Draft types
1171+
const metadata = state.assetsMetadata as Record<string, AssetMetadata>;
1172+
const balances = state.assetsBalance as Record<
10921173
string,
1093-
Record<string, unknown>
1174+
Record<string, AssetBalance>
10941175
>;
1095-
const prices = state.assetsPrice as unknown as Record<string, unknown>;
1176+
const prices = state.assetsPrice as Record<string, AssetPrice>;
10961177

10971178
if (normalizedResponse.assetsMetadata) {
10981179
for (const [key, value] of Object.entries(
@@ -1247,11 +1328,6 @@ export class AssetsController extends BaseController<
12471328

12481329
for (const [assetId, balance] of Object.entries(accountBalances)) {
12491330
const typedAssetId = assetId as Caip19AssetId;
1250-
const assetChainId = extractChainId(typedAssetId);
1251-
1252-
if (!chainIdSet.has(assetChainId)) {
1253-
continue;
1254-
}
12551331

12561332
const metadataRaw = this.state.assetsMetadata[typedAssetId];
12571333

@@ -1260,16 +1336,28 @@ export class AssetsController extends BaseController<
12601336
continue;
12611337
}
12621338

1263-
const metadata = metadataRaw as AssetMetadata;
1339+
const metadata = metadataRaw;
1340+
1341+
// Skip hidden assets (assetPreferences)
1342+
const prefs = this.state.assetPreferences[typedAssetId];
1343+
if (prefs?.hidden) {
1344+
continue;
1345+
}
1346+
1347+
const assetChainId = extractChainId(typedAssetId);
1348+
1349+
if (!chainIdSet.has(assetChainId)) {
1350+
continue;
1351+
}
12641352

12651353
// Filter by asset type
12661354
const tokenAssetType = this.#tokenStandardToAssetType(metadata.type);
12671355
if (!assetTypeSet.has(tokenAssetType)) {
12681356
continue;
12691357
}
12701358

1271-
const typedBalance = balance as AssetBalance;
1272-
const priceRaw = this.state.assetsPrice[typedAssetId] as AssetPrice;
1359+
const typedBalance = balance;
1360+
const priceRaw = this.state.assetsPrice[typedAssetId];
12731361
const price: AssetPrice = priceRaw ?? {
12741362
price: 0,
12751363
lastUpdated: 0,
@@ -1783,5 +1871,7 @@ export class AssetsController extends BaseController<
17831871
'AssetsController:removeCustomAsset',
17841872
);
17851873
this.messenger.unregisterActionHandler('AssetsController:getCustomAssets');
1874+
this.messenger.unregisterActionHandler('AssetsController:hideAsset');
1875+
this.messenger.unregisterActionHandler('AssetsController:unhideAsset');
17861876
}
17871877
}

packages/assets-controller/src/data-sources/RpcDataSource.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,6 @@ async function withController<ReturnValue>(
187187

188188
// Mock AssetsController:getState
189189
messenger.registerActionHandler('AssetsController:getState', () => ({
190-
allTokens: {},
191-
allDetectedTokens: {},
192-
allIgnoredTokens: {},
193190
assetsMetadata: {},
194191
assetsBalance: {},
195192
}));

packages/assets-controller/src/data-sources/RpcDataSource.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,10 @@ type TokenListControllerGetStateAction = {
170170
};
171171
};
172172

173-
// AssetsController:getState action (for user tokens state and assets balance)
173+
// AssetsController:getState action (for assets balance and metadata)
174174
type AssetsControllerGetStateAction = {
175175
type: 'AssetsController:getState';
176176
handler: () => {
177-
allTokens: Record<string, Record<string, { address: string }[]>>;
178-
allDetectedTokens: Record<string, Record<string, { address: string }[]>>;
179-
allIgnoredTokens: Record<string, Record<string, string[]>>;
180177
assetsMetadata: Record<Caip19AssetId, AssetMetadata>;
181178
assetsBalance: Record<string, Record<string, { amount: string }>>;
182179
};
@@ -1304,7 +1301,9 @@ export class RpcDataSource extends BaseController<
13041301
*/
13051302
#getExistingAssetsMetadata(): Record<Caip19AssetId, AssetMetadata> {
13061303
try {
1307-
const state = this.messenger.call('AssetsController:getState');
1304+
const state = this.messenger.call('AssetsController:getState') as {
1305+
assetsMetadata?: Record<Caip19AssetId, AssetMetadata>;
1306+
};
13081307
return (state.assetsMetadata ?? {}) as unknown as Record<
13091308
Caip19AssetId,
13101309
AssetMetadata

packages/assets-controller/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type {
1919
AssetsControllerAddCustomAssetAction,
2020
AssetsControllerRemoveCustomAssetAction,
2121
AssetsControllerGetCustomAssetsAction,
22+
AssetsControllerHideAssetAction,
23+
AssetsControllerUnhideAssetAction,
2224
AssetsControllerActions,
2325
AssetsControllerStateChangeEvent,
2426
AssetsControllerBalanceChangedEvent,

0 commit comments

Comments
 (0)