Skip to content

Commit 19636de

Browse files
authored
feat: Graceful Perps icon fallback (MetaMask#24340)
## **Description** This PR implements a graceful degradation pattern for Perps asset icons, allowing the app to first attempt loading icons from MetaMask's contract-metadata repository and automatically fall back to HyperLiquid's CDN if the primary source fails. We want to host curated Perps icons in our own contract-metadata repo for quality control. However, not all icons may be uploaded yet, and we need a reliable fallback. This approach allows gradual migration while maintaining full icon coverage Solution: 1. Re-added `METAMASK_PERPS_ICONS_BASE_URL` constant pointing to contract-metadata repo 2. Created `getAssetIconUrls()` function that returns both primary and fallback URLs 3. Updated PerpsTokenLogo component to try primary URL first, then fallback on error 4. HIP-3 assets use the hip3:dex_SYMBOL.svg format for MetaMask URLs In cases where both primary and fallback urls fail, we fallback to the default two letter icon. ## **Changelog** CHANGELOG entry: Perps icon fallback url mechanism ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2329 ## **Manual testing steps** ```gherkin Feature: Perps Token Icon Fallback Scenario: User views Perps market list with icons from MetaMask repo Given the user has opened the Perps tab When the market list loads Then icons load from MetaMask contract-metadata URL first And if MetaMask URL fails, icons fall back to HyperLiquid URL And if both URLs fail, a 2-letter text fallback is displayed Scenario: User views HIP-3 asset icons Given the user has opened a HIP-3 market (e.g., xyz:AAPL) When the icon loads Then the primary URL uses format hip3:xyz_AAPL.svg And the fallback URL uses format xyz:AAPL.svg ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/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] > Implements a two-source icon strategy for Perps, prioritizing MetaMask’s `contract-metadata` with automatic fallback to HyperLiquid, plus comprehensive tests. > > - Adds `METAMASK_PERPS_ICONS_BASE_URL` and new `getAssetIconUrls()` in `marketUtils` to return `{ primary, fallback }` URLs (supports HIP-3 `hip3:dex_SYMBOL.svg` and `k`-prefix handling) > - Updates `PerpsTokenLogo` to try `primary` first, switch to `fallback` on `onError`, reset to primary on symbol change, and show 2-letter text fallback if both fail; adjusts `key/recyclingKey` to reflect URL state > - Extends tests in `PerpsTokenLogo.test.tsx` and `marketUtils.test.ts` to cover URL selection, fallback flow, HIP-3 formatting, and casing > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 03ee9c9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 44dccc3 commit 19636de

5 files changed

Lines changed: 255 additions & 18 deletions

File tree

app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.test.tsx

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,14 @@ describe('PerpsTokenLogo', () => {
8080
// Empty symbol results in empty fallback text
8181
});
8282

83-
it('renders Image component with correct URI', () => {
83+
it('renders Image component with primary MetaMask URL initially', () => {
8484
const { UNSAFE_getByType } = render(
8585
<PerpsTokenLogo symbol="BTC" testID="with-image" />,
8686
);
8787

8888
const image = UNSAFE_getByType(Image);
8989
expect(image.props.source.uri).toBe(
90-
'https://app.hyperliquid.xyz/coins/BTC.svg',
90+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/BTC.svg',
9191
);
9292
expect(image.props.style).toEqual(
9393
expect.objectContaining({
@@ -97,23 +97,55 @@ describe('PerpsTokenLogo', () => {
9797
);
9898
});
9999

100-
it('handles image error by showing text fallback', async () => {
101-
// Arrange
102-
const { UNSAFE_getByType, getByTestId } = render(
100+
it('switches to fallback HyperLiquid URL when primary fails', async () => {
101+
const { UNSAFE_getByType } = render(
102+
<PerpsTokenLogo symbol="BTC" testID="fallback-test" />,
103+
);
104+
105+
const image = UNSAFE_getByType(Image);
106+
107+
// Verify initial URL is primary (MetaMask)
108+
expect(image.props.source.uri).toContain('contract-metadata');
109+
110+
// Simulate primary URL error
111+
await act(async () => {
112+
image.props.onError();
113+
});
114+
115+
// Get updated image after error
116+
const updatedImage = UNSAFE_getByType(Image);
117+
118+
// Verify fallback URL is now used (HyperLiquid)
119+
expect(updatedImage.props.source.uri).toBe(
120+
'https://app.hyperliquid.xyz/coins/BTC.svg',
121+
);
122+
});
123+
124+
it('shows text fallback when both primary and fallback URLs fail', async () => {
125+
const { UNSAFE_getByType, UNSAFE_queryByType, getByTestId } = render(
103126
<PerpsTokenLogo symbol="FAIL" testID="image-error" />,
104127
);
105128

106129
const image = UNSAFE_getByType(Image);
107130

108-
// Act - Simulate image error
131+
// First error - switches to fallback URL
109132
await act(async () => {
110133
image.props.onError();
111134
});
112135

113-
// Assert - Should show text fallback after error
136+
// Get image with fallback URL
137+
const fallbackImage = UNSAFE_getByType(Image);
138+
139+
// Second error - both URLs failed, show text fallback
140+
await act(async () => {
141+
fallbackImage.props.onError();
142+
});
143+
144+
// Verify text fallback is shown
114145
const container = getByTestId('image-error');
115146
expect(container).toBeTruthy();
116-
// Text fallback should show "FA" for "FAIL"
147+
// Image component no longer rendered, text fallback shown instead
148+
expect(UNSAFE_queryByType(Image)).toBeNull();
117149
});
118150

119151
it('correctly applies size prop to container', () => {
@@ -180,4 +212,30 @@ describe('PerpsTokenLogo', () => {
180212
const image = UNSAFE_getByType(Image);
181213
expect(image.props.source.uri).toContain('BTC.svg');
182214
});
215+
216+
it('resets to primary URL when symbol changes', async () => {
217+
const { UNSAFE_getByType, rerender } = render(
218+
<PerpsTokenLogo symbol="BTC" testID="symbol-change" />,
219+
);
220+
221+
const image = UNSAFE_getByType(Image);
222+
223+
// Trigger error to switch to fallback
224+
await act(async () => {
225+
image.props.onError();
226+
});
227+
228+
// Verify fallback is being used
229+
expect(UNSAFE_getByType(Image).props.source.uri).toContain(
230+
'app.hyperliquid.xyz',
231+
);
232+
233+
// Change symbol
234+
rerender(<PerpsTokenLogo symbol="ETH" testID="symbol-change" />);
235+
236+
// Verify primary URL is used for new symbol
237+
const newImage = UNSAFE_getByType(Image);
238+
expect(newImage.props.source.uri).toContain('contract-metadata');
239+
expect(newImage.props.source.uri).toContain('ETH.svg');
240+
});
183241
});

app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.tsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Image } from 'expo-image';
2-
import React, { memo, useMemo } from 'react';
2+
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
33
import { ActivityIndicator, View } from 'react-native';
44
import Text, {
55
TextVariant,
66
} from '../../../../../component-library/components/Texts/Text';
77
import { useTokenLogo } from '../../../../hooks/useTokenLogo';
88
import {
9-
getAssetIconUrl,
9+
getAssetIconUrls,
1010
getPerpsDisplaySymbol,
1111
} from '../../utils/marketUtils';
1212
import {
@@ -23,12 +23,27 @@ const PerpsTokenLogo: React.FC<PerpsTokenLogoProps> = ({
2323
testID,
2424
recyclingKey,
2525
}) => {
26-
// SVG URL - expo-image handles SVG rendering properly
27-
const imageUri = useMemo(() => {
26+
// Track if we should use fallback URL (after primary fails)
27+
const [useFallbackUrl, setUseFallbackUrl] = useState(false);
28+
29+
// Get both primary (MetaMask) and fallback (HyperLiquid) URLs
30+
const iconUrls = useMemo(() => {
2831
if (!symbol) return null;
29-
return getAssetIconUrl(symbol, K_PREFIX_ASSETS);
32+
return getAssetIconUrls(symbol, K_PREFIX_ASSETS);
33+
}, [symbol]);
34+
35+
// Reset fallback state when symbol changes
36+
useEffect(() => {
37+
setUseFallbackUrl(false);
3038
}, [symbol]);
3139

40+
// Select current image URL based on fallback state
41+
const imageUri = iconUrls
42+
? useFallbackUrl
43+
? iconUrls.fallback
44+
: iconUrls.primary
45+
: null;
46+
3247
// Extract display symbol (e.g., "TSLA" from "xyz:TSLA")
3348
const fallbackText = useMemo(() => {
3449
const displaySymbol = getPerpsDisplaySymbol(symbol || '');
@@ -53,6 +68,22 @@ const PerpsTokenLogo: React.FC<PerpsTokenLogoProps> = ({
5368
assetsRequiringDarkBg: ASSETS_REQUIRING_DARK_BG,
5469
});
5570

71+
// Handle image error with fallback logic:
72+
// 1. If primary URL fails, try fallback URL
73+
// 2. If fallback URL also fails, show text fallback
74+
const handleImageError = useCallback(() => {
75+
if (!useFallbackUrl && iconUrls?.fallback) {
76+
// Primary failed - try fallback URL
77+
setUseFallbackUrl(true);
78+
} else {
79+
// Both URLs failed - show text fallback
80+
handleError();
81+
}
82+
}, [useFallbackUrl, iconUrls?.fallback, handleError]);
83+
84+
// Image key includes fallback state for proper re-render when switching URLs
85+
const imageKey = `${recyclingKey || symbol}-${useFallbackUrl ? 'fallback' : 'primary'}`;
86+
5687
// Show custom two-letter fallback if no symbol or error
5788
if (!symbol || !imageUri || hasError) {
5889
return (
@@ -72,15 +103,15 @@ const PerpsTokenLogo: React.FC<PerpsTokenLogoProps> = ({
72103
</View>
73104
)}
74105
<Image
75-
key={recyclingKey || symbol} // Use recyclingKey for proper recycling
106+
key={imageKey}
76107
source={{ uri: imageUri }}
77108
style={imageStyle}
78109
onLoadStart={handleLoadStart}
79110
onLoadEnd={handleLoadEnd}
80-
onError={handleError}
111+
onError={handleImageError}
81112
contentFit="contain"
82113
cachePolicy="memory-disk" // Persistent caching across app sessions
83-
recyclingKey={recyclingKey || symbol} // For FlashList optimization
114+
recyclingKey={imageKey} // For FlashList optimization
84115
transition={0} // Disable transition for faster rendering
85116
priority="high" // High priority loading
86117
placeholder={null} // No placeholder for cleaner loading

app/components/UI/Perps/constants/hyperLiquidConfig.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,17 @@ export const HYPERLIQUID_ENDPOINTS: HyperLiquidEndpoints = {
5454
testnet: 'wss://api.hyperliquid-testnet.xyz/ws',
5555
};
5656

57-
// Asset icons base URL
57+
// Asset icons base URL (HyperLiquid CDN - fallback source)
5858
export const HYPERLIQUID_ASSET_ICONS_BASE_URL =
5959
'https://app.hyperliquid.xyz/coins/';
6060

61+
// MetaMask-hosted Perps asset icons (primary source)
62+
// Assets uploaded to: https://github.com/MetaMask/contract-metadata/tree/master/icons/eip155:999
63+
// HIP-3 assets use format: hip3:dex_SYMBOL.svg (e.g., hip3:xyz_AAPL.svg)
64+
// Regular assets use format: SYMBOL.svg (e.g., BTC.svg)
65+
export const METAMASK_PERPS_ICONS_BASE_URL =
66+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/';
67+
6168
// Asset configurations for multichain abstraction
6269
export const HYPERLIQUID_ASSET_CONFIGS: HyperLiquidAssetConfigs = {
6370
USDC: {

app/components/UI/Perps/utils/marketUtils.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
calculateFundingCountdown,
33
calculate24hHighLow,
44
getAssetIconUrl,
5+
getAssetIconUrls,
56
escapeRegex,
67
compileMarketPattern,
78
matchesMarketPattern,
@@ -16,6 +17,8 @@ import { CandlePeriod } from '../constants/chartConfig';
1617

1718
jest.mock('../constants/hyperLiquidConfig', () => ({
1819
HYPERLIQUID_ASSET_ICONS_BASE_URL: 'https://app.hyperliquid.xyz/coins/',
20+
METAMASK_PERPS_ICONS_BASE_URL:
21+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/',
1922
}));
2023

2124
describe('marketUtils', () => {
@@ -411,6 +414,77 @@ describe('marketUtils', () => {
411414
});
412415
});
413416

417+
describe('getAssetIconUrls', () => {
418+
it('returns primary and fallback URLs for regular asset', () => {
419+
const symbol = 'BTC';
420+
421+
const result = getAssetIconUrls(symbol);
422+
423+
expect(result).toEqual({
424+
primary:
425+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/BTC.svg',
426+
fallback: 'https://app.hyperliquid.xyz/coins/BTC.svg',
427+
});
428+
});
429+
430+
it('returns primary and fallback URLs for HIP-3 asset', () => {
431+
const symbol = 'xyz:TSLA';
432+
433+
const result = getAssetIconUrls(symbol);
434+
435+
expect(result).toEqual({
436+
primary:
437+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/hip3:xyz_TSLA.svg',
438+
fallback: 'https://app.hyperliquid.xyz/coins/xyz:TSLA.svg',
439+
});
440+
});
441+
442+
it('returns null for empty symbol', () => {
443+
const symbol = '';
444+
445+
const result = getAssetIconUrls(symbol);
446+
447+
expect(result).toBeNull();
448+
});
449+
450+
it('removes k prefix for specified assets', () => {
451+
const symbol = 'kBONK';
452+
const kPrefixAssets = new Set(['KBONK']);
453+
454+
const result = getAssetIconUrls(symbol, kPrefixAssets);
455+
456+
expect(result).toEqual({
457+
primary:
458+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/BONK.svg',
459+
fallback: 'https://app.hyperliquid.xyz/coins/BONK.svg',
460+
});
461+
});
462+
463+
it('uppercases lowercase symbols for regular assets', () => {
464+
const symbol = 'eth';
465+
466+
const result = getAssetIconUrls(symbol);
467+
468+
expect(result).toEqual({
469+
primary:
470+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/ETH.svg',
471+
fallback: 'https://app.hyperliquid.xyz/coins/ETH.svg',
472+
});
473+
});
474+
475+
it('formats HIP-3 assets with hip3 prefix and underscore separator', () => {
476+
const symbol = 'ABC:xyz100';
477+
478+
const result = getAssetIconUrls(symbol);
479+
480+
expect(result).toEqual({
481+
primary:
482+
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/hip3:abc_XYZ100.svg',
483+
fallback: 'https://app.hyperliquid.xyz/coins/abc:XYZ100.svg',
484+
});
485+
});
486+
});
487+
414488
describe('Pattern Matching Utilities', () => {
415489
describe('escapeRegex', () => {
416490
it('escapes regex special characters', () => {

app/components/UI/Perps/utils/marketUtils.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { CandleData, CandleStick } from '../types/perps-types';
22
import type { PerpsMarketData } from '../controllers/types';
33
import type { BadgeType } from '../components/PerpsBadge/PerpsBadge.types';
4-
import { HYPERLIQUID_ASSET_ICONS_BASE_URL } from '../constants/hyperLiquidConfig';
4+
import {
5+
HYPERLIQUID_ASSET_ICONS_BASE_URL,
6+
METAMASK_PERPS_ICONS_BASE_URL,
7+
} from '../constants/hyperLiquidConfig';
58

69
/**
710
* Maximum length for market filter patterns (prevents DoS attacks)
@@ -460,3 +463,67 @@ export const getAssetIconUrl = (
460463
// Regular asset
461464
return `${HYPERLIQUID_ASSET_ICONS_BASE_URL}${processedSymbol}.svg`;
462465
};
466+
467+
/**
468+
* Icon URLs with primary (MetaMask-hosted) and fallback (HyperLiquid) sources
469+
*/
470+
export interface AssetIconUrls {
471+
/** MetaMask contract-metadata repo (preferred source) */
472+
primary: string;
473+
/** HyperLiquid CDN (fallback source) */
474+
fallback: string;
475+
}
476+
477+
/**
478+
* Generate icon URLs for an asset symbol with fallback support
479+
*
480+
* Resolution order:
481+
* 1. Primary: MetaMask contract-metadata repo (manually curated)
482+
* 2. Fallback: HyperLiquid CDN (always available)
483+
*
484+
* @param symbol - Asset symbol (e.g., "BTC" or "xyz:TSLA")
485+
* @param kPrefixAssets - Optional set of assets that have a 'k' prefix to remove
486+
* @returns Object with primary and fallback URLs, or null if no symbol
487+
*
488+
* @example Regular asset
489+
* getAssetIconUrls('BTC')
490+
* // → { primary: 'https://raw.githubusercontent.com/.../BTC.svg',
491+
* // fallback: 'https://app.hyperliquid.xyz/coins/BTC.svg' }
492+
*
493+
* @example HIP-3 asset
494+
* getAssetIconUrls('xyz:TSLA')
495+
* // → { primary: 'https://raw.githubusercontent.com/.../hip3:xyz_TSLA.svg',
496+
* // fallback: 'https://app.hyperliquid.xyz/coins/xyz:TSLA.svg' }
497+
*/
498+
export const getAssetIconUrls = (
499+
symbol: string,
500+
kPrefixAssets?: Set<string>,
501+
): AssetIconUrls | null => {
502+
if (!symbol) return null;
503+
504+
// Check for HIP-3 asset (contains colon) BEFORE uppercasing
505+
if (symbol.includes(':')) {
506+
const [dex, assetSymbol] = symbol.split(':');
507+
// HyperLiquid uses dex:SYMBOL format
508+
const hyperliquidFormat = `${dex.toLowerCase()}:${assetSymbol.toUpperCase()}`;
509+
// MetaMask contract-metadata uses hip3:dex_SYMBOL format
510+
const metamaskFormat = `hip3:${dex.toLowerCase()}_${assetSymbol.toUpperCase()}`;
511+
return {
512+
primary: `${METAMASK_PERPS_ICONS_BASE_URL}${metamaskFormat}.svg`,
513+
fallback: `${HYPERLIQUID_ASSET_ICONS_BASE_URL}${hyperliquidFormat}.svg`,
514+
};
515+
}
516+
517+
// For regular assets, uppercase the entire symbol
518+
let processedSymbol = symbol.toUpperCase();
519+
520+
// Remove 'k' prefix only for specific assets if provided
521+
if (kPrefixAssets?.has(processedSymbol)) {
522+
processedSymbol = processedSymbol.substring(1);
523+
}
524+
525+
return {
526+
primary: `${METAMASK_PERPS_ICONS_BASE_URL}${processedSymbol}.svg`,
527+
fallback: `${HYPERLIQUID_ASSET_ICONS_BASE_URL}${processedSymbol}.svg`,
528+
};
529+
};

0 commit comments

Comments
 (0)