Skip to content

Commit 8d6c095

Browse files
authored
feat: show custom token with toggle (#42829)
This PR is to fix: 1. Show imported custom token in the token list 2. If a user manually hides a token, they should be able to import it with custom token route ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 3. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** 1. Start extension with assets unified state enabled (ASSETS_UNIFIED_STATE_ENABLED=true) and remote flag assetsUnifyState.enabled=true, featureVersion='1'. 2. Open wallet, go to Manage tokens, and ensure an ERC20 token can be found/imported (or use one already imported). 4. Hide that token from Manage tokens (toggle off), then leave and return to confirm it is hidden. 5. Go to Add custom token and enter the same token contract address you just hid. 6. Verify you do not see the error “Token has already been added”. 7. Verify symbol/decimals autofill still works and the Import button becomes enabled. 8. Click Import and verify you are returned to Manage tokens with the success toast. 9. In Manage tokens, verify that token is now visible again with toggle on. 10. Validate regression case: with a token that is already visible (not hidden), open Add custom token and enter that token’s address. 11. Verify you do see “Token has already been added” and import remains blocked. 12. Validate legacy behavior toggle: disable remote flag (assetsUnifyState.enabled=false) and repeat step 9 to confirm existing “already added” validation still works as before. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/8c6372f9-eab2-477e-bf1f-e14a3f765fb8 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Updates custom token import behavior under the `assets-unify-state` flag, affecting token visibility/rehydration and dispatching additional controller actions; mistakes could cause tokens not to appear or to be incorrectly marked hidden/visible. > > **Overview** > Fixes custom token import when **`assets-unify-state` is enabled** by (1) treating *hidden* assets in `assetPreferences` as eligible for re-import (so the “tokenAlreadyAdded” guard won’t block them) and (2) dispatching `importCustomAssetsBatch` on submit to seed AssetsController metadata so newly imported tokens show up in Manage Tokens. > > Adds `name` to the autofill state and persists `name`/`symbol` separately in the AssetsController metadata (defaulting `name` to `symbol` if missing), while preserving legacy behavior when the feature flag is off. > > Extends `custom-token-import` unit tests to cover the new flag-gated behavior (including hidden-token re-import) and updates the Jest console baseline for the added test warnings. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 51467ca. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d27b6e1 commit 8d6c095

3 files changed

Lines changed: 330 additions & 24 deletions

File tree

test/jest/console-baseline-unit.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,9 @@
13721372
"ui/pages/confirmations/utils/confirm.test.ts": {
13731373
"error: Failed to detect if URL": 1
13741374
},
1375+
"ui/pages/custom-token-import/custom-token-import.test.tsx": {
1376+
"React: Act warnings (component updates not wrapped)": 2
1377+
},
13751378
"ui/pages/defi/components/defi-details-page.test.tsx": {
13761379
"Reselect: Identity function warnings": 1
13771380
},

ui/pages/custom-token-import/custom-token-import.test.tsx

Lines changed: 235 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ jest.mock('react-router-dom', () => {
3838
};
3939
});
4040

