Skip to content

Commit 12505e7

Browse files
authored
feat: fetch asset metadata on search (#31258)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **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? 2. What is the improvement/solution? --> This changes adds a `useAssetMetadata` hook that fetches asset details if it is not found in existing token lists. I've also added more unit test coverage for the asset-picker token filtering See [Copilot summary for more details](#31258 (review)) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/31258?quickstart=1) ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMS-2094 ## **Manual testing steps** 1. Select solana as active network 2. Go to Bridge page 3. Get the address of a new pump.fun coin 4. Paste address into the asset picker's search bar 5. The token should appear on the list and be selectable 6. After filling out quote params, quotes should be fetched ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** No asset is shown <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/e1cf7185-49f3-4b64-a31f-1232a19a995d <!-- [screenshots/recordings] --> ## **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.
1 parent ad4fa0b commit 12505e7

File tree

12 files changed

+1854
-89
lines changed

12 files changed

+1854
-89
lines changed

shared/lib/asset-utils.test.ts

+297
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import {
2+
CaipAssetType,
3+
CaipAssetTypeStruct,
4+
CaipChainId,
5+
Hex,
6+
} from '@metamask/utils';
7+
import { toEvmCaipChainId } from '@metamask/multichain-network-controller';
8+
import { MultichainNetwork } from '@metamask/multichain-transactions-controller';
9+
import { toHex } from '@metamask/controller-utils';
10+
import { MINUTE } from '../constants/time';
11+
import { MultichainNetworks } from '../constants/multichain/networks';
12+
import fetchWithCache from './fetch-with-cache';
13+
import { getAssetImageUrl, fetchAssetMetadata, toAssetId } from './asset-utils';
14+
15+
jest.mock('./fetch-with-cache');
16+
jest.mock('@metamask/multichain-network-controller');
17+
jest.mock('@metamask/controller-utils');
18+
19+
describe('asset-utils', () => {
20+
const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io';
21+
const TOKEN_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3';
22+
23+
describe('toAssetId', () => {
24+
it('should return the same asset ID if input is already a CAIP asset type', () => {
25+
const caipAssetId = CaipAssetTypeStruct.create('eip155:1/erc20:0x123');
26+
const chainId = 'eip155:1' as CaipChainId;
27+
28+
const result = toAssetId(caipAssetId, chainId);
29+
expect(result).toBe(caipAssetId);
30+
});
31+
32+
it('should create Solana token asset ID correctly', () => {
33+
const address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
34+
const chainId = MultichainNetwork.Solana as CaipChainId;
35+
36+
const result = toAssetId(address, chainId);
37+
expect(result).toBe(`${MultichainNetwork.Solana}/token:${address}`);
38+
});
39+
40+
it('should create EVM token asset ID correctly', () => {
41+
const address = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984';
42+
const chainId = 'eip155:1' as CaipChainId;
43+
44+
const result = toAssetId(address, chainId);
45+
expect(result).toBe(`eip155:1/erc20:${address}`);
46+
});
47+
48+
it('should return undefined for non-hex address on EVM chains', () => {
49+
const address = 'not-a-hex-address';
50+
const chainId = 'eip155:1' as CaipChainId;
51+
52+
const result = toAssetId(address, chainId);
53+
expect(result).toBeUndefined();
54+
});
55+
56+
it('should handle different EVM chain IDs', () => {
57+
const address = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984';
58+
const chainId = 'eip155:137' as CaipChainId;
59+
60+
const result = toAssetId(address, chainId);
61+
expect(result).toBe(`eip155:137/erc20:${address}`);
62+
});
63+
64+
it('should handle checksummed addresses', () => {
65+
const address = '0x1F9840a85d5aF5bf1D1762F925BDADdC4201F984';
66+
const chainId = 'eip155:1' as CaipChainId;
67+
68+
const result = toAssetId(address, chainId);
69+
expect(result).toBe(`eip155:1/erc20:${address}`);
70+
});
71+
});
72+
73+
describe('getAssetImageUrl', () => {
74+
it('should return correct image URL for a CAIP asset ID', () => {
75+
const assetId = 'eip155:1/erc20:0x123' as CaipAssetType;
76+
const expectedUrl = `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png`;
77+
78+
expect(getAssetImageUrl(assetId, 'eip155:1')).toBe(expectedUrl);
79+
});
80+
81+
it('should return correct image URL for non-hex CAIP asset ID', () => {
82+
const assetId =
83+
`${MultichainNetworks.SOLANA}/token:aBCD` as CaipAssetType;
84+
const expectedUrl = `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/aBCD.png`;
85+
86+
expect(getAssetImageUrl(assetId, 'eip155:1')).toBe(expectedUrl);
87+
});
88+
89+
it('should handle asset IDs with multiple colons', () => {
90+
const assetId = 'test:chain:1/token:0x123' as CaipAssetType;
91+
92+
expect(getAssetImageUrl(assetId, 'eip155:1')).toBe(undefined);
93+
});
94+
});
95+
96+
describe('fetchAssetMetadata', () => {
97+
const mockAddress = '0x123' as Hex;
98+
const mockChainId = 'eip155:1' as CaipChainId;
99+
const mockHexChainId = '0x1' as Hex;
100+
const mockAssetId = 'eip155:1/erc20:0x123' as CaipAssetType;
101+
102+
beforeEach(() => {
103+
jest.clearAllMocks();
104+
(toEvmCaipChainId as jest.Mock).mockReturnValue(mockChainId);
105+
(toHex as jest.Mock).mockImplementation((val) => val as Hex);
106+
});
107+
108+
it('should fetch EVM token metadata successfully', async () => {
109+
const mockMetadata = {
110+
assetId: mockAssetId,
111+
symbol: 'TEST',
112+
name: 'Test Token',
113+
decimals: 18,
114+
};
115+
116+
(fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]);
117+
118+
const result = await fetchAssetMetadata(mockAddress, mockHexChainId);
119+
120+
expect(fetchWithCache).toHaveBeenCalledWith({
121+
url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`,
122+
fetchOptions: {
123+
method: 'GET',
124+
headers: { 'X-Client-Id': 'extension' },
125+
},
126+
cacheOptions: {
127+
cacheRefreshTime: MINUTE,
128+
},
129+
functionName: 'fetchAssetMetadata',
130+
});
131+
132+
expect(result).toStrictEqual({
133+
symbol: 'TEST',
134+
decimals: 18,
135+
image:
136+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png',
137+
assetId: mockAssetId,
138+
address: mockAddress,
139+
chainId: mockHexChainId,
140+
});
141+
});
142+
143+
it('should fetch Solana token metadata successfully', async () => {
144+
const solanaChainId = MultichainNetwork.Solana;
145+
const solanaAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
146+
const solanaAssetId = `${solanaChainId}/token:${solanaAddress}`;
147+
148+
const mockMetadata = {
149+
assetId: solanaAssetId,
150+
symbol: 'SOL',
151+
name: 'Solana Token',
152+
decimals: 9,
153+
};
154+
155+
(fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]);
156+
157+
const result = await fetchAssetMetadata(solanaAddress, solanaChainId);
158+
159+
expect(result).toStrictEqual({
160+
symbol: 'SOL',
161+
decimals: 9,
162+
image:
163+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.png',
164+
assetId: solanaAssetId,
165+
address: solanaAddress,
166+
chainId: solanaChainId,
167+
});
168+
});
169+
170+
it('should handle CAIP chain IDs', async () => {
171+
const mockMetadata = {
172+
assetId: mockAssetId,
173+
symbol: 'TEST',
174+
name: 'Test Token',
175+
decimals: 18,
176+
};
177+
178+
(fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]);
179+
180+
const result = await fetchAssetMetadata(mockAddress, mockChainId);
181+
182+
expect(toEvmCaipChainId).not.toHaveBeenCalled();
183+
184+
expect(fetchWithCache).toHaveBeenCalledWith({
185+
url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`,
186+
fetchOptions: {
187+
method: 'GET',
188+
headers: { 'X-Client-Id': 'extension' },
189+
},
190+
cacheOptions: {
191+
cacheRefreshTime: MINUTE,
192+
},
193+
functionName: 'fetchAssetMetadata',
194+
});
195+
196+
expect(result).toStrictEqual({
197+
symbol: 'TEST',
198+
decimals: 18,
199+
image:
200+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png',
201+
assetId: mockAssetId,
202+
address: mockAddress,
203+
chainId: mockHexChainId,
204+
});
205+
});
206+
207+
it('should handle hex chain IDs', async () => {
208+
const mockMetadata = {
209+
assetId: mockAssetId,
210+
symbol: 'TEST',
211+
name: 'Test Token',
212+
decimals: 18,
213+
};
214+
215+
(fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]);
216+
217+
const result = await fetchAssetMetadata(mockAddress, mockHexChainId);
218+
219+
expect(toEvmCaipChainId).toHaveBeenCalledWith(mockHexChainId);
220+
221+
expect(fetchWithCache).toHaveBeenCalledWith({
222+
url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`,
223+
fetchOptions: {
224+
method: 'GET',
225+
headers: { 'X-Client-Id': 'extension' },
226+
},
227+
cacheOptions: {
228+
cacheRefreshTime: MINUTE,
229+
},
230+
functionName: 'fetchAssetMetadata',
231+
});
232+
233+
expect(result).toStrictEqual({
234+
symbol: 'TEST',
235+
decimals: 18,
236+
image:
237+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png',
238+
assetId: mockAssetId,
239+
address: mockAddress,
240+
chainId: mockHexChainId,
241+
});
242+
});
243+
244+
it('should return undefined when API call fails', async () => {
245+
(fetchWithCache as jest.Mock).mockRejectedValueOnce(
246+
new Error('API Error'),
247+
);
248+
249+
const result = await fetchAssetMetadata(mockAddress, mockHexChainId);
250+
251+
expect(result).toBeUndefined();
252+
});
253+
254+
it('should return undefined when metadata processing fails', async () => {
255+
(fetchWithCache as jest.Mock).mockResolvedValueOnce([null]);
256+
257+
const result = await fetchAssetMetadata(mockAddress, mockHexChainId);
258+
259+
expect(result).toBeUndefined();
260+
});
261+
262+
it('should return undefined when EVM address is not valid', async () => {
263+
const mockMetadata = {
264+
assetId: 'hjk',
265+
symbol: 'TEST',
266+
name: 'Test Token',
267+
decimals: 18,
268+
};
269+
270+
(fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]);
271+
272+
const result = await fetchAssetMetadata(mockAddress, mockHexChainId);
273+
274+
expect(fetchWithCache).toHaveBeenCalledWith({
275+
url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`,
276+
fetchOptions: {
277+
method: 'GET',
278+
headers: { 'X-Client-Id': 'extension' },
279+
},
280+
cacheOptions: {
281+
cacheRefreshTime: MINUTE,
282+
},
283+
functionName: 'fetchAssetMetadata',
284+
});
285+
286+
expect(result).toStrictEqual({
287+
symbol: 'TEST',
288+
decimals: 18,
289+
image:
290+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png',
291+
assetId: mockAssetId,
292+
address: mockAddress,
293+
chainId: mockHexChainId,
294+
});
295+
});
296+
});
297+
});

0 commit comments

Comments
 (0)