41+
jest.mock('../../../shared/lib/assets-unify-state/remote-feature-flag', () =>
42+
jest.requireActual(
43+
'../../../shared/lib/assets-unify-state/remote-feature-flag',
44+
),
45+
);
46+
4147
// The page kicks off real on-chain probes through `getTokenStandardAndDetailsByChain`
4248
// and `tokenInfoGetter`. Replace them with deterministic stubs so the unit
4349
// test never reaches the background script.
@@ -46,6 +52,7 @@ jest.mock('../../store/actions', () => {
4652
return {
4753
...actual,
4854
addImportedTokens: jest.fn(() => () => Promise.resolve()),
55+
importCustomAssetsBatch: jest.fn(() => () => Promise.resolve()),
4956
getTokenStandardAndDetailsByChain: jest.fn().mockResolvedValue({
5057
standard: 'ERC20',
5158
symbol: 'APE',
@@ -69,15 +76,25 @@ jest.mock('../../helpers/utils/token-util', () => {
6976
const getMockedActions = () =>
7077
jest.requireMock('../../store/actions') as {
7178
addImportedTokens: jest.Mock;
79+
importCustomAssetsBatch: jest.Mock;
7280
};
7381

82+
const ASSETS_UNIFY_STATE_FLAG_ON = {
83+
assetsUnifyState: {
84+
enabled: true,
85+
featureVersion: '1',
86+
},
87+
};
88+
7489
describe('CustomTokenImportPage', () => {
7590
beforeEach(() => {
7691
mockNavigate.mockClear();
77-
getMockedActions().addImportedTokens.mockClear();
92+
const actions = getMockedActions();
93+
actions.addImportedTokens.mockClear();
94+
actions.importCustomAssetsBatch.mockClear();
7895
});
7996

80-
const buildState = () => ({
97+
const buildState = (metamaskOverrides: Record<string, unknown> = {}) => ({
8198
...mockState,
8299
metamask: {
83100
...mockState.metamask,
@@ -98,11 +115,15 @@ describe('CustomTokenImportPage', () => {
98115
],
99116
},
100117
},
118+
...metamaskOverrides,
101119
},
102120
});
103121

104-
const renderPage = (trackEvent = jest.fn()) => {
105-
const store = configureStore(buildState());
122+
const renderPage = (
123+
trackEvent = jest.fn(),
124+
metamaskOverrides: Record<string, unknown> = {},
125+
) => {
126+
const store = configureStore(buildState(metamaskOverrides));
106127
return {
107128
store,
108129
...renderWithProvider(
@@ -115,6 +136,28 @@ describe('CustomTokenImportPage', () => {
115136
};
116137
};
117138

139+
const submitCustomToken = async (
140+
metamaskOverrides: Record<string, unknown> = {},
141+
) => {
142+
const trackEvent = jest.fn();
143+
const rendered = renderPage(trackEvent, metamaskOverrides);
144+
145+
fireEvent.change(screen.getByTestId('custom-token-import-address-input'), {
146+
target: { value: '0x1111111111111111111111111111111111111111' },
147+
});
148+
149+
await screen.findByTestId('custom-token-import-symbol-input');
150+
await waitFor(() =>
151+
expect(
152+
screen.getByTestId('custom-token-import-submit-button'),
153+
).not.toBeDisabled(),
154+
);
155+
156+
fireEvent.click(screen.getByTestId('custom-token-import-submit-button'));
157+
158+
return { trackEvent, ...rendered };
159+
};
160+
118161
it('renders the form scaffolding without crashing', () => {
119162
renderPage();
120163

@@ -185,21 +228,7 @@ describe('CustomTokenImportPage', () => {
185228

186229
it('returns to token management with success toast state after submitting a custom token', async () => {
187230
const actions = getMockedActions();
188-
const trackEvent = jest.fn();
189-
renderPage(trackEvent);
190-
191-
fireEvent.change(screen.getByTestId('custom-token-import-address-input'), {
192-
target: { value: '0x1111111111111111111111111111111111111111' },
193-
});
194-
195-
await screen.findByTestId('custom-token-import-symbol-input');
196-
await waitFor(() =>
197-
expect(
198-
screen.getByTestId('custom-token-import-submit-button'),
199-
).not.toBeDisabled(),
200-
);
201-
202-
fireEvent.click(screen.getByTestId('custom-token-import-submit-button'));
231+
const { trackEvent } = await submitCustomToken();
203232

204233
await waitFor(() =>
205234
expect(actions.addImportedTokens).toHaveBeenCalledWith(
@@ -241,4 +270,191 @@ describe('CustomTokenImportPage', () => {
241270
}),
242271
);
243272
});
273+
274+
describe('when the assets-unify-state remote feature flag is enabled', () => {
275+
it('seeds AssetsController via importCustomAssetsBatch so the token appears in the manage-tokens list', async () => {
276+
const actions = getMockedActions();
277+
278+
await submitCustomToken({
279+
remoteFeatureFlags: ASSETS_UNIFY_STATE_FLAG_ON,
280+
});
281+
282+
const expectedAssetId =
283+
'eip155:1/erc20:0x1111111111111111111111111111111111111111';
284+
285+
await waitFor(() =>
286+
expect(actions.importCustomAssetsBatch).toHaveBeenCalledWith(
287+
'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3',
288+
[
289+
{
290+
assetId: expectedAssetId,
291+
isHidden: false,
292+
},
293+
],
294+
{
295+
[expectedAssetId]: expect.objectContaining({
296+
address: '0x1111111111111111111111111111111111111111',
297+
symbol: 'APE',
298+
name: 'ApeCoin',
299+
decimals: 18,
300+
chainId: '0x1',
301+
unlisted: true,
302+
}),
303+
},
304+
),
305+
);
306+
307+
await waitFor(() => expect(actions.addImportedTokens).toHaveBeenCalled());
308+
});
309+
310+
it('passes the full token name (not the symbol) as the name field in metadata', async () => {
311+
const actions = getMockedActions();
312+
313+
await submitCustomToken({
314+
remoteFeatureFlags: ASSETS_UNIFY_STATE_FLAG_ON,
315+
});
316+
317+
const expectedAssetId =
318+
'eip155:1/erc20:0x1111111111111111111111111111111111111111';
319+
320+
await waitFor(() => {
321+
const metadataArg = actions.importCustomAssetsBatch.mock.calls[0][2];
322+
const metadata = metadataArg[expectedAssetId];
323+
// tokenInfoGetter mock returns name: 'ApeCoin' and symbol: 'APE'.
324+
// These must be stored separately; the symbol must not be used in place
325+
// of the name.
326+
expect(metadata.name).toBe('ApeCoin');
327+
expect(metadata.symbol).toBe('APE');
328+
expect(metadata.name).not.toBe(metadata.symbol);
329+
});
330+
});
331+
332+
it('marks the asset as previously hidden when assetPreferences has hidden: true for it', async () => {
333+
const actions = getMockedActions();
334+
const assetId =
335+
'eip155:1/erc20:0x1111111111111111111111111111111111111111';
336+
337+
await submitCustomToken({
338+
remoteFeatureFlags: ASSETS_UNIFY_STATE_FLAG_ON,
339+
assetPreferences: {
340+
[assetId]: { hidden: true },
341+
},
342+
});
343+
344+
await waitFor(() =>
345+
expect(actions.importCustomAssetsBatch).toHaveBeenCalledWith(
346+
'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3',
347+
[
348+
{
349+
assetId,
350+
isHidden: true,
351+
},
352+
],
353+
expect.any(Object),
354+
),
355+
);
356+
});
357+
358+
it('allows re-importing a token that is currently hidden in assetPreferences without showing "tokenAlreadyAdded"', async () => {
359+
const actions = getMockedActions();
360+
const tokenAddress = '0x1111111111111111111111111111111111111111';
361+
const assetId = `eip155:1/erc20:${tokenAddress}`;
362+
const accountId = 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3';
363+
364+
renderPage(jest.fn(), {
365+
remoteFeatureFlags: ASSETS_UNIFY_STATE_FLAG_ON,
366+
// Simulate state after the user hid the token from the manage tokens
367+
// list when assets-unify-state is on: the token remains in
368+
// `customAssets` (so the unified `getAllTokens` selector still
369+
// returns it) and `assetPreferences[assetId].hidden` is `true`.
370+
customAssets: { [accountId]: [assetId] },
371+
assetsInfo: {
372+
[assetId]: {
373+
type: 'erc20',
374+
symbol: 'APE',
375+
name: 'ApeCoin',
376+
decimals: 18,
377+
},
378+
},
379+
assetPreferences: {
380+
[assetId]: { hidden: true },
381+
},
382+
});
383+
384+
fireEvent.change(
385+
screen.getByTestId('custom-token-import-address-input'),
386+
{ target: { value: tokenAddress } },
387+
);
388+
389+
await screen.findByTestId('custom-token-import-symbol-input');
390+
391+
expect(
392+
screen.queryByText(messages.tokenAlreadyAdded.message),
393+
).not.toBeInTheDocument();
394+
await waitFor(() =>
395+
expect(
396+
screen.getByTestId('custom-token-import-submit-button'),
397+
).not.toBeDisabled(),
398+
);
399+
400+
fireEvent.click(screen.getByTestId('custom-token-import-submit-button'));
401+
402+
await waitFor(() =>
403+
expect(actions.importCustomAssetsBatch).toHaveBeenCalledWith(
404+
accountId,
405+
[
406+
{
407+
assetId,
408+
isHidden: true,
409+
},
410+
],
411+
expect.any(Object),
412+
),
413+
);
414+
});
415+
});
416+
417+
it('still shows "tokenAlreadyAdded" for a visible (non-hidden) token even when assets-unify-state is enabled', async () => {
418+
const tokenAddress = '0x1111111111111111111111111111111111111111';
419+
const assetId = `eip155:1/erc20:${tokenAddress}`;
420+
const accountId = 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3';
421+
422+
renderPage(jest.fn(), {
423+
remoteFeatureFlags: ASSETS_UNIFY_STATE_FLAG_ON,
424+
// Token is present in `customAssets` but NOT hidden: the unified
425+
// selector should still return it, so the "already added" guard
426+
// must trigger.
427+
customAssets: { [accountId]: [assetId] },
428+
assetsInfo: {
429+
[assetId]: {
430+
type: 'erc20',
431+
symbol: 'APE',
432+
name: 'ApeCoin',
433+
decimals: 18,
434+
},
435+
},
436+
});
437+
438+
fireEvent.change(screen.getByTestId('custom-token-import-address-input'), {
439+
target: { value: tokenAddress },
440+
});
441+
442+
await waitFor(() =>
443+
expect(
444+
screen.getByText(messages.tokenAlreadyAdded.message),
445+
).toBeInTheDocument(),
446+
);
447+
expect(
448+
screen.getByTestId('custom-token-import-submit-button'),
449+
).toBeDisabled();
450+
});
451+
452+
it('does not call importCustomAssetsBatch when the assets-unify-state remote feature flag is off', async () => {
453+
const actions = getMockedActions();
454+
455+
await submitCustomToken();
456+
457+
await waitFor(() => expect(actions.addImportedTokens).toHaveBeenCalled());
458+
expect(actions.importCustomAssetsBatch).not.toHaveBeenCalled();
459+
});
244460
});

0 commit comments

Comments
 (0